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]>
407 lines
14 KiB
Rust
407 lines
14 KiB
Rust
//! 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("&"),
|
||
'<' => out.push_str("<"),
|
||
'>' => out.push_str(">"),
|
||
'"' => out.push_str("""),
|
||
'\'' => out.push_str("'"),
|
||
// 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("&"));
|
||
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());
|
||
}
|
||
}
|