Files
Discord-Authenticator/crates/render/src/lib.rs
T
Miles WardandClaude Opus 4.8 7751ebe9b3 v0.1.2: working Edit sheet, centred search, brand assets
Fixes a bug that shipped in v0.1: the per-account Edit sheet never opened at
all. Its Cancel button used `onclick: dlg.close`, evaluated while building the
arguments to openModal() and therefore before `const dlg` was initialised — a
temporal-dead-zone ReferenceError that aborted the whole handler. Every other
button in that sheet was already a closure, which is why it went unnoticed:
browser testing had covered add, import and settings, but never opened this
sheet.

Renames now reach Discord almost immediately. The push loop skips any account
whose current code it has already sent, so a rename previously stayed invisible
until the code next rolled — up to a full period. update() now clears the
last-sent code when the naming actually changes, so the next tick treats the
tile as stale and rewrites it in about a second. Toggling push alone is not a
rename and still costs nothing.

- webui: pencil icon and "Edit account" title so the row action reads as edit
  rather than settings; both fields labelled, with the second one explained as
  the username/email and left untouched unless changed
- webui: topbar is a three-column grid so the search sits on the same centre
  line as the account tiles. Below 820px there isn't room for brand + 420px
  search + actions, so it falls back to a row rather than clipping the
  wordmark; the brand now reads "Discord Authenticator" in full
- webui: the add-account FAB pins to the viewport corner instead of tracking
  the content column edge
- brand: docs/brand/icon.svg plus PNGs rendered from it by da-render, so the
  README image, Gitea avatar and Unraid template icon all derive from one
  source rather than drifting. Unraid's <Icon> now points at a PNG, which it
  renders more reliably than SVG

126 tests (was 124).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 01:42:18 -04:00

407 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Draws the code tile that gets attached to each Discord message.
//!
//! The tile is authored as SVG and rasterised with resvg. Hand-blitting
//! glyphs was the alternative and it loses on every axis that matters here:
//! centring text, rounded corners and the accent gradient are one attribute
//! each in SVG, and arithmetic otherwise.
//!
//! **There is deliberately no countdown drawn on the tile.** An image is a
//! snapshot; by the time someone looks at it, any timer baked into the pixels
//! is stale and confidently wrong. Discord's own `<t:…:R>` relative timestamp
//! ticks live in the client for free, so the countdown belongs in the message
//! text and the tile stays a clean statement of the current code.
#![forbid(unsafe_code)]
use image::ImageEncoder;
use std::sync::OnceLock;
/// Rendered at 2× the nominal display size so the digits stay crisp on
/// retina displays without asking Discord to upscale.
const WIDTH: u32 = 760;
const HEIGHT: u32 = 264;
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error("could not parse the tile SVG: {0}")]
Svg(#[from] usvg::Error),
#[error("could not allocate a {0}×{1} pixmap")]
Pixmap(u32, u32),
#[error("could not encode the tile: {0}")]
Encode(#[from] image::ImageError),
}
pub type Result<T> = std::result::Result<T, RenderError>;
/// What to draw.
#[derive(Debug, Clone)]
pub struct Tile<'a> {
pub issuer: &'a str,
pub label: &'a str,
/// The code, digits only — grouping is applied here, not by the caller.
pub code: &'a str,
pub period: u64,
}
/// Encode the tile as lossless WebP.
///
/// WebP because it's what Discord renders natively and what was asked for.
/// Note that pure-Rust WebP encoding is **lossless-only** — there is no
/// pure-Rust lossy encoder, and the lossy one lives in libwebp (C), which
/// would break the static musl build. That is fine here: the tile is flat
/// colour and large text, exactly the content lossless compresses well, and
/// the result lands in the low tens of kilobytes.
pub fn tile_webp(tile: &Tile<'_>) -> Result<Vec<u8>> {
let rgba = rasterise(tile)?;
let mut out = Vec::new();
image::codecs::webp::WebPEncoder::new_lossless(&mut out).write_image(
&rgba,
WIDTH,
HEIGHT,
image::ExtendedColorType::Rgba8,
)?;
Ok(out)
}
/// Encode the tile as PNG. Kept as a fallback and for tests.
pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
let rgba = rasterise(tile)?;
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::png::PngEncoder::new(&mut out).write_image(
&rgba,
WIDTH,
HEIGHT,
image::ExtendedColorType::Rgba8,
)?;
Ok(out.into_inner())
}
/// Rasterise arbitrary SVG source to a PNG at a given size.
///
/// Exposed so the brand assets (README image, Gitea avatar, Unraid template
/// icon) are generated from the same `icon.svg` the app ships, rather than
/// being hand-exported and drifting out of sync with it.
pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result<Vec<u8>> {
let tree = usvg::Tree::from_str(svg, options())?;
let mut pixmap =
tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?;
// Scale the document to fill the requested pixmap.
let size = tree.size();
let scale = (width as f32 / size.width()).min(height as f32 / size.height());
resvg::render(
&tree,
tiny_skia::Transform::from_scale(scale, scale),
&mut pixmap.as_mut(),
);
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::png::PngEncoder::new(&mut out).write_image(
&rgba,
width,
height,
image::ExtendedColorType::Rgba8,
)?;
Ok(out.into_inner())
}
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
let svg = build_svg(tile);
let tree = usvg::Tree::from_str(&svg, options())?;
let mut pixmap =
tiny_skia::Pixmap::new(WIDTH, HEIGHT).ok_or(RenderError::Pixmap(WIDTH, HEIGHT))?;
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
// tiny-skia stores premultiplied alpha; PNG and WebP both want
// straight alpha. Skipping this makes every antialiased edge — and the
// rounded corners in particular — render subtly too dark.
let mut rgba = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
Ok(rgba)
}
/// Font database and render options, built once.
///
/// `fontdb_mut()` clones the database when it's already shared, so building
/// this per render would copy both faces on every push.
fn options() -> &'static usvg::Options<'static> {
static OPTS: OnceLock<usvg::Options<'static>> = OnceLock::new();
OPTS.get_or_init(|| {
let mut opt = usvg::Options::default();
{
let db = opt.fontdb_mut();
db.load_font_data(FONT_REGULAR.to_vec());
db.load_font_data(FONT_BOLD.to_vec());
}
// Must match the family name inside the TTFs, or resvg falls back to
// its default ("Times New Roman") and finds nothing.
opt.font_family = "JetBrains Mono".to_string();
opt
})
}
const FONT_REGULAR: &[u8] = include_bytes!("../assets/JetBrainsMono-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../assets/JetBrainsMono-Bold.ttf");
/// Group a code the way every authenticator app does: `123 456`, `1234 5678`.
fn group(code: &str) -> String {
let n = code.chars().count();
if n < 6 {
return code.to_string();
}
let mid = if n == 8 { 4 } else { n.div_ceil(2) };
let (a, b) = code.split_at(mid);
format!("{a} {b}")
}
/// Stable per-issuer accent hue, matching how the web UI colours avatars so
/// a tile in Discord reads as the same account as the row in the browser.
fn hue_for(seed: &str) -> u16 {
let mut hash: i32 = 0;
for ch in seed.chars() {
hash = hash.wrapping_mul(31).wrapping_add(ch as i32);
}
(hash.unsigned_abs() % 360) as u16
}
/// HSL → `#rrggbb`. Saturation and lightness are fixed so every generated
/// accent sits at the same visual weight regardless of hue.
#[allow(clippy::many_single_char_names)] // conventional names for colour maths
fn hsl_hex(h: f32, s: f32, l: f32) -> String {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", to(r1), to(g1), to(b1))
}
/// XML-escape text that lands inside the SVG. Issuer and label are
/// user-controlled, so an unescaped `&` or `<` would produce an SVG that
/// fails to parse — and the tile would silently stop updating.
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
// Control characters are not legal XML content at all.
c if (c as u32) < 0x20 && c != '\t' => out.push(' '),
c => out.push(c),
}
}
out
}
/// Truncate to a character budget, with an ellipsis. The tile is fixed
/// width, so an over-long issuer would otherwise run off the edge.
fn clip(s: &str, max: usize) -> String {
let n = s.chars().count();
if n <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
fn build_svg(tile: &Tile<'_>) -> String {
let hue = hue_for(if tile.issuer.is_empty() {
tile.label
} else {
tile.issuer
});
let accent = hsl_hex(f32::from(hue), 0.62, 0.58);
let accent_soft = hsl_hex(f32::from(hue), 0.55, 0.42);
let issuer = esc(&clip(tile.issuer, 26));
let label = esc(&clip(tile.label, 34));
let code = esc(&group(tile.code));
// Long codes need to shrink to stay inside the tile. 8 digits plus a
// separator is 9 glyphs against 6+1=7 for the common case.
let code_size = if tile.code.chars().count() >= 8 {
96
} else {
116
};
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1c2027"/>
<stop offset="1" stop-color="#141821"/>
</linearGradient>
<linearGradient id="rail" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{accent}"/>
<stop offset="1" stop-color="{accent_soft}"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="{WIDTH}" height="{HEIGHT}" rx="28" fill="url(#bg)"/>
<rect x="0.75" y="0.75" width="{ring_w}" height="{ring_h}" rx="27.25"
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1.5"/>
<!-- Accent rail, tinted per issuer to match the web UI's avatar colour. -->
<rect x="30" y="34" width="9" height="{rail_h}" rx="4.5" fill="url(#rail)"/>
<text x="62" y="74" font-family="JetBrains Mono" font-weight="700"
font-size="30" fill="#f2f4f8">{issuer}</text>
<text x="62" y="108" font-family="JetBrains Mono" font-weight="400"
font-size="21" fill="#98a2b3">{label}</text>
<text x="62" y="{code_y}" font-family="JetBrains Mono" font-weight="700"
font-size="{code_size}" fill="#ffffff" letter-spacing="4">{code}</text>
<text x="{meta_x}" y="{meta_y}" text-anchor="end" font-family="JetBrains Mono"
font-weight="400" font-size="18" fill="#6b7484">{period}s</text>
</svg>"##,
WIDTH = WIDTH,
HEIGHT = HEIGHT,
ring_w = WIDTH - 1,
ring_h = HEIGHT - 1,
rail_h = HEIGHT - 68,
code_y = HEIGHT - 52,
meta_x = WIDTH - 32,
meta_y = HEIGHT - 30,
period = tile.period,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample<'a>(issuer: &'a str, code: &'a str) -> Tile<'a> {
Tile {
issuer,
label: "[email protected]",
code,
period: 30,
}
}
#[test]
fn renders_webp_with_a_valid_header() {
let bytes = tile_webp(&sample("Cloudflare", "123456")).unwrap();
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
// RIFF container with a WEBP fourcc at offset 8.
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WEBP");
}
#[test]
fn webp_round_trips_back_to_the_expected_size() {
let bytes = tile_webp(&sample("Plex", "987654")).unwrap();
let decoded = image::load_from_memory(&bytes).expect("decodes as an image");
assert_eq!(decoded.width(), WIDTH);
assert_eq!(decoded.height(), HEIGHT);
}
#[test]
fn renders_png_too() {
let bytes = tile_png(&sample("Ubisoft", "246810")).unwrap();
assert_eq!(&bytes[1..4], b"PNG");
}
#[test]
fn actually_draws_something() {
// A blank canvas would still encode cleanly, so assert the render
// produced non-background pixels — this is what catches the font
// database silently failing to load.
let rgba = rasterise(&sample("Cloudflare", "123456")).unwrap();
let white = rgba
.chunks_exact(4)
.filter(|p| p[0] > 220 && p[1] > 220 && p[2] > 220 && p[3] > 200)
.count();
assert!(
white > 500,
"expected the digits to paint many near-white pixels, saw {white}"
);
}
#[test]
fn code_grouping() {
assert_eq!(group("123456"), "123 456");
assert_eq!(group("12345678"), "1234 5678");
assert_eq!(group("1234567"), "1234 567");
assert_eq!(group("12345"), "12345");
}
#[test]
fn issuer_hue_is_stable_and_varies() {
assert_eq!(hue_for("Cloudflare"), hue_for("Cloudflare"));
assert_ne!(hue_for("Cloudflare"), hue_for("Plex"));
}
#[test]
fn hsl_conversion_spans_the_wheel() {
// Six anchor hues must land in six distinct colours, and stay in range.
let mut seen = std::collections::HashSet::new();
for h in [0.0, 60.0, 120.0, 180.0, 240.0, 300.0] {
let hex = hsl_hex(h, 0.62, 0.58);
assert_eq!(hex.len(), 7, "{hex}");
assert!(hex.starts_with('#'));
seen.insert(hex);
}
assert_eq!(seen.len(), 6);
}
#[test]
fn xml_special_characters_cannot_break_the_svg() {
// An unescaped ampersand here would make the SVG unparseable and the
// account's tile would stop updating with no obvious cause.
let tile = Tile {
issuer: "Ben & Jerry's <Corp>",
label: "a\"b'c & <script>",
code: "111222",
period: 30,
};
assert!(tile_webp(&tile).is_ok());
let svg = build_svg(&tile);
assert!(svg.contains("&amp;"));
assert!(!svg.contains("<script>"));
}
#[test]
fn over_long_names_are_clipped() {
let long = "X".repeat(200);
let tile = Tile {
issuer: &long,
label: &long,
code: "123456",
period: 30,
};
assert!(tile_webp(&tile).is_ok());
assert!(clip(&long, 26).chars().count() <= 26);
}
#[test]
fn eight_digit_and_unicode_content_render() {
assert!(tile_webp(&sample("Steam", "12345678")).is_ok());
assert!(tile_webp(&sample("Ünïcøde ✓ Ω", "123456")).is_ok());
// Empty issuer must not panic — hue falls back to the label.
assert!(tile_webp(&sample("", "123456")).is_ok());
}
}