Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

ashlar is a Rust library for building desktop apps (and, for its headless core, web apps) on egui. It gives an app the parts most apps rebuild by hand:

  • Documents with undo: the document is an Arc snapshot with a revision number, every edit is an undo step, and the file format is versioned with migrations. Autosave and crash recovery come with it.
  • Commands: one registry drives the menus, keyboard shortcuts, the command palette, a CLI, a control port and an MCP server. Anything a user can do, a script or an AI agent can do too.
  • Background jobs with progress, cancellation and panic isolation, whose results land on the UI thread as one undo step.
  • Files and settings: per-app folders, atomic writes, keyed settings, recent files.
  • Native OS features: file dialogs, the menu bar, a tray icon, global shortcuts, notifications, drag and drop, single instance.
  • UI components on egui: lists and tables that stay fast at any length, a sidebar, tabs, forms, dialogs and popovers, toasts, markdown, code and diff views, a chat kit, animated values.
  • A runtime with crash safety, telemetry, and a headless mode for tests and agents.

Domain widgets (a timeline for a video editor, a canvas for a CAD tool) stay in the app. ashlar provides the general pieces they are built from.

The component gallery (cargo run -p ashlar-example-gallery), rendered headless.

Why

  • Everything is a command. Menus, shortcuts, the palette, the CLI, the control port and MCP all go through one registry and one execute chokepoint.
  • It does not crash. The library has no unwrap, panic! or unsafe outside tests. A panic in a view, a command or a job is caught, the session keeps running, and unsaved work is recovered on the next launch.
  • An idle app does no work. Frames are drawn only when something asks for one: input, a document change, a finished job, a running animation.
  • Apps can be tested and driven without a window. Every app takes --headless --snap out.png --tree tree.json, runs .ui scripts on a virtual clock, and serves golden screenshots and named scenes.

Status

Version0.2. The API will change between minor versions until 1.0.
LicenseBSD 3-Clause
Rust1.95 or newer (edition 2024)
egui / eframe0.36, wgpu renderer
PlatformsmacOS (Apple silicon and Intel), Windows 10+ x86_64, Linux x86_64 (X11 and Wayland), web (wasm32-unknown-unknown, experimental for the windowed runtime)

The full API reference is on docs.rs/ashlar-rs. The source is on GitHub.

Where to start

  1. Install the crate.
  2. Build your first app.
  3. Read the notes example in the repository (examples/notes): a complete app with typed commands, undo, autosave, a virtualized list, scenes and a notes-cli binary.

Installation

cargo add ashlar-rs

The package is published as ashlar-rs; the library is imported as ashlar:

use ashlar::prelude::*;

Documents are serialized with serde, so most apps also add it:

cargo add serde --features derive

Feature flags

FeatureDefaultWhat it adds
accesskitonScreen readers and other assistive technology see the window (AccessKit)
headlesson--headless on the app’s command line: no window, .ui scripts, --snap, --tree
native-menuoffThe app’s commands in the macOS and Windows menu bar
trayoffA tray or status-bar icon with a menu (App::tray)
hotkeyoffSystem-wide shortcuts (App::global_shortcut)
telemetryoffCrash, performance and usage reports to a server you host
cargo add ashlar-rs --features native-menu,telemetry

default-features = false drops accesskit and headless for a smaller binary (about 1 MB).

What the umbrella crate contains

ashlar re-exports the layers as modules: ashlar::app, ashlar::command, ashlar::store, ashlar::jobs, ashlar::fs, ashlar::native, ashlar::ui, ashlar::control, ashlar::cli, ashlar::mcp, plus ashlar::egui and ashlar::serde_json. ashlar::prelude has what most apps need: App, Cx, Session, Document, Driver, Effect, CommandSpec, CommandError, Param, Params, cmd!, query!, always, Jobs, apply, Format, Revision, ToastLevel, egui, Value and json!.

Some crates are not re-exported. Add them yourself when you need them:

CrateWhen
ashlar-motionSpring and tween curves for Cx::motion and ashlar::ui::motion
ashlar-diffBuilding the FileDiffs that ashlar::ui::diff_view shows
ashlar-debug.ui script reports and golden snapshots in tests (usually a dev-dependency)
ashlar-native with notifySystem notifications

Each ashlar-* crate also works on its own. A headless tool that only needs documents and undo can depend on ashlar-store alone.

Linux build dependencies

Debian and Ubuntu package names:

sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev

Binary size

The smallest app (examples/hello) is 13.6 MB stripped in a release build on macOS arm64. A profile with fat LTO, one codegen unit, opt-level = "s" and stripping brings it to 9.0 MB:

[profile.release-small]
inherits = "release"
lto = "fat"
codegen-units = 1
opt-level = "s"
strip = true
cargo build --profile release-small

Your first app

This is the example from the README: a list of notes with one command. It is compiled as a doc test, so it stays correct.

cargo new jot && cd jot
cargo add ashlar-rs
cargo add serde --features derive
use ashlar::prelude::*;

#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
struct Doc {
    notes: Vec<String>,
}

fn add(s: &mut Session<Doc>, p: &Value) -> Result<Value, CommandError> {
    let text = p.get("text").and_then(Value::as_str).unwrap_or("New note").to_string();
    s.edit("Add Note", |d| d.notes.push(text)); // one undo step per command
    Ok(json!(s.doc().notes.len()))
}

fn main() -> Result<(), ashlar::app::Error> {
    App::<Doc>::new("jot", env!("CARGO_PKG_VERSION"))
        .format(Format::new("example.jot", "jot 0.1"))
        .commands([cmd!("note.add", "Add Note", ["Edit"], Some("Mod+Shift+N"), [Param::string("text")], always, add)])
        .view(|ui, cx: &mut Cx<'_, Doc>| {
            for n in &cx.doc().notes {
                ui.label(n);
            }
            if ui.button("Add").clicked() {
                cx.dispatch("note.add", json!({}));
            }
        })
        .run() // undo/redo, save/open/autosave, menus, palette, crash guard, control port
}
cargo run

What each part does

The document. Doc is any type that is Clone + Default + Serialize + Deserialize + Send + Sync + 'static; such types implement Document automatically. The session keeps it as an Arc<Doc> and gives each state a Revision.

The command. add runs on the Session<Doc>. s.edit(label, f) changes the document and records one undo step named “Add Note”. The command reads its parameters from a JSON Value and returns a JSON result, which is what the CLI, the control port and MCP see.

cmd! registers it: id note.add, label “Add Note”, in the Edit menu, on Mod+Shift+N (Mod is ⌘ on macOS and Ctrl elsewhere), with one optional string parameter text, always enabled. See Commands.

Format names the file format (example.jot) and the program that writes it (jot 0.1). Without one the app gets a default format called <name>.document. Either way the app has File > New, Open, Save, Save As and Revert, and autosave with recovery. Setting a format (or a file extension) also turns on opening files dropped on the window. See Documents and undo.

The view is a closure that draws the UI each frame with egui. Cx gives it the document, and cx.dispatch runs a command after the view returns. See Views and components.

run() opens the window. It also adds:

  • Undo and Redo (Mod+Z, Mod+Shift+Z), and the File and View menus.
  • The command palette (Mod+Shift+P) and a keyboard shortcut editor.
  • Autosave every 60 seconds and a recovery dialog after a crash.
  • A crash guard: a panic in the view shows an error instead of closing the window.
  • --control <port> for agents, and --headless for tests (see Testing apps).

Smaller still

The smallest app has an empty document and no commands:

use ashlar::prelude::*;

#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
struct Doc {}

fn main() -> Result<(), ashlar::app::Error> {
    let mut on = false;
    App::<Doc>::new("hello", env!("CARGO_PKG_VERSION"))
        .view(move |ui, _cx: &mut Cx<'_, Doc>| {
            ui.heading("Hello, ashlar");
            ui.checkbox(&mut on, "Switch");
        })
        .run()
}

More builder options

MethodWhat it sets
.title("Notes")The window title (default: the app name)
.organization("com", "Acme")The reverse-domain prefix and organisation used for the app’s folders
.file_extension("notes")The extension of the app’s documents (open dialog, drops)
.window_size(960.0, 640.0)The first window size
.viewport(|b| b.with_min_inner_size([480.0, 320.0]))Any egui ViewportBuilder option
.icon(include_bytes!("icon.png"))The window and taskbar icon (PNG bytes)
.setup(|ctx| { … })Runs once with the egui context before the first frame (fonts)
.autosave(Some(Duration::from_secs(30)))Autosave interval; None turns autosave and recovery off
.remember_window(true)Restore the window’s position and size (default on)
.follow_system_accent(true)Take the accent colour from the OS (default on)
.instructions("…")What MCP clients are told about the app
.scene("empty", |s| { … })A named starting state for tests and agents

The full list is on docs.rs.

Documents and undo

The session

A command runs on a Session<D>, and a view reaches the same session through Cx. The session holds the document store, the file it came from and view state.

CallWhat it does
s.doc()The current document, &D
s.snapshot()The current document as an Arc<D>, to keep or send to a job
s.revision()The Revision of the current state; key caches by it
s.is_dirty()Unsaved changes since the last save
s.edit(label, |d| …)Change the document as one undo step called label
s.try_edit(label, |d| …)The same, but f returns a Result; on Err nothing changes
s.edit_merged(label, key, |d| …)Fold this edit into the previous one with the same label and key
s.undo() / s.redo()Step through the history; returns the label
s.can_undo() / s.can_redo()Whether there is a step

Edits are copy-on-write: the store clones the document once per edit, so keep large parts behind Arc and use Arc::make_mut for the part you change. The notes example keeps each note as Arc<Note>, so editing one note copies one note, not all of them.

fn delete(s: &mut Session<Notes>, p: &Value) -> Result<Value, CommandError> {
    let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| CommandError::bad_params("", "`id` is required"))?;
    s.try_edit("Delete Note", |d| {
        let i = d.index_of(id).ok_or_else(|| CommandError::failed("", format!("no note {id}")))?;
        d.notes.remove(i);
        Ok(json!({"deleted": id}))
    })
}

Gestures as one step

Typing into a field or dragging a slider produces many edits. edit_merged folds them into one undo step as long as the label and merge key stay the same:

if response.changed() {
    cx.edit_merged("Edit Note", &format!("note-{id}"), |d| d.set_title(id, &title));
}

View state

View state is data that is not in the document and not undoable: the selection, a scroll target, an open panel. It is JSON keyed by name, and agents can set it with the ui.set control method.

s.set_view("selected", &id);
let selected: Option<u64> = s.view("selected");
let zoom = s.view_or("zoom", 1.0_f64);

File formats

A Format describes how documents are stored: a JSON envelope with the format id, the program that wrote it, a schema version and the document.

fn v1_to_v2(mut file: Value) -> Result<Value, String> {
    // rewrite file["document"] from schema 1 to schema 2
    Ok(file)
}

let format = Format::<Notes>::new("acme.notes", concat!("Notes ", env!("CARGO_PKG_VERSION")))
    .migrations(vec![v1_to_v2]);
  • The schema version is the number of migrations plus one. Add a migration whenever the document shape changes; older files are migrated on open.
  • Files from a newer version, or of another format, are refused with a FormatError (TooNew, WrongFormat) instead of being misread.
  • .max_bytes(n) caps the size of a file that will be read; .pretty(true) writes indented JSON.
  • Saves are atomic: a crash during a save leaves the old file or the new one, never a mix.

Saving and opening

The built-in commands handle files: file.new, file.open, file.save, file.save_as, file.revert and app.quit. They ask before discarding unsaved changes, and agents can pass {"path": "…"} to file.open and file.save_as to skip the dialog.

From code, the session has s.open(path), s.save(), s.save_as(path), s.new_document(), s.path() and s.display_name().

Autosave and recovery

Autosave is on by default, every 60 seconds while the document has unsaved changes. Snapshots go to a Recovery folder in the app’s data folder. When the app ends normally they are removed. When it crashes or is killed, the next launch finds them and shows a dialog that offers to recover the work.

App::<Doc>::new("jot", "0.1.0")
    .autosave(Some(std::time::Duration::from_secs(30))) // or None to turn it off

Asking the window for something

A command has no window. It asks for one with an Effect, which a headless session (CLI, MCP) ignores:

s.toast(ToastLevel::Success, "Exported");
s.request(Effect::OpenPalette);
s.request(Effect::PickSaveAs);

The effects are Toast, OpenPalette, OpenKeymap, Theme, PickOpen, PickSaveAs, ConfirmDiscard and Quit.

Using the store on its own

The store is the ashlar-store crate. It is headless and builds for wasm32, so a CLI tool or a server can use Store, History, Format and Autosave without the UI. See docs.rs/ashlar-store.

Commands

A command is a named action with typed parameters. Every command an app registers shows up in the menus, the command palette, the keyboard shortcut editor, the app’s CLI, its control port and its MCP server. They all run it through the same execute chokepoint, which validates the parameters, checks that the command is enabled, catches panics and records undo.

Writing one

A command body is a plain function from the session and the JSON parameters to a JSON result:

fn add(s: &mut Session<Notes>, p: &Value) -> Result<Value, CommandError> {
    let title = p.get("title").and_then(Value::as_str).unwrap_or("Untitled note").to_string();
    let id = s.edit("New Note", |d| d.push(title));
    s.set_view("selected", &id);
    Ok(json!({"id": id, "count": s.doc().notes.len()}))
}

Return CommandError::bad_params(id, msg) for bad input and CommandError::failed(id, msg) when the command cannot do its work. An empty id is filled in with the command’s own id.

Registering it

CommandSpec::new and its const setters:

const ADD: &[Param] = &[Param::string("title"), Param::string("body")];
const ID: &[Param] = &[Param::int_in("id", 1, i64::MAX).about("default: the selected note")];

pub fn commands() -> Vec<CommandSpec<Session<Notes>>> {
    vec![
        CommandSpec::new("note.add", "New Note", add).menu(&["Note"]).shortcut("Mod+Shift+N").params(Params::Static(ADD)),
        CommandSpec::new("note.delete", "Delete Note", delete)
            .menu(&["Note"])
            .shortcut("Mod+Backspace")
            .params(Params::Static(ID))
            .enabled(has_target),
        CommandSpec::new("note.list", "List Notes", list).params(Params::Static(LIST)).query(),
    ]
}

or the cmd! and query! macros, which fit a command on one line:

cmd!("note.generate", "Generate Notes…", ["Note"], None, [Param::int_in("count", 1, 1_000_000).about("how many")], always, generate)
query!("note.count", "Count Notes", [], count)

cmd! takes the id, label, menu path, shortcut, parameters, enabled predicate and body. query! makes a command that only reads: always enabled, not in menus, not recorded in the command journal. .query() does the same for a CommandSpec.

Pass the list to the app with App::commands(…), or one at a time with App::command(…).

Parameters

ConstructorJSON
Param::bool("name")boolean
Param::int("name"), Param::int_in("name", min, max)integer
Param::float("name"), Param::float_in("name", min, max)number
Param::string("name")string
Param::one_of("name", &["a", "b"])one of these strings
Param::json("name")any JSON value

.required() makes a parameter mandatory and .about("…") describes it. The registry turns the list into a JSON Schema, which the MCP server and describe show, and validates every call against it before the body runs.

Enabled predicates

An enabled predicate returns Err(reason) to disable a command. Menus and the palette grey it out and show the reason; a call through the CLI or MCP fails with that reason.

fn has_target(s: &Session<Notes>) -> Result<(), String> {
    match s.view::<u64>("selected") {
        Some(id) if s.doc().get(id).is_some() => Ok(()),
        _ => Err("no note selected".into()),
    }
}

always is the predicate of a command that is always available.

.menu(&["Note"]) puts the command in the Note menu; a longer path makes submenus. The built-in File, Edit and View menus are already there. Menus are drawn in the window; with the native-menu feature they go to the macOS and Windows menu bar.

Shortcuts use one portable syntax: Mod+Shift+S, where Mod is ⌘ on macOS and Ctrl elsewhere, and Ctrl is the literal Control key. Several shortcuts are separated by / ("Mod+Shift+Z / Mod+Y"). Users can rebind them in the Keyboard Shortcuts editor (View > Keyboard Shortcuts…, the app.keymap command), which also shows conflicts.

The palette

Mod+Shift+P (the app.palette command) opens the command palette. It lists the app’s commands with their menu paths and shortcuts and filters them with a fuzzy matcher.

Running commands from a view

if ui.button("New Note").clicked() {
    cx.dispatch("note.add", json!({"title": "Groceries"})); // runs after the view returns; errors become toasts
}
if let Ok(result) = cx.execute("note.list", json!({"limit": 1})) { // runs now, returns the result
    ui.label(result["count"].to_string());
}

dispatch is the usual choice: the command runs after the frame’s view returns, so it never changes the document while the view is reading it.

Built-in commands

IdLabelShortcut
file.newNewMod+N
file.openOpen…Mod+O
file.saveSaveMod+S
file.save_asSave As…Mod+Shift+S
file.revertRevert to Saved
app.quitQuitMod+Q
app.undoUndoMod+Z
app.redoRedoMod+Shift+Z / Mod+Y
app.paletteCommand Palette…Mod+Shift+P
app.keymapKeyboard Shortcuts…
view.themeSwitch Theme
app.documentDocument Info

When two commands share an id, the first one registered wins and a warning is logged. The File and Edit built-ins come before the app’s commands and the View ones after, so an app can replace view.theme but not file.save.

Views and components

The view and Cx

The view is a closure FnMut(&mut egui::Ui, &mut Cx<'_, D>) that draws the whole window each frame. State the view keeps between frames lives in the closure:

fn view() -> impl FnMut(&mut egui::Ui, &mut Cx<'_, Notes>) {
    let mut query = String::new();
    move |ui, cx| {
        ui.text_edit_singleline(&mut query);
        for n in cx.doc().notes.iter().filter(|n| n.title.contains(&query)) {
            ui.label(&n.title);
        }
    }
}

What Cx offers:

CallWhat it does
cx.doc(), cx.snapshot(), cx.revision()The document, as &D or Arc<D>, and its revision
cx.dispatch(id, params)Run a command after the view returns
cx.execute(id, params)Run a command now and get its result
cx.edit(label, f), cx.edit_merged(label, key, f)Edit the document directly, as an undo step
cx.view(key), cx.view_or(key, default), cx.set_view(key, &value)View state
cx.tokens()The theme’s design tokens (cx.tokens().space.md)
cx.motion(id, target, curve)An animated value that jumps for the user’s own edits and animates for undo, agents and jobs
cx.origin()Who changed the document since the last frame
cx.dropped_files()Files dropped on the window that the runtime did not open
cx.jobs, cx.toasts, cx.settingsBackground jobs, toasts, app settings
cx.session(), cx.session_mut(), cx.ctx()The session and the egui context

Frames are drawn only when something asks for one. Cache derived data by cx.revision() instead of recomputing it each frame: the revision changes only when the document does.

Components

The components are in ashlar::ui (the ashlar-ui crate). They take state by reference and report what the user did; the app owns its data and selection. Every component registers an automation id, so tests and agents can find it, and reports AccessKit roles to screen readers.

Lists and tables

list::Row is a cheap row for short lists. For long ones, virtual_list::VirtualList lays out only the visible rows, so a frame costs the same at 100 rows or 100,000:

use ashlar::ui::virtual_list::{RowHeights, VirtualList};

let out = VirtualList::new("files", names.len(), RowHeights::Uniform(22.0))
    .selected(*selected)
    .show(ui, |ui, row| {
        ui.label(&names[row.index]);
    });
if let Some(i) = out.select {
    *selected = Some(i);
}

VirtualTable does the same with columns and sorting, selection::Selection adds multi-selection with a cursor and an anchor, and tree::TreeView shows a hierarchy.

use ashlar::ui::sidebar::{Sidebar, SidebarItem, SidebarSection};

let items: Vec<SidebarItem> = names.iter().map(|n| SidebarItem::new(n)).collect();
let sections = [SidebarSection::new("Projects", &items)];
let out = Sidebar::new("nav", "Navigation", &mut state, &sections).show(ui);
if let Some((at, name)) = out.renamed {
    names[at.item] = name;
}

Sections fold and show counts; rows have an icon, a label, a detail and a badge, and support type-ahead and rename in place.

Tabs

use ashlar::ui::tabs::TabStrip;

let out = TabStrip::new("documents", &titles, &mut selected).closable(true).reorderable(true).show(ui);
if let Some(i) = out.closed { titles.remove(i); }
if let Some((from, to)) = out.moved { let t = titles.remove(from); titles.insert(to, t); }

Tabs that do not fit go to an overflow menu. For panels that users rearrange, see Panel docking.

Controls

controls has text buttons, a switch, a segmented control, radio groups, badges, keycaps, progress bars and spinners; buttons::IconButton, slider::Slider, slider::Knob, value_field::ValueField (a numeric drag field), swatches::SwatchGrid and search::SearchField cover the rest.

use ashlar::ui::controls::{Button, Switch, Variant};

if Button::new("export.run", "Export").variant(Variant::Primary).loading(busy).show(ui).clicked() {
    cx.dispatch("doc.export", json!({}));
}
Switch::new("settings.sync", "Sync", &mut sync).show(ui);

Value edits (fields, sliders, knobs) report edit::EditPhase Begin, Update, Commit and Cancel, which map onto edit_merged for one undo step per drag.

Forms

The settings-form pattern: sections of rows with a label, a description and a control, a “modified” marker with a reset button, and a filter.

use ashlar::ui::{controls::Switch, form};

form::filter(ui, "settings", &query, |f| {
    f.section("general", "General", |s| {
        s.row("launch", "Launch at login")
            .description("Starts with your session")
            .keywords(&["startup", "boot"])
            .bind(&mut launch, false)
            .control(|ui, id, on| Switch::new(id, "", on).show(ui));
    });
});

Overlays and dialogs

overlay::Select, overlay::Combobox and overlay::Popover float next to a control; ashlar::ui::help(response, text) adds a tooltip that screen readers also read. dialog::Dialog is a modal with keyboard defaults:

use ashlar::ui::dialog::{Dialog, DialogButton, DialogOutcome};

let buttons = [DialogButton::cancel("Cancel"), DialogButton::destructive("Delete").default()];
match Dialog::new("confirm-delete", "Delete 3 files?", &buttons).show(ui.ctx(), |ui| {
    ui.label("This cannot be undone.");
}) {
    Some(DialogOutcome::Button(1)) => { /* delete */ }
    Some(_) => { /* closed */ }
    None => {}
}

Toasts and the status bar

use ashlar::ui::toasts::Toast;

cx.toasts.push(Toast::success("Exported 12 files").action("Undo", "app.undo"));

From a command, use s.toast(ToastLevel::Info, "…"). With App::run_toast_actions(true), a clicked action whose command is registered runs like a menu pick.

status_bar::StatusBar is a strip along the bottom with left and right sections; content has EmptyState, Banner, Card, Collapsible and Accordion.

Markdown, code and diffs

use ashlar::ui::{code_view::CodeView, markdown};

let style = markdown::MarkdownStyle::from_visuals(ui.visuals(), 14.0);
markdown::show(ui, "help", text, &style);

CodeView::new("preview", source).lang_for_path("src/main.rs").revision(rev).show(ui);

The markdown view handles headings, lists, task lists, tables, code blocks with a Copy button and links, and is safe to call on text that is still streaming in. CodeView is virtualized, with line numbers, selection and syntax colours for 18 languages. diff_view::DiffView shows unified or split diffs of ashlar_diff::FileDiffs with word-level emphasis.

Chat

chat has a transcript, message and tool-call views, and a composer with an @ / / popup. They draw plain data and know nothing about where messages come from.

use ashlar::ui::chat::{Composer, Message, Transcript};

Transcript::new("chat", &messages, revision).stick_to_bottom(true).show(ui, |ui, m| {
    let id = format!("chat.msg.{}", m.id);
    Message::new(&id, m.role, &m.text).streaming(m.streaming).show(ui);
});
if let Some(text) = Composer::new("chat.input", &mut draft).placeholder("Ask anything").show(ui).submitted {
    // send `text`
}

Motion

ashlar::ui::motion animates values with springs that keep their velocity when the target changes, and asks for frames only while something moves:

use ashlar::ui::motion;

let alpha = motion::opacity(ui.ctx(), id, visible);
let width = motion::f32(ui.ctx(), id.with("w"), if open { 240.0 } else { 0.0 }, ashlar_motion::tokens::SNAP.enter);

Curves come from the ashlar-motion crate (tokens::SNAP, SETTLE, GLIDE, POP and others). Motion follows the OS reduced-motion setting; App::motion(prefs) overrides it.

Theme

tokens holds the design tokens (OKLCH colours, spacing, radii, type sizes, motion) for light, dark and system modes; cx.tokens() returns the current ones. fonts installs app fonts with a CJK fallback chain. The accent follows the OS accent colour unless App::follow_system_accent(false) is set.

The gallery example shows every component:

cargo run -p ashlar-example-gallery

The full list is on docs.rs/ashlar-ui.

Background jobs

Long work (an export, an import, indexing) runs as a job on a worker thread, so the UI never blocks. A job:

  • works on a snapshot moved into it (an Arc of the document, a path), never on live state;
  • reports progress and checks for cancellation through a Progress;
  • runs under catch_unwind, so a panic fails the job and nothing else;
  • finishes with an Apply: a closure the UI thread runs on the session. Edits made there are one undo step.

Starting one from a view

use ashlar::prelude::*;

if ui.button("Count words").clicked() {
    let doc = cx.snapshot();
    cx.jobs.spawn("count", "Counting words", doc, |doc, progress| {
        let total = doc.notes.len() as u64;
        let mut words = 0;
        for (i, n) in doc.notes.iter().enumerate() {
            if !progress.steps(i as u64, total) {
                return Err("cancelled".into()); // the job was cancelled
            }
            words += n.body.split_whitespace().count();
        }
        Ok(apply(move |s: &mut Session<Notes>| {
            s.toast(ToastLevel::Info, format!("{words} words"));
            Ok(json!(words))
        }))
    });
}

spawn(kind, label, input, work) returns a JobId. work gets the input and a &Progress:

Progress callWhat it does
set(fraction)Report 0..=1; returns false once the job was cancelled
steps(done, total)The same, as steps
message(text)Say what the job is doing (“Reading frame 12 of 300”)
report(fraction, text)Both at once
is_cancelled(), token()Check for cancellation, or hand a CancelToken to other code

Return Err(message) to fail the job; the runtime shows the message as an error toast.

Variants

cx.jobs.job(kind, label) describes a job before starting it:

  • .here() runs it on the calling thread, now (a command that needs the result at once, a CLI). It is still listed, reports progress and runs under catch_unwind.
  • .spawn_streaming(input, work) also gets an Updates handle, to post partial results while it runs (search hits as they are found).
  • .spawn_stepped(input, step) splits the work into steps. On the web, where there are no threads, stepped jobs advance a few steps per frame so a frame never freezes.

Watching and cancelling

cx.jobs.list() describes every running job (kind, label, progress, message) for a progress panel; cx.jobs.is_busy() says whether any is running; cx.jobs.cancel(id) and cx.jobs.cancel_all() cancel. status_bar::progress draws a compact progress indicator.

Agents see jobs too: app.stats over the control port includes them.

Idle cost

A job wakes the UI only when it starts, moves its progress by more than half a percent, or finishes. An app with no running jobs does no work for them.

Jobs live in the ashlar-jobs crate, which is headless and can be used without the rest. See docs.rs/ashlar-jobs.

Files and settings

App folders

Each app gets four folders, in the places the OS expects: config, data, cache and logs. They are named from App::organization(qualifier, org) and the app name (the default organisation is org / ashlar).

App::<Doc>::new("notes", "0.1.0").organization("com", "Acme")

To put everything somewhere else (tests, a portable install), pass the folders yourself or set ASHLAR_DIRS_ROOT:

use ashlar::fs::AppDirs;

App::<Doc>::new("notes", "0.1.0").dirs(AppDirs::under("/tmp/notes-test"))

What goes where:

FolderContents
configSettings, the control key file
dataRecovery (autosave snapshots)
logsThe rotating log file and crash logs
cacheFree for the app

Settings

cx.settings is keyed JSON with typed getters. Changes are saved after a short delay, with an atomic write, and flushed when the app ends.

let recent_limit: u32 = cx.settings.get_or("recent.limit", 10);
if ui.checkbox(&mut sync, "Sync").changed() {
    let _ = cx.settings.set("sync", &sync);
}

get, get_or, get_value, set, set_value, remove and keys cover most uses. The runtime keeps its own entries there too, such as the window placement under "window".

Recent files

RecentFiles is a most-recently-used list that lives in the settings:

use ashlar::fs::RecentFiles;

let mut recent = RecentFiles::load(cx.settings, "recent", 10);
if let Some(path) = cx.session().path() {
    recent.push(path);
}
recent.store(cx.settings, "recent");
for p in recent.paths() {
    ui.label(p.display().to_string());
}

prune(|p| p.exists()) drops files that are gone.

Atomic writes

ashlar::fs::atomic_write(path, bytes) replaces a file so that a crash or a power cut leaves the old content or the new, never a torn mix. Document saves, autosave and settings all use it.

File dialogs

The built-in file.open and file.save_as show the native dialogs. For other files use ashlar::native::dialogs::FileDialog:

use ashlar::native::dialogs::FileDialog;

match FileDialog::new().title("Import image").filter("Images", &["png", "jpg"]).open_file() {
    Ok(path) => cx.dispatch("image.import", json!({"path": path})),
    Err(e) => log::info!("no file: {e}"), // NativeError::Cancelled when the user cancels
}

open_files, pick_folder and save_file work the same way. On Linux they use the XDG desktop portal; on the web only opening is available.

The ashlar-fs crate is headless and builds for wasm32. See docs.rs/ashlar-fs.

Native OS features

ashlar::native (the ashlar-native crate) wraps OS features behind one API. Where a feature is not available on a platform, the call returns NativeError::Unsupported; nothing panics. Most apps use these through the App builder rather than calling the crate directly.

FeaturemacOSWindowsLinuxWeb
File dialogsyesyesyes (portal)open only
Native menu baryesyesin-window menuin-window menu
Tray iconyesyesyes (D-Bus)no
Global shortcutsyesyesX11 onlyno
Clipboard (text, image)yesyesyesno
Notificationsbundled appsyesyes (D-Bus)no
Single instanceyesyesyesno

The full matrix is in the ashlar-native README.

The app’s commands, grouped by their .menu(&[…]) path, form the menu bar. By default it is drawn inside the window. With the native-menu feature it moves to the macOS menu bar and the Windows window menu:

cargo add ashlar-rs --features native-menu

Tray icon

With the tray feature, App::tray shows a tray or status-bar icon. Each menu item’s id is a command id; choosing it runs the command. Clicking the icon shows and focuses the window.

use ashlar::command::menu::{Item, MenuNode, MenuSpec, Submenu};
use ashlar::native::tray::{Icon, TraySpec};

let icon = Icon { rgba: icon_rgba, width: 32, height: 32 };
let menu = MenuSpec::new(vec![Submenu::new("Tray", vec![MenuNode::Item(Item::new("note.add", "New Note"))])]);
App::<Notes>::new("notes", "0.1.0").tray(TraySpec::new("notes", "Notes", icon, menu).template(true))

template(true) makes macOS tint the icon for the menu bar. Where the desktop has no tray, the icon is skipped with a log line.

Global shortcuts

With the hotkey feature, App::global_shortcut(command, chord) runs a command when the chord is pressed anywhere, even while another app has the focus. The window comes to the front first.

App::<Notes>::new("notes", "0.1.0").global_shortcut("note.add", "Mod+Shift+Space")

A chord without Ctrl, Alt or ⌘, or one another app already holds, is skipped with a log line.

Drag and drop

When the app sets a Format or a file_extension, files dropped on the window open: the first file with a known extension goes to file.open (asking to save a dirty document first). Other files reach the view:

for path in cx.dropped_files() {
    cx.toasts.info(format!("Dropped {}", path.display()));
}

App::accept_drops(false) leaves drops entirely to the view.

Single instance

App::<Notes>::new("notes", "0.1.0").single_instance(true)

A second launch hands its arguments (the files to open) to the running app and exits. The running app opens them with file.open and comes to the front. Desktop only.

Notifications

Notifications need the notify feature of ashlar-native:

cargo add ashlar-native --features notify
use ashlar_native::notify::{self, Notification};

let _ = notify::notify(&Notification::new("Export finished", "12 files written").with_id("export"));

A notification with the same id replaces the previous one. notify::on_click(waker) reports clicks. On macOS this works only in an app bundle with a bundle identifier; elsewhere it returns Unsupported.

Other features

ashlar::native also has the clipboard with rich types (clipboard), keep-awake (power), files opened from the file manager (open_files), system preferences such as accent colour, reduced motion and contrast (system), and, behind their own features, dock and taskbar progress (progress), the OS keychain, and dragging files out of the window (drag-out, macOS). See docs.rs/ashlar-native.

Agents and automation

Because every action is a command, an ashlar app can be driven from outside in four ways, all with one vocabulary of methods:

SurfaceTransportUse it for
CLIone-shot verbs, one-line JSON repliesscripts, CI, batch jobs
Control portJSON lines over TCP on 127.0.0.1, with a keydriving the running window
MCPJSON-RPC 2.0 over stdioAI agents
Child processJSON lines over stdin/stdouta parent process that owns the app

The method list, the security model and the reply format are in the control protocol reference.

A CLI binary

App::cli() turns the app definition into a command-line tool that runs the same commands on a headless session. Put the app definition in a library function and add a second binary:

// src/bin/notes-cli.rs
fn main() -> std::process::ExitCode {
    ashlar::app::cli_main(notes::app())
}
notes-cli commands [--json] [--query Q]          list commands
notes-cli describe <id>                          one command in full (parameter schema)
notes-cli docs [category|path.md] [--query Q]   the documentation
notes-cli exec <id> [json|-]                     run one command
notes-cli run <file.jsonl|-> [--keep-going]    run {"command","params"} lines
notes-cli mcp [--isolate] [--per-command]        MCP server on stdio
notes-cli serve                                  control protocol on stdio
notes-cli version [--json]
notes-cli exec note.add '{"title": "Groceries"}'
# {"ok":true,"result":{"count":1,"id":1}}

Replies are one line, {"ok":true,"result":…} or {"ok":false,"error":"…"}. Exit codes are 0 (ok), 1 (the command failed) and 2 (usage). commands, describe, exec, run and mcp take --connect PORT to drive the running app instead of a fresh headless session.

To add your own verbs, take the ashlar::cli::Cli from App::cli() and call .verb(…) before .run().

The control port

Start the app with a port, and it serves the control protocol on loopback:

notes --control 7700          # or NOTES_CONTROL_PORT=7700, or App::control_port(7700)
notes-cli exec note.list '{}' --connect 7700

When the port opens, the app writes a random key to a file in its config folder (mode 0600). Every request must carry it, so another local user or a web page cannot drive the app. Clients in this repository (--connect, ashlar-debug) find the key file themselves.

Besides commands (engine.commands, engine.execute, engine.batch), the running window answers UI methods: ui.snapshot (an outline of the UI with refs), ui.click, ui.fill, ui.press, ui.wait, ui.screenshot, app.clock, app.stats, app.logs and more.

ashlar-debug drives a running app from the shell with those methods:

ashlar-debug open ./target/debug/notes
ashlar-debug snapshot -i
ashlar-debug fill @e2 "Groceries" --submit
ashlar-debug screenshot --annotate
ashlar-debug close

See Testing and debugging UIs.

MCP

notes-cli mcp is an MCP server on stdio. Its tools are generated from the command registry (list_commands, describe_command, execute, batch), plus the UI tools (ui_snapshot, ui_click, screenshot, …) when it is connected to a running window with --connect PORT. --per-command also makes each command its own tool with its real input schema.

A client config entry looks like any stdio server:

{
  "mcpServers": {
    "notes": { "command": "notes-cli", "args": ["mcp"] }
  }
}

App::instructions("…") sets what MCP clients are told about the app; write it for an agent that has never seen it.

The ashlar-agents crate writes these config entries, plus skills and rules, for common coding agents, and removes exactly what it added. ashlar-mcp-http serves MCP over HTTP on loopback instead of stdio.

Agents and undo

Agents go through the same chokepoint as users: parameter validation, enabled predicates, undo. A command an agent runs is one undo step the user can take back, and the view can tell it apart (cx.origin()) to animate the change instead of jumping.

Testing apps

An ashlar app can be tested at three levels: commands on a headless session, the whole UI on a virtual clock with no window, and the real binary from the command line. The full reference is Testing and debugging UIs.

The binary: --headless

Every binary whose main calls App::run takes --headless (the headless feature, on by default). It runs the app with no window, on a virtual clock, in a fresh temporary folder (never your real settings or documents), and writes real pixels when there is a GPU:

notes --headless --size 1024x700 --scale 2 --theme dark --scene two-notes --script add.ui --at 1.5 \
      --snap out.png --tree tree.json
  • --size is in points (default 1280x800); the PNG is that times --scale.
  • --script runs a .ui script first; --at lets that many seconds pass before the picture.
  • --tree writes the UI outline (ui.snapshot) as JSON.
  • Exit codes: 0 done, 1 a step failed, 2 bad arguments.

.ui scripts

One command per line, with assertions:

exec note.add '{"title": "Groceries"}'
sleep 500
fill id=notes.title "Groceries and milk"
press Mod+S
wait --text "Saved"
assert exists id=notes.add
assert text id=status "Saved"
assert count role=listitem >= 1
assert screenshot notes-two

Elements are found by automation id (id=…), text (text=…), role, or a ref from the last snapshot (@e3). The same scripts run in process, with --headless, or against a running app with ashlar-debug batch file.ui.

In-process tests: Driver

App::runtime() builds the app without a window and Driver runs its frames on a virtual clock. Point the app folders at a temporary directory so tests never touch real settings:

use ashlar::app::Driver;
use ashlar::fs::AppDirs;
use serde_json::json;

#[test]
fn adding_a_note_shows_it() {
    let dir = tempfile::tempdir().unwrap();
    let mut d = Driver::new(notes::app().dirs(AppDirs::under(dir.path())).runtime());
    d.settle(60);
    d.call("engine.execute", json!({"command": "note.add", "params": {"title": "Groceries"}})).unwrap();
    d.settle(60);
    let tree = d.call("ui.snapshot", json!({})).unwrap();
    assert!(tree["tree"].as_str().unwrap().contains("Groceries"));
}
Driver callWhat it does
frame()Run one frame
settle(max)Run frames until nothing asks for one (at most max)
advance(secs)Let virtual time pass, running every frame that is asked for
call(method, params)Any control method: engine.execute, ui.click, ui.snapshot…
run_ui(script)Run a .ui script; sleep and wait use virtual time
scene(name)Apply a named scene
assert_snapshot(name)Compare a screenshot with tests/snapshots/<name>.png
push_event(e), drop_files(paths)Inject egui input or dropped files
set_screen(w, h), pixels_per_point(s)Window size and scale
replay(path)Play a recorded session
record(f)Record motion while f runs, for the motion gate

For headless tests of commands alone, App::headless() gives a Headless session with the registry and no UI.

Scenes

A scene is a named starting state, declared on the app:

App::new("notes", env!("CARGO_PKG_VERSION"))
    .scene("empty", |_: &mut Session<Notes>| {})
    .scene("two-notes", |s: &mut Session<Notes>| {
        s.edit("Two notes", |d| { /* fill the document */ });
    })

notes --list-scenes prints them, --scene two-notes starts in one (with or without a window), Driver::scene("two-notes") applies one in a test, and agents use ui.scene or the MCP tool ui_scene.

Golden snapshots

For real pixels, build the driver with the wgpu renderer:

use ashlar::app::{Driver, Renderer};

let rt = notes::app().dirs(AppDirs::under(dir.path())).runtime();
let mut d = Driver::with_renderer(rt, Renderer::wgpu());
d.set_screen(640.0, 400.0);
d.pixels_per_point(1.0);
d.scene("two-notes").unwrap();
d.settle(60);
d.assert_snapshot("notes-two").unwrap();

The comparison is exact unless a tolerance is given. On a mismatch it writes <name>-new.png and <name>-diff.png. ASHLAR_UPDATE_SNAPSHOTS=1 records new baselines, with a .meta.json naming the GPU adapter; a baseline from another adapter is skipped with a note instead of failing. The result type (SnapshotOutcome) is in the ashlar-debug crate.

Time: app.clock

app.clock freezes the time the app’s frames see, so you can look at an animation at an exact moment: {"pause": true}, {"step_ms": 16}, {"resume": true}. It is also a .ui script line (clock pause, clock step 16), an ashlar-debug clock command and the MCP tool ui_clock.

Record and replay

ASHLAR_RECORD=session.jsonl (or Runtime::record_to(path) in a test) records every frame’s input and every control request. Driver::replay plays it into a fresh driver, which turns a bug report into a test:

let mut d = Driver::new(notes::app().runtime());
d.replay("session.jsonl")?;

The motion gate

Driver::record records values animated through ashlar::ui::motion and the position of every element per frame. check flags overshoot, reversals, pops, flicker and frames requested after everything settled:

use ashlar::ui::motion_gate::MotionRules;

let rec = d.record(|d| drop(d.run_ui("click overlays.open\nwait 1")));
let report = rec.check(&MotionRules::default());
assert!(report.is_clean(), "{report}");

The notes example has a test for each of these in examples/notes/tests.

Web

ashlar’s headless core (commands, documents, files, jobs, logging) builds for wasm32-unknown-unknown, and CI checks it. Running the windowed app in a browser is experimental: it works, and examples/web-hello builds in the gate, but not every feature is there.

The example

use ashlar::prelude::*;

#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
struct Doc {
    clicks: u32,
}

fn main() -> Result<(), ashlar::app::Error> {
    App::<Doc>::new("web-hello", env!("CARGO_PKG_VERSION"))
        .title("Hello, web")
        .canvas_id("web-hello")
        .view(|ui, cx: &mut Cx<'_, Doc>| {
            let t = cx.tokens();
            ui.heading("Hello from ashlar on the web");
            ui.add_space(t.space.md);
            if ui.button("Click").clicked() {
                cx.edit("Click", |d| d.clicks = d.clicks.saturating_add(1));
            }
            ui.label(format!("Clicked {} times (undo with Ctrl/Cmd+Z)", cx.doc().clicks));
        })
        .run()
}

The same code opens a window on the desktop. On the web the app runs in the <canvas> named by App::canvas_id (default: the app’s name, then ashlar). If it cannot start (no such canvas, no WebGL), a visible error appears next to the canvas as well as in the console.

Dependencies

Turn off the default features: headless has no meaning in a browser. Keep accesskit for screen readers.

[dependencies]
ashlar = { package = "ashlar-rs", version = "0.2", default-features = false, features = ["accesskit"] }
serde = { version = "1", features = ["derive"] }

Building and serving

The example uses trunk, which runs wasm-bindgen and serves the page:

rustup target add wasm32-unknown-unknown
trunk serve examples/web-hello/index.html

The page needs a canvas with the right id and a trunk link to the binary:

<link data-trunk rel="rust" data-bin="ashlar-example-web-hello">
<canvas id="web-hello"></canvas>

What differs on the web

  • Jobs run inline, with no threads. Use spawn_stepped for long work so it advances a few steps per frame.
  • The disk-backed settings and files compile but report errors; use the in-memory backends or a browser-storage backend (ashlar::fs::ConfigStore).
  • File dialogs can only open files.
  • The tray, global shortcuts, notifications, the clipboard, single instance, the control port and record and replay are not available.

Crash safety and telemetry

What the runtime catches

  • A panic in the view is caught. The window stays open and shows the error in place of the view; the document and undo history are untouched.
  • A panic in a command fails that command with CommandError::Panicked. The document is left as it was before the command, and the user sees an error toast.
  • A panic in a job fails the job. Nothing else is affected.
  • A panic in a scene or in setup is logged and skipped.

Every panic is written to a crash log in the app’s logs folder by a panic hook that chains to any hook installed before it.

When the process dies anyway

A crash outside these guards, a kill or a power cut ends the process. Two things limit the damage:

  • Recovery. Autosave keeps snapshots of unsaved work. On the next launch a dialog offers to recover them. See Documents and undo.
  • Safe mode. If the previous launch died before its first frame, the app starts without restoring view settings (theme, window state) and says so. --safe-mode does the same by hand.

Logs

The runtime logs to stderr, a rotating file in the logs folder, and an in-memory ring that app.logs (control port) and the MCP tool app_logs read. Filter with <APP>_LOG or RUST_LOG (NOTES_LOG=warn,notes=debug for an app named notes). Nothing is written to stdout, so a CLI or an MCP server can log freely.

In your own code

The library code follows the rules it enforces on itself, and apps can adopt them:

  • Deny unwrap, expect, panic!, unreachable!, todo! and unimplemented! outside tests:

    #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
  • Treat numbers from input as hostile: get() instead of [i], checked or saturating math.

  • Return CommandError::failed or bad_params from commands instead of panicking.

Telemetry

With the telemetry feature, the app can send crash, performance and usage reports to a server you host (ashlar-trace-server):

cargo add ashlar-rs --features telemetry
App::<Doc>::new("notes", env!("CARGO_PKG_VERSION"))
    .telemetry("app-write-key", Some("https://telemetry.example.com"))

When an endpoint is set, telemetry is on. The runtime reports:

  • panics, with recent log lines;
  • each command run (id, success, duration), from the command chokepoint;
  • frame times and startup time;
  • hangs, when a main-loop turn takes too long;
  • saves.

In this integration every category is on and there is no first-run notice; DO_NOT_TRACK is ignored. TRACER_DISABLED in the environment (meant for developer machines and CI) turns it off. The endpoint must be https:// (or http:// to loopback). Command parameters, file names, paths and typed text are never sent. The write key only allows sending data; it is not a secret. Without the feature, every telemetry call compiles to nothing.

Because users cannot switch it off from the app, tell them in your privacy policy what is sent. An app that needs consent and per-category switches can use the ashlar-trace SDK directly.

Before you ship telemetry, read what is collected:

Building with an AI assistant

ashlar apps are easy for a coding agent to check: the agent can build the app, run it with no window, look at a PNG of it, read its UI tree, and drive it through the same commands a user runs. This page has a rules file to give the agent, and the steps to connect an agent to your running app.

Docs for agents

  • llms.txt: an index of every page of this site.
  • llms-full.txt: the whole site as one markdown file.
  • Each page’s markdown is at its URL plus .md, for example https://ashlar.himanshuat.com/guide/commands.md.
  • The API reference: docs.rs/ashlar-rs.

A rules file for your project

Copy this into AGENTS.md or CLAUDE.md at the root of your app’s repository and adjust the names (my-app, my-app-cli) to yours.

# Building this app

This is a desktop app built on ashlar (crate `ashlar-rs`, imported as `ashlar`), a Rust
framework on egui. Docs: https://ashlar.himanshuat.com/llms-full.txt and
https://docs.rs/ashlar-rs. Read the relevant page before using an API you have not used here.

## Never crash
- No `unwrap`, `expect`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests.
  The crate root has
  `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- Numbers from input (files, params, the network) are hostile: `get()` not `[i]`, checked or
  saturating math, capped allocations.
- Commands return `CommandError::bad_params` / `CommandError::failed` instead of panicking.
- No blocking I/O on the UI thread: long work is a job (`cx.jobs.spawn`).

## Adding a feature
- Every user action is a command: a `fn(&mut Session<Doc>, &Value) -> Result<Value, CommandError>`
  registered with `cmd!` or `CommandSpec::new`, with typed `Param`s, a menu path and an enabled
  predicate. Views call `cx.dispatch("id", json!({…}))`; they do not change the document directly
  except for live gestures (`cx.edit_merged`).
- Each command changes the document with one `s.edit("Label", |d| …)`: one undo step.
- Give every interactive widget a stable automation id (`notes.add`, not a generated one), so tests
  and agents can find it.
- Cache derived data by `cx.revision()`; long lists use `ashlar::ui::virtual_list`.

## Checking your work
- `cargo clippy --all-targets -- -D warnings` and `cargo test` pass.
- Run a command with no window: `cargo run --bin my-app-cli -- exec <id> '<json>'`.
- Look at the UI with no window:
  `cargo run -- --headless --size 1024x700 --scene <scene> --snap out.png --tree tree.json`,
  then read `out.png` and `tree.json`.
- Script an interaction in a `.ui` file (`exec`, `click id=…`, `fill id=… "text"`, `press Mod+S`,
  `wait --text "…"`, `assert exists id=…`) and run it with `--headless --script file.ui`
  (exit code 0 = passed, 1 = a step failed).
- New screens get a scene (`App::scene`) and a golden snapshot test (`Driver::assert_snapshot`).

Connecting an agent to your app

An app with a -cli binary is an MCP server: my-app-cli mcp. Build the binary and register it with your agent. For Claude Code:

cargo build --bin my-app-cli
claude mcp add my-app -- ./target/debug/my-app-cli mcp

For agents configured with JSON:

{
  "mcpServers": {
    "my-app": { "command": "/path/to/my-app-cli", "args": ["mcp"] }
  }
}

The agent then lists and runs your commands on a headless session (list_commands, describe_command, execute). To let it see and drive the running window as well, start the app with a control port and connect the server to it:

cargo run -- --control 7700
claude mcp add my-app-live -- ./target/debug/my-app-cli mcp --connect 7700

With --connect the agent also gets screenshot, ui_snapshot, ui_click, ui_fill, ui_wait and the other UI tools. Everything it does goes through the same commands and undo history as the user, so a change it makes can be undone with Mod+Z.

Write App::instructions("…") for the agent: what the app is, what its main commands do, and how to save. MCP clients show it to the model when they connect.

See Agents and automation for the CLI and the control port, and the control protocol for every method and tool.

Cookbook

Short recipes for common tasks. Each uses use ashlar::prelude::*; and a document type Doc unless it says otherwise.

A custom file format, saved and opened from code

let app = App::<Doc>::new("sketch", env!("CARGO_PKG_VERSION"))
    .format(Format::new("acme.sketch", concat!("Sketch ", env!("CARGO_PKG_VERSION"))).migrations(vec![v1_to_v2]))
    .file_extension("sketch");

The built-in File commands now read and write .sketch files. To save or open without a dialog (a script, an agent, a test), pass a path:

cx.dispatch("file.save_as", json!({"path": "/tmp/drawing.sketch"}));
cx.dispatch("file.open", json!({"path": "/tmp/drawing.sketch"}));

From the shell: sketch-cli exec file.save_as '{"path": "out.sketch"}'.

A long job with progress and a Cancel button

use ashlar::ui::status_bar;

if ui.button("Export").clicked() {
    let doc = cx.snapshot();
    cx.jobs.spawn("export", "Exporting", doc, |doc, progress| {
        let total = doc.pages.len() as u64;
        for (i, page) in doc.pages.iter().enumerate() {
            if !progress.report(i as f64 / total.max(1) as f64, format!("Page {} of {total}", i + 1)) {
                return Err("cancelled".into());
            }
            render(page)?;
        }
        Ok(apply(|s: &mut Session<Doc>| {
            s.toast(ToastLevel::Success, "Export finished");
            Ok(Value::Null)
        }))
    });
}
for job in cx.jobs.list() {
    status_bar::progress(ui, "export.progress", &job.label, job.fraction.map(|f| f as f32));
    if job.cancellable && ui.button("Cancel").clicked() {
        cx.jobs.cancel(job.id);
    }
}

A settings page stored in the app settings

use ashlar::ui::{controls::Switch, form};

let mut autosave: bool = cx.settings.get_or("autosave", true);
let out = form::section(ui, "settings.general", "General", |s| {
    s.row("autosave", "Autosave")
        .description("Save a recovery copy every minute")
        .bind(&mut autosave, true)
        .control(|ui, id, on| Switch::new(id, "", on).show(ui));
});
if autosave != cx.settings.get_or("autosave", true) {
    let _ = cx.settings.set("autosave", &autosave);
}

The row shows a “modified” marker and a reset button when the value differs from the default.

A sortable table of 100,000 rows

use ashlar::ui::virtual_list::{Column, RowHeights, Sort, VirtualTable};

let columns = [Column::new("Name", 220.0), Column::new("Size", 80.0)];
let out = VirtualTable::new("files", &columns, files.len(), RowHeights::Uniform(22.0))
    .selected(selected)
    .sort(Some(sort))
    .show(ui, |ui, row, col| {
        let f = &files[row];
        match col {
            0 => { ui.label(&f.name); }
            _ => { ui.label(format!("{} KB", f.size / 1024)); }
        }
    });
if let Some(col) = out.header_clicked {
    sort = Sort { column: col, ascending: !(sort.column == col && sort.ascending) };
    // re-sort `files` by `sort`
}
if let Some(row) = out.list.select {
    selected = Some(row);
}

Only the visible rows are laid out, so the cost per frame does not grow with the row count.

A context menu that runs commands

use ashlar::ui::commands::{Item, MenuNode, MenuSpec, Submenu, menu};

let spec = MenuSpec::new(vec![Submenu::new("Note", vec![
    MenuNode::Item(Item::new("note.rename", "Rename…")),
    MenuNode::Item(Item::new("note.delete", "Delete").accelerator("Mod+Backspace")),
])]);
let row = ui.label(&note.title);
if let Some(id) = menu::context(&row, &spec) {
    cx.dispatch(&id, json!({"id": note.id}));
}

A spec with one menu lists its items directly; with several, each becomes a submenu. Build the spec once and keep it, rather than every frame.

A toast with an Undo button

use ashlar::ui::toasts::Toast;

// on the App: .run_toast_actions(true)
cx.toasts.push(Toast::info("Note deleted").action("Undo", "app.undo"));

An undoable drag on a numeric field

use ashlar::ui::edit::EditPhase;
use ashlar::ui::value_field::{Decimal, ValueField};

let mut opacity = cx.doc().opacity;
let out = ValueField::new("inspector.opacity", "Opacity", &mut opacity, &Decimal { decimals: 0, suffix: " %" })
    .range(0.0..=100.0)
    .show(ui);
if let Some(edit) = out.edit
    && edit.phase != EditPhase::Begin
{
    // Update and Commit carry the new value; Cancel (Escape) carries the value before the drag
    cx.edit_merged("Opacity", "inspector.opacity", |d| d.opacity = edit.value);
}

All the edits of one drag share a label and merge key, so the drag is one undo step called “Opacity”.

A golden screenshot test of a scene

// tests/snapshots.rs
use ashlar::app::{Driver, Renderer};
use ashlar::fs::AppDirs;

#[test]
fn the_empty_scene_looks_as_recorded() {
    let dir = tempfile::tempdir().unwrap();
    let rt = my_app::app().dirs(AppDirs::under(dir.path())).runtime();
    let mut d = Driver::with_renderer(rt, Renderer::wgpu());
    d.set_screen(640.0, 400.0);
    d.pixels_per_point(1.0);
    d.scene("empty").unwrap();
    d.settle(60);
    d.assert_snapshot("empty").unwrap();
}

Record the baseline once with ASHLAR_UPDATE_SNAPSHOTS=1 cargo test, then commit tests/snapshots/empty.png and its .meta.json.

A picture of the app for a bug report or a PR

cargo run -- --headless --scene empty --size 1024x700 --snap app.png --tree tree.json

FAQ and troubleshooting

The build fails on Linux

Install the system libraries (Debian and Ubuntu names):

sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev

Native file dialogs on Linux go through the XDG desktop portal, so the desktop needs a portal service (xdg-desktop-portal and a backend for your desktop) at run time.

Which Rust version do I need?

1.95 or newer, edition 2024. CI tests the latest stable and checks 1.95.

Which egui version?

ashlar 0.2 is built on egui and eframe 0.36 with the wgpu renderer. Use the egui that ashlar re-exports (ashlar::egui, also in the prelude) rather than adding your own egui dependency, so the versions cannot drift apart. If you do depend on egui directly, pin the same minor version.

Is the API stable?

No. ashlar is at 0.2; the API will change between minor versions until 1.0. Each release lists its changes in the changelog. Pin the minor version (ashlar-rs = "0.2").

The window does not update when my data changes

An ashlar app draws a frame only when something asks for one: input, a document change, a finished job, a running animation. If your data changes some other way (a thread of your own, a socket, a timer), ask for a frame:

use ashlar::ui::wake::{self, Reason};

const FEED: Reason = Reason("feed"); // your own reason, shown in app.stats

let waker = wake::waker(cx.ctx(), FEED); // Send + Sync: hand it to the thread
std::thread::spawn(move || {
    // … new data arrived …
    waker.wake();
});
wake::wake_after(cx.ctx(), Reason::TIMER, std::time::Duration::from_secs(1)); // a timed frame

Edits through commands, cx.edit and jobs ask for frames themselves. app.stats over the control port shows which reasons asked for frames, which also helps find an app that never goes idle.

The app is slow with a long list

Use virtual_list::VirtualList or VirtualTable instead of a loop over ui.label: they lay out only the visible rows. Key derived data (filtered rows, layouts) by cx.revision() so it is rebuilt only when the document changes. Wrap a plain list in list::checked(ui, |ui| …) and debug builds warn when it builds too many widgets in one frame.

What works on the web?

The headless core (commands, documents, files, jobs, logging) builds for wasm32-unknown-unknown and is checked in CI. The windowed runtime runs in a browser canvas but is experimental. There are no threads (jobs run inline; use spawn_stepped for long work), no disk (use in-memory or browser-storage backends), file dialogs can only open, and there is no tray, global shortcut, notification, clipboard, single instance or control port. See Web.

Notifications do nothing on macOS

macOS delivers notifications only to an app bundle with a bundle identifier. A binary started with cargo run gets NativeError::Unsupported. Test them from a bundled build.

The command does not appear in the menu

Give it a menu path with .menu(&["Edit"]) (or the third argument of cmd!). Commands made with query! are never in menus. A command whose id is already registered is ignored with a warning in the log.

How do I see what went wrong?

  • The log: stderr, the rotating file in the app’s logs folder, or app.logs over the control port. Raise the level with <APP>_LOG=debug (for an app named notes, NOTES_LOG=debug).
  • Crash logs are in the same folder.
  • --safe-mode starts without restoring view settings.
  • ashlar-debug open ./target/debug/my-app then ashlar-debug snapshot -i shows what the UI contains; see Testing and debugging UIs.

How big is a release binary?

About 13.6 MB stripped for the smallest app on macOS arm64, 9.0 MB with a size-optimised profile. See Installation.

Contributing to ashlar

Thanks for your interest in ashlar. Bug reports, fixes, docs, examples and new building blocks are all welcome.

Before you start

  • Bugs: search the issues first, then open one with the bug report template.
  • Features and new crates: open an issue to discuss the idea before you write a lot of code. ashlar holds only general building blocks that serve more than one kind of app. Widgets for one domain (a timeline, a CAD canvas) belong in the app that needs them.
  • Security issues: don’t open a public issue. See SECURITY.md.

Development setup

  1. Install stable Rust (1.95 or newer) with rustfmt, clippy and the wasm target:
    rustup component add rustfmt clippy
    rustup target add wasm32-unknown-unknown
    
  2. On Linux, install the system libraries:
    sudo apt-get install libgtk-3-dev libxdo-dev libxkbcommon-dev libdbus-1-dev
    
  3. Optional: cargo install cargo-deny --locked for the dependency checks.
  4. Run the gate:
    cargo xtask ci
    

The rules

AGENTS.md holds the project’s rules. They apply to people and AI agents alike, and CI enforces most of them. In short:

  • Never crash. No unwrap, expect, panic!, unreachable!, todo! or unimplemented! outside tests, and no unsafe. Treat every input-derived number as hostile.
  • Respect the layers. A crate depends only on crates of a lower rank (docs/architecture.md). The headless core never depends on egui.
  • Performance is a feature. Idle apps do no work. Hot paths have benchmarks.
  • No app-specific code. Describe apps by kind (“a video editor”), not by name.

The full list of gates, and how to run each one alone, is in docs/contributing.md.

Pull requests

  1. Fork the repository and create a branch from main.
  2. Keep each pull request to one change, with tests for new behaviour and bug fixes.
  3. Run cargo xtask ci and make sure it passes.
  4. Update the docs and the crate’s README when you change public behaviour, and add a line to CHANGELOG.md under “Unreleased”.
  5. Write commit subjects in the imperative mood, under about 72 characters. Use the body to explain why.
  6. Open the pull request and fill in the template.

License

By contributing, you agree that your contributions are licensed under the BSD 3-Clause License, the same license as the project.

Contributing

Read AGENTS.md first; its rules apply to people and agents alike. The crate ranks and design principles are in architecture.md.

Gates

cargo xtask ci must pass before every commit. It runs:

StepWhat it checks
fmtcargo fmt --all --check
namesno app name in any file of the repository (false positives: xtask/names-allow.txt)
layerseach crate depends only on lower ranks; ranks 0–4 never on a UI crate
deny-panicsevery crate inherits the workspace lints; no never-crash lint is allowed away outside tests (the OS FFI crate follows docs/adr/0001-native-ffi.md instead)
depscargo deny check bans sources (skipped with a note when cargo-deny isn’t installed)
clippycargo clippy --workspace --all-targets -- -D warnings
testcargo test --workspace
wasmcargo check --target wasm32-unknown-unknown on the rank 0–1 crates, ashlar-ui, ashlar-app and examples/web-hello

The quick checks run first and are all reported; then clippy, wasm and the tests run side by side (clippy and wasm in their own target dirs under target/, their output shown only on failure). If another checkout is compiling, ci waits for it so two gates don’t fight over the cores; --no-wait skips that. Every step also runs on its own: cargo xtask names, cargo xtask wasm…

Other tasks:

  • cargo xtask msrv: cargo check on the workspace rust-version (1.95); rustup toolchain install 1.95 --profile minimal first.
  • cargo xtask advisories: cargo-deny on advisories, bans and sources (cargo install cargo-deny --locked).
  • cargo xtask bench [--compare FILE]: the performance gate, see architecture.md.

CI (.github/workflows/ci.yml) runs cargo xtask ci on Linux, macOS and Windows, cargo xtask msrv, and the advisory check. Actions are pinned by commit SHA; update them together.

Releases (crates.io and GitHub) are in releasing.md.

Tools needed locally: stable Rust with rustfmt, clippy and the wasm32-unknown-unknown target; cargo-deny is optional locally and installed on CI.

Commits

  • One task per commit, with its id: AS-P1.2: history merge keys.
  • Only green states: run cargo xtask ci (or, while working on one crate in a parallel worktree, cargo test -p <crate>, cargo clippy -p <crate> --all-targets -- -D warnings and cargo xtask layers; the full gate runs at integration).
  • Imperative subject under ~72 characters; the body says why.
  • AI-assisted commits end with the co-author line, e.g. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Parallel work

One git worktree per contributor under .claude/worktrees/<name> (ignored by git), branch wt/<name>, with its own CARGO_TARGET_DIR=$PWD/target/agent-<name>; remove both once merged. Never commit on main; the integrator merges.

ashlar: rules for agents and developers

ashlar is a Rust library for building fast desktop and web apps: documents and undo, commands, background jobs, files and settings, native OS features, UI components (egui), and a runtime that gives every app crash safety, telemetry and agent control (control port, MCP, CLI) for free. Domain widgets (a timeline, a CAD canvas…) are app-specific and stay in the apps; ashlar only provides the general building blocks they are made from.

0. Never crash

No unwrap/expect/panic!/unreachable!/todo!/unimplemented! outside tests (denied in [workspace.lints.clippy]), no unsafe (forbidden; only ashlar-native may later relax this for FFI, one module at a time, with // SAFETY: comments). Every input-derived number is hostile: get() not [i], checked/saturating math, capped allocations, bounded recursion. Lock poisoning is not fatal (lock().unwrap_or_else(PoisonError::into_inner)). Background work runs under catch_unwind. Integration-test files start with #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)].

1. No app code, no app names

A crate here must serve more than one app and never depend on one. Never name the apps that use ashlar, anywhere in this repository (public API, identifiers, docs, agent instructions, commit messages): describe them by kind instead, “a video editor”, “a DAW”. cargo xtask names enforces this over every file in the repository (the names are kept only as hashes; false positives go in xtask/names-allow.txt). The one exception is the “Built with ashlar” section of the root README, allowed in that file only.

2. Layering (cargo xtask layers)

Crates have a rank (see xtask/src/main.rs); a crate depends only on lower ranks. Ranks 0–4 never depend on egui/eframe/winit: the core is headless and builds for wasm32.

3. Performance is a feature

Idle apps do zero work (repaint on demand). Caches are keyed by revision numbers, not by diffing. Long lists are virtualized. No blocking I/O on the UI thread. Benches guard every hot path.

4. Gates before every commit

cargo xtask ci: fmt check, names, layers, deny-panics, deps (cargo-deny, when installed), then clippy -D warnings, tests and the wasm32 check (ranks 0–1, ashlar-ui, ashlar-app) side by side (--no-wait skips waiting for other checkouts’ builds). Also cargo xtask msrv, advisories and bench; see docs/contributing.md. One task id per commit (AS-P1.2: history merge keys), only green states, attribution line at the end.

5. Stable contracts

Items marked CONTRACT in ashlar-command are shared across crates built in parallel: change them only additively.

6. Working in parallel

One git worktree per agent under .claude/worktrees/<name> (inside the checkout, kept out of git), branch wt/<name>, own CARGO_TARGET_DIR (target/agent-<name> inside the worktree); remove the worktree and its branch once merged. Never commit on main; the integrator merges. Build and test only your crates (cargo test -p <crate>); the full gate runs at integration.

Architecture

ashlar is a workspace of small crates stacked in ranks. An app depends on the umbrella crate ashlar-rs (imported as ashlar) and turns on the features it needs; each layer below it can also be used on its own.

Crate ranks

A crate depends only on crates of a lower rank (dev-dependencies are exempt). Ranks 0–4 never depend on a UI crate (egui, eframe, winit, egui-wgpu, egui_kittest), and ranks 0–1 build for wasm32-unknown-unknown, as do ashlar-ui and ashlar-app (the web build path). cargo xtask layers and cargo xtask wasm enforce this; the table below must match LAYERS in xtask/src/main.rs (a crate missing from LAYERS fails the gate).

RankCrateWhat it isHeadlesswasm32
0ashlar-commandCommands: registry, typed params, one execute chokepoint, discoveryyesyes
0ashlar-fsFiles and settings: app dirs, durable atomic writes, keyed settings, recent filesyesyes
0ashlar-jobsBackground jobs: progress, cancel, panic isolation, apply-on-UI-threadyesyes
0ashlar-trace-protoTelemetry wire format: envelopes, items, name rules, limitsyesyes
0ashlar-docsDocumentation library for agents: folders are categories, markdown files are documents, embedded at build time, a run-time folder on topyesyes
0ashlar-logLogging and crash safety: directive filters, stderr + rotating file + ring, panic hook, launch guardyesyes
0ashlar-obfuscateCompile-time string obfuscation: build-time values kept out of a strings dump of the binaryyesyes
0ashlar-motionMotion maths: closed-form springs, curves, animated values that keep their velocity on retarget, reduced-motion rules per value class, motion tokens, OKLab/OKLCH colour, gesture helpersyesyes
0ashlar-syntaxSyntax highlighting: linear-time lexers for 18 languages, line states for incremental views, a Highlighter trait for bigger enginesyesyes
0ashlar-diffText diffs: Myers with a cost cap, word-level changes, hunks with collapsible context, a unified-patch parser and writeryesyes
0ashlar-i18nTranslations: JSON catalogs per locale with fallback chains, {name} templates, CLDR plural categories, number formatting, locale negotiation and catalog checksyesyes
1ashlar-traceTelemetry SDK: consent, spool, sending, crash/hang/frame/system signals, event catalogyesyes (no-op)
1ashlar-storeDocuments: Arc snapshots, revisions, undo history, versioned formats, autosaveyesyes
2ashlar-nativeNative OS features behind one API: dialogs, menus, clipboard, power, notifications, secretsyes–
2ashlar-controlAgent control port: hardened JSON-lines server, client, standard method setyes–
2ashlar-trace-serverSelf-hosted telemetry server: ingest, SQLite store, rollups, dashboardyes–
2ashlar-printSystem PDF print spooler (CUPS, Windows)yes–
2ashlar-updateUpdate checks: latest release from a releases API, semver comparison, background schedule, cached result, “later” per versionyes–
2ashlar-integrationsIntegrations: tool servers installed from a catalog (download with resume and sha256, code-signature check, versions, enable/disable) and run as MCP servers over stdio; their tools for an assistantyes–
2ashlar-mcp-httpMCP over streamable HTTP on loopback for an open app: bearer token, Host/Origin checks, a gate, sessionsyes–
2ashlar-agentsConnect coding agents to an app: its MCP server in each agent’s config, skills and rules from a docs category, install and uninstallyes–
3ashlar-telemetryAlways-on crash, performance and usage telemetry over ashlar-trace: client, transport, hooks for commands, logs, frames and dialogsyes–
3ashlar-mcpMCP server generated from the command registry (stdio), headless or bridgedyes–
4ashlar-cliStandard CLI verbs over the command registryyes–
4ashlar-assistantIn-app AI assistant harness, independent of the model: conversation and tool types, providers (an account backend, Anthropic, OpenAI-compatible), the agent loop with approvals, keys in the OS keychain, an MCP server as the tool hostyes–
4ashlar-debugashlar-debug: drive a running app’s UI from the shell (snapshot, refs, click, wait, screenshot, scripted UI tests)yes–
5ashlar-uiUI components on egui: theme tokens, controls, layout, docking, iconsnoyes
6ashlar-appRuntime: App builder, frame scheduler, crash guard, logging, telemetry, agent controlnoyes
7ashlar-rsUmbrella crate (imported as ashlar): re-exports the others behind featuresno–
8ashlar-example-gallery, ashlar-example-hello, ashlar-example-notes, ashlar-example-trace-demo-cli, ashlar-example-trace-loadgen, ashlar-example-web-helloExample appsno–
9xtaskRepo gates and tools (cargo xtask …)––

Adding a crate: give it the lowest rank its dependencies allow, add it to LAYERS and to this table, add [lints] workspace = true to its manifest, and add it to [workspace.dependencies] with both path and version.

Design principles

  1. Never crash. No unwrap/expect/panic!/unreachable!/todo!/unimplemented! and no unsafe outside tests. Every input-derived number is hostile: get() instead of indexing, checked or saturating math, capped allocations, bounded recursion. Lock poisoning is not fatal. Background work runs under catch_unwind. Enforced by the workspace clippy lints and cargo xtask deny-panics.
  2. No app code, no app names. A crate here serves more than one app and never depends on one. App-specific widgets (a timeline, a CAD canvas) stay in the apps and are built from ashlar’s primitives. Docs say “a video editor”, “a DAW”. Enforced by cargo xtask names.
  3. A headless core. Documents, commands, jobs and files never touch the UI, so they run in tests, in a CLI, behind MCP and in the browser. The UI is openly egui; there is no renderer-agnostic abstraction.
  4. Everything is a command. Menus, shortcuts, the palette, the CLI and MCP all go through one registry and one execute chokepoint, so anything a user can do an agent can do.
  5. Performance is a feature. Idle apps do zero work (repaint on demand). Caches are keyed by revision numbers, not by diffing. Long lists are virtualized. No blocking I/O on the UI thread. Edits are copy-on-write with structural sharing. Every hot path has a perf test, and cargo xtask bench --compare guards it (see below).
  6. Motion is a policy. Nothing animates on first show, the user’s own hand is never animated, reduced motion is applied per value class (ashlar-motion’s Class), and settled animations request no frames.
  7. Stable contracts. Items marked CONTRACT in ashlar-command are shared across crates built in parallel and change only additively.

Performance gate

Hot paths have perf tests: #[test] #[ignore] functions whose names start with perf, usually in tests/perf.rs. They print their numbers as

bench: <metric> <value> <unit>        e.g. bench: edit_100k 812 ns

Units ending in /s are higher-is-better; all others lower-is-better. A test that prints no bench: line is measured by wall time, which is noisy, so prefer explicit metrics.

One step is not a test: perf_hello_size builds examples/hello (an empty document, a heading and a checkbox) in release, strips a copy and reports hello_stripped_bytes, the size the framework adds to every app. cargo build --profile release-small (fat LTO, one codegen unit, opt-level = "s", stripped) is the workspace’s size profile.

cargo xtask bench                             # writes target/bench/bench.{json,md}
cp target/bench/bench.json baseline.json      # before a change
cargo xtask bench --repeat 3 --compare baseline.json   # fails on a >5% regression

--repeat N keeps the best value of each metric over N rounds; --threshold PCT changes the 5%; --filter TEXT runs only matching tests.

Dependencies

cargo xtask ci checks duplicate versions and sources of every dependency when cargo-deny is installed (deny.toml); cargo xtask advisories also checks the advisory database.

ADR 0001: unsafe OS FFI in ashlar-native, and nowhere else

  • Status: accepted (2026-08-18, wave 1)

Context

The workspace sets unsafe_code = "forbid" (AGENTS.md §0). Some OS features every desktop app needs have no safe binding: registering Apple event handlers (files opened from Finder), ending an NSProcessInfo activity, SetThreadExecutionState, ITaskbarList3, ShellExecuteW, SystemParametersInfoW, and attaching a muda menu to a Win32 window. The bindings used (objc2-*, the official windows crate, muda) are generated declarations.

Decision

Allow unsafe in exactly one crate, ashlar-native, for OS FFI. Containment rules (the same as the video editor’s platform crate these were adapted from):

  1. The crate does not use lints.workspace = true. Its own [lints] table copies the workspace lints with unsafe_code = "deny" (not forbid) and adds clippy::undocumented_unsafe_blocks = "deny". Only FFI modules carry #[allow(unsafe_code)]:

    • power (macOS endActivity:, Windows SetThreadExecutionState)
    • open_files (macOS: the Apple event handler class and its registration)
    • progress (Windows ITaskbarList3)
    • system (Windows ShellExecuteW, SystemParametersInfoW, GetUserPreferredUILanguages (added 2026-11-10 so apps started from the desktop, which have no LANG, find the user’s languages); and for bring_to_front, added 2026-11-10: IsIconic, ShowWindow, SetForegroundWindow, BringWindowToTop, AttachThreadInput)
    • mac_shell (macOS: replacing the implementation of winit’s window delegate observeValueForKeyPath:ofObject:change:context: with a checked wrapper that calls the original, class_addMethod of applicationShouldHandleReopen:hasVisibleWindows: on winit’s application delegate, and the msg_send!s that inspect the KVO change); added 2026-11-10 so a nil appearance during screen capture doesn’t end the app and the Dock icon shows the window
    • menu::popup (muda’s show_context_menu_for_nsview / show_context_menu_for_hwnd on the window’s own view or HWND, retaining the macOS view, and posting the macOS menu to the main run loop) and drag_out (macOS: an NSDraggingSource class, retaining the window’s view, an NSDraggingItem frame); added 2026-11-10 for context menus and dragging files out
    • single_instance (Windows AllowSetForegroundWindow, one block; added 2026-11-10 so the second launch lets the first come to the front)
    • menu::native::attach_window (muda’s init_for_hwnd) and menu::native::accelerators (Windows: SetWindowSubclass, TranslateAcceleratorW, DefSubclassProc; added 2026-11-10 so menu shortcuts work without a hook in winit’s message loop)
    • terminate (Unix, through libc: pipe, fcntl, sigaction, and in the handler only write, signal and raise, which are async-signal-safe; added 2026-09-10 so a stdio server can end its telemetry session when it is asked to stop)
    • memory (macOS proc_pid_rusage, glibc malloc_trim, Windows GetProcessMemoryInfo; added for idle apps that report their footprint and hand freed memory back; macOS proc_pidinfo and Windows CreateToolhelp32Snapshot/Process32FirstW/Process32NextW/ CloseHandle added 2026-11-10 for the thread count in app.stats)
    • display (Windows GetProcessWindowStation, GetUserObjectInformationW; added 2026-10-27 so an app started where no window can be shown says so and exits instead of panicking; Windows EnumDisplayMonitors with its callback, GetMonitorInfoW and GetDpiForMonitor added 2026-11-10 to list the monitors before the window exists, so a saved placement is checked at launch; macOS uses objc2’s safe NSScreen bindings)
    • notify::macos (the UNUserNotificationCenterDelegate class, its init, and setUserInfo:) and notify::windows (SetCurrentProcessExplicitAppUserModelID, CoInitializeEx); added 2026-11-10 so banners show while the app is frontmost, clicks are reported and Windows gets toasts
    • session_end (macOS: class_addMethod of applicationShouldTerminate: on winit’s application delegate class, and reading that method’s implementation; Windows: a hidden window’s class, window and message loop for WM_QUERYENDSESSION / WM_ENDSESSION); added 2026-11-10 so Dock Quit, logout and shutdown get the “Save changes?” question and the settings, autosave and telemetry flush

    Everything else (dialogs, clipboard, secrets, Linux notifications, macOS progress and preferences, single instance off Windows, tray, global shortcuts) uses safe APIs of rfd, arboard, keyring-core, objc2 and zbus.

  2. Every unsafe block has a // SAFETY: comment saying why it is sound.

  3. The public API is safe: no pub unsafe fn, no raw pointers or FFI types in signatures. Window handles come in through raw_window_handle::HasWindowHandle, so a caller can’t pass a dangling HWND. Failures are Result<_, NativeError>.

  4. No panic crosses an FFI boundary: the Apple event handler methods and the notification completion block run under catch_unwind; calls into third-party bindings that could panic (rfd, arboard, muda, keyring) go through guarded, which turns a panic into NativeError::Os.

  5. The crate compiles on every target. OS bindings are target-specific optional dependencies enabled per cargo feature; elsewhere a call returns NativeError::Unsupported. With default features it passes cargo check --target wasm32-unknown-unknown.

Consequences

  • Changes to the FFI modules need the same scrutiny as any FFI code. A new use of unsafe goes into one of the listed modules with a SAFETY comment, or needs an update to this record.
  • Main-thread requirements (macOS menus, Dock tile, Apple event handlers) are checked at run time with MainThreadMarker and reported as errors instead of crashing.

Benchmarks

How fast ashlar is, how that was measured, and how to reproduce it. Machine: Apple M5 Pro (15 cores), 24 GB, macOS. All builds are release builds, run through memgate.

Two kinds of numbers appear below:

  • Quiet measurements were taken with nothing else heavy running. Comparisons use only these.
  • The full-suite snapshot (cargo xtask bench, at the end) ran while other builds shared the machine. Its absolute timings are inflated, often by 2–4×. Allocation counts, sizes and frame counts are not affected by load.

Running them

cargo xtask bench                                   # every `perf_*` test; writes target/bench/bench.{json,md}
cargo xtask bench --filter plain_scroll             # one group
cargo xtask bench --repeat 3 --compare base.json    # fails on a >5 % regression against a saved run
cargo xtask launch --runs 9                         # launch → window → first present, opens real windows
cargo xtask launch --memory                         # idle footprint and peak after a settle

A perf test is a #[test] #[ignore] fn perf_* that prints bench: <metric> <value> <unit> lines (see docs/architecture.md, “Performance gate”).

Against a comparable native Rust UI framework

Measured on 2026-11-10, both frameworks in the same session with the same tools:

  • a launcher that times spawn → window, plus ps and vmmap for idle cost
  • each framework’s own benchmark for the same scrolling workload: a plain list of 1,000 rows of 32 pt in a 760×800 pt viewport at 2×, with 120 warm-up frames and then 480 wheel steps of 12 pt at 120 Hz, reversing halfway

The other framework draws with its own GPU renderer and a retained element tree. ashlar draws with egui.

Metricashlarother frameworkBetter
Scroll 1k plain rows, frame CPU p50 / p95, with list::Row0.10 / 0.12 ms0.298 / 0.32 msashlar, ~2.8×
Same, rows drawn as egui selectable labels0.65–0.76 / 0.70–0.81 ms0.298 / 0.32 msother, ~2.3×
Scroll 10k plain rows, p50, list::Row / labels0.96–1.15 / 8.6–8.9 msnot run—
Stripped hello binary14.0 MB; 9.2 MB with --profile release-small21.3 MBashlar
Incremental rebuild of hello5.3 s14.7–17.7 sashlar, ~3×
Clean release build of hello (loaded machine)133 s133 stie
Launch → window, warm median of 8106.5 ms98 msother, ~8 %
Launch → window, cold first run111 ms1,360 msashlar
Idle CPU over 10 s0 %0 %tie
Idle footprint / peak footprint40 MB / 94 MB (window never covered); 66 MB / 192 MB after the window was covered29 MB / 176 MBother at idle
Dependencies of hello (unique packages)179202ashlar

What each scroll number covers:

  • ashlar’s is the thread CPU of the egui pass plus tessellation.
  • The other framework’s is the thread CPU from wheel dispatch to its draw list.
  • Neither includes GPU submission.

Lists should be virtualized (VirtualList). Then the cost does not depend on the row count at all: 0.035 ms at 1k or 100k rows, measured quiet.

Idle memory, explained: ashlar’s idle footprint is about 40 MB (94 MB peak) for a window that has never been covered, and about 66 MB (about 193 MB peak) once it has been covered, minimized or moved to another Space, even briefly right after launch. At that point macOS maps two swapchain images it shares with the window server into the process (about 19 MB), and Metal allocates a second set of blocks. This happens with any present mode or frame latency, so it is not something ashlar’s own settings cause. cargo xtask launch --memory launches 5 times and reports both groups. The other framework’s 29 MB was measured the same way. Whether its windows avoid the effect, or were simply never covered during the measurement, is not known.

Before and after the 2026-11 improvement work

All quiet measurements. “Before” is commit 393afe0.

MetricBeforeAfterChange
Plain 1k-row list, frame CPU p500.63 ms (labels)0.10 ms (list::Row)6× less
Typing with a search query, 100k notes, keystroke p954.6–5.0 ms0.53 ms9× less
Selecting the last of 100k notes, frame p950.106 ms0.038 msno longer depends on the selected row’s position
Live resize, frame p950.103 ms0.034 ms3× less
Static 52 KB markdown, frame p500.944 ms0.015–0.031 ms30–60× less
Static 52 KB markdown, layout jobs per frame1156
Long 50 KB markdown paragraphquadraticlinear (md_long_paragraph_parse 0.4–0.7 ms)
Galley cache, worst frame after it fills4,160 re-layouts0
One row height change in a 1M-row variable list1,388 µs~1.3 µs
Frames drawn per engine command3.201.2661 % fewer
Frames over 30 s of idle with a focused text field194 (all “input”)21 (caret blinks), then 0
Allocations per idle wake frame95427271 % fewer, gated at ≤ 578
Allocations per keystroke with a query3,716990
Allocations per scroll frame (100k notes)472345
Stripped hello binary12.76 MB (scratch app)9.2 MB with release-small

Component costs (quiet)

MetricValue
VirtualList scroll, 1k / 100k rows0.035 / 0.033 ms
Tree, 1M nodes, scroll frame p950.041 ms
Code view, 100k lines, scrolled frame0.058 ms (first frame about 4.5 ms)
Diff view, 50k lines, scrolled frame0.41 ms (split 0.49 ms)
Chat transcript, 10k messages, frame0.129 ms (1k: 0.092 ms)
Myers diff, 10k lines2.08 ms
Syntax highlighting, 1k lines (Rust / TypeScript / Python)477 / 757 / 280 µs
Headless wgpu screenshot, 2560×1600 including PNG and base6426 ms
Spring sample / rect retarget / OKLab mix7.9 ns / 185 ns / 36 ns
Galley cache hit20 ns
Document edit at 100k elements, chunked vs flat515 ns vs 12,632 ns
Undo or redo14 ns

Full-suite snapshot (loaded machine)

This is cargo xtask bench at imp 30e3264, run while five other builds were compiling. Use it to see which metrics exist, not for absolute timings: run it on a quiet machine before comparing.

MetricValue
allocs_per_idle_wake_frame272 (gate ≤ 578)
bytes_per_frame_scroll_100k203,865 B
notes_first_frame_wall18.3 ms
frame_p95_scroll_100k / tessellate_p95 / frame_cpu_p95_x20.132 / 0.026 / 0.183 ms
frames_per_key_command3.0
plain_scroll_1k_rows_widget_p500.275 ms (0.10 ms quiet)
virtual_scroll_100k_p500.057 ms
md_static_frame_p50 / md_static_layout_jobs0.031 ms / 6
galley_overflow_relayouts0
one_row_height_change_1m1.5 µs
transcript_10k_messages_frame0.111 ms
code_view_100k_lines_frame / diff_50k_lines_frame0.094 / 0.428 ms
hello_stripped_bytes14,009,160 B

Control protocol

Every ashlar app can be driven by an agent in three ways, all speaking one method vocabulary:

SurfaceTransportCrate
Control portJSON lines over TCP 127.0.0.1:<port>, keyedashlar-control (Server, Client)
Child processJSON lines over stdin/stdout, no key (private pipe)ashlar-control::serve_stream, <tool> serve
MCPJSON-RPC 2.0 over stdio; tools map onto the methods belowashlar-mcp
CLIone-shot verbs, one-line repliesashlar-cli

Protocol version: 1 (reported by app.version).

Framing

One JSON object per line (\n, an optional \r is stripped), each way, one reply per request, in order.

Request:

{"id": 7, "key": "<64 hex digits>", "method": "engine.execute", "params": {"command": "edit.undo"}}
  • id (optional, any JSON): echoed in the reply; omitted from the reply when absent.
  • key: the app’s control key (TCP only; see Keys). Not needed after auth on the same connection.
  • method (required string).
  • params (optional; null or absent means {}).

Reply:

{"id": 7, "ok": true, "result": …}
{"id": 7, "ok": false, "error": "human-readable message", "detail": …}

detail is optional machine-readable context: for a failed command, the command error {"kind": "unknown"|"disabled"|"bad_params"|"failed"|"panicked", "id": …, …}; for a failed batch, {"steps": [...]}.

Hardening (TCP)

The server is built so that a hostile local process or web page cannot drive or crash the app:

  • Loopback only. Binds 127.0.0.1; the client only connects there.
  • Key. Once the port is bound the app writes 32 random bytes (64 hex digits) to <dir>/control-key.<instance>, created with mode 0600, where <dir> is a folder the app chooses (its settings folder) and <instance> is the Flatpak instance id or local-<port>. The file is removed when the server is dropped; a stale one is replaced. Every request must carry the key, as the top-level key member or once per connection with auth. A missing or wrong key gets {"ok":false,"error":"unauthorized"} and the connection is closed. Keys are compared in constant time. params.key is an ordinary parameter for every method but auth (ui.key {"key":"Enter"}).
  • Strict lines. A line that is not a JSON object with a string method (invalid UTF-8, bad JSON, an array, a number, an HTTP request line) gets one error reply and the connection is closed, so nothing after it runs. This is what stops a browser’s cross-origin fetch POST: its request line POST / HTTP/1.1 is rejected before the JSON body is read.
  • Bounded lines. Longer than max_line (4 MiB default): error reply, the rest of the line is drained (at most 64 MiB, 2 s) so the client can read the error, then close. A peer can never make the server buffer more than max_line.
  • Connection cap. At most max_connections (16) at once; extra ones get {"ok":false,"error":"too many control connections"} and are closed.
  • First-request deadline. A connection must send an authorised request within 30 s. It is one overall deadline: blank lines or a slow drip of bytes do not extend it. After that, no idle limit.
  • Reply timeout. The app thread answers within 60 s or the client gets timeout: ….
  • Never panics. No unwrap/indexing on input; junk never reaches the app thread; a request the app drops unanswered replies with an error by itself.

Standard methods

Answered by ashlar_control::handle from any Dispatch (headless or windowed):

MethodParamsResult
auth{key} or top-level key{} (transport-level)
app.ping—"pong"
app.version—{name, version, protocol, methods: [...]}: methods lists every method this app answers, so clients know whether ui.* works
engine.commands{query?} (case-insensitive, matches id, label, menu path)[CommandInfo]: {id, label, menu, shortcut, params_schema, params_text, enabled, disabled_reason, mutates}
engine.execute{command, params?}the command’s result
engine.batch{commands: [{command, params?}], keepGoing?}[{command, ok, result}]; on failure an error with detail.steps (stops at the first failure unless keepGoing; at most 10 000 steps)

UI methods

Routed to the app’s UiHandler. A handler may keep a request and answer it on a later frame. Headless sessions refuse them with a hint. The windowed runtime answers the app-level ones itself and hands the debugger methods (next section) to ashlar_ui::devtools:

MethodParamsResult
ui.inspect—UI state plus every registered element {id, label, role, rect:[x,y,w,h], enabled, value?, selected?}
ui.menu.list—the menu tree with enablement
ui.menu.invoke{id}as engine.execute
ui.setapp-defined view state ({theme?, workspace?, …}){}
app.quit{discard?}{closing}, then the app quits
app.stats{reset?}frame times, passes, jobs, wakes (frames and requests per reason)
app.logs{limit?, level?}{lines: [{unix, level, target, message}]}
app.clockone of {pause: true}, {step_ms}, {resume: true}, or {} to read{paused, time}
ui.scene{name}, or {} to list{scene} / {scenes: [..]}; only apps that declare scenes (App::scene) list it

app.clock holds the time the app’s frames see, to look at an animation at an exact moment. pause freezes it at the current frame’s time; step_ms (0 < ms ≤ 60000; pauses first) moves the frozen time on and runs one frame there; resume lets time run again from the frozen value, so nothing jumps (the app’s time then lags the wall clock by the time spent paused). time is the egui time in seconds the next frame sees. While paused:

  • input still runs frames, at the frozen time. Two clicks are then 0 s apart, so egui counts them as a double click; step between clicks that must stay single.
  • everything timed by egui time stands still: egui’s animations, ashlar_ui::motion, tooltip delays, toast timeouts, the caret’s blink phase and other timed wakes. A timed wake still runs a frame about once per its delay (the frame finds it not yet due), and an egui animation that is mid-flight keeps asking for frames that draw the same picture.
  • settings and autosave run on the host’s clock, so saves are not held up. Background jobs and control requests run as usual.

ashlar-debug clock pause|step <ms>|resume (also a .ui script line) and the MCP tool ui_clock send it. A headless session refuses it.

Apps may add their own methods (ui.timeline.hit…); list them in UiHandler::methods so they show in app.version.

Debugger methods

Drive the UI the way a browser automation tool drives a page: snapshot the element tree with short refs, act on refs, wait, read, screenshot. Implemented by ashlar_ui::devtools (an egui plugin), so an app that does not use ashlar-app gets the same methods (the ashlar-debug CLI and the MCP ui_* tools speak them; see debugging-uis.md) by passing requests to devtools::handle(ctx, request) (see that module’s docs). app.version lists them when available.

Elements and refs

The element tree is egui’s AccessKit output (every egui widget: role, label, value, state, bounds) merged with the ashlar automation registry (dotted ids, component roles). An element:

{"ref": "e12", "id": "notes.title", "role": "textbox", "name": "Title", "value": "Groceries",
 "enabled": true, "focused": false, "selected": false, "checked": null, "visible": true,
 "rect": [x, y, w, h], "parent": "e3"}

id, value, selected, checked and parent are omitted when they do not apply. rect is the on-screen part in logical points. Roles: button checkbox switch radio radiogroup textbox searchbox slider spinbutton combobox link label image colorwell panel window dialog scrollbar separator progressbar menu menubar menuitem tab tablist list listitem tree treeitem table row cell columnheader toolbar status alert tooltip group generic.

A ref stays the same for an element across snapshots while it exists (keyed by its automation id, else by its egui widget id and role); every ui.snapshot increments the generation. The first debugger request turns egui’s AccessKit output on for the rest of the session.

Targets

Every method that acts on an element takes a target, as target: {...} or flat in the params:

TargetMatches
{ref: "e12"} (also "@e12", 12, or the bare string "@e12")the element of a snapshot
{id: "file.save"}the (first visible) element with that automation id
{text: "Save", role?: "button"}the first visible element with that name: exact, then ignoring case, then containing it; interactive elements first. Not accepted flat (text is the typed text of ui.fill/ui.type)
{x, y}that point (logical points); the element there, if any

The error text starts with the kind (stale_ref: …, not_found: …, timeout: …) and detail.kind carries it: stale_ref ({kind, ref, generation}: the element of that ref is gone; take a new snapshot), not_found ({kind, generation}), timeout ({kind, elapsed_ms}).

Methods

All answer once the frames they need have run; input methods return {} after the injected input has been processed by a frame.

MethodParamsResult
ui.snapshot{interactive?: false, compact?: false, scope?: target, depth?}{generation, tree, elements: [element], focused: ref│null}
ui.clicktarget, button? (primary/secondary/middle), count? (1–3), modifiers?{}
ui.hover / ui.movetarget (ui.move: {x, y}){}
ui.focustarget{}: AccessKit focus, or a click for text fields
ui.filltarget?, text, submit?{}: click the target (not when it is a text field that has the keyboard already), select all, type text (or delete when empty), Enter when submit; without a target, the focused field
ui.typetarget?, text{}: typed at the caret of the target (clicked first, unless it is a text field that has the keyboard already, so calls in a row add up) or of the focused widget
ui.presskeys ("Mod+Shift+S", sequences "G G"; ashlar_command::shortcut syntax; key is accepted too){}
ui.key{key, modifiers?}{} (one key; modifiers: {command, shift, alt, ctrl}, command = ⌘ on macOS, Ctrl elsewhere)
ui.scrolltarget? (default: the window centre), dx, dy in points; dy > 0 scrolls down (towards the end), dx > 0 right{}
ui.scroll_into_viewtarget{}
ui.drag{from: target, to: target, steps?: 8, modifiers?}{}
ui.gettarget, what: text (a field’s content, else the name), name, value, rect, enabled, visible, focused, checked, selected, role, element{value}
ui.wait{for: element│gone│text│enabled│idle, target?, text?, timeout_ms?: 5000} (for defaults to text when text is given, else element){elapsed_ms}; error timeout
ui.screenshottarget? (crop; also the legacy {id}), pad?, annotate?, highlight?: target, raise?: true{png: <base64>, width, height, annotations?: [{ref, rect}]}
ui.highlighttarget, ms?: 1500{rect}: the element is outlined in the live window
devtools.toggle{open?}the state below
devtools.state—{open, generation, elements, pending: {input_frames, waits, screenshots}, repaints_while_open}

The text outline of ui.snapshot, one element per line, two spaces of indent per level:

- button "New Note" [ref=e5] [id=notes.add]
- list [ref=e8] [id=notes.list]
  - listitem "Groceries" [ref=e9] [id=notes.list.row.0] [selected]
- textbox "Title" [ref=e14] [id=notes.title] [value="Groceries"] [focused]

Flags: [disabled] [focused] [selected] [checked]│[unchecked] [offscreen]; names and values are cut at 80 characters. interactive lists only visible controls; compact leaves out unnamed non-interactive and off-screen elements. Both leave out unnamed generic elements (drag areas, click catchers) that have no automation id.

Waits are checked on frames the app runs anyway plus one wake at the deadline: a pending wait never makes the app repaint in a loop. idle means a frame asked for no repaint within 200 ms, no injected input is pending and the app reports no work of its own (devtools::set_busy; the runtime reports its background jobs). Screenshot annotations[].rect is in image pixels relative to the returned image; annotate and highlight are painted into the captured frame. Screenshots are rendered off screen when the app provides a renderer (devtools::set_offscreen; ashlar-app uses eframe’s wgpu device), so they work for a window that is covered, in the background, minimized behind others or on a locked screen; ashlar-app also runs the UI passes other ui.* requests need while such a window draws none (Runtime::hidden_pass). Otherwise the backend captures a presented frame: the request is repeated every 0.25 s, the window is brought to the front once (unless raise: false), and after 1 s without a frame it fails with an error saying why. Either way it never hangs and never spins: a failed screenshot costs a bounded handful of frames. Pictures travel inline, never through a predictable temp file.

The inspector

F12 (or devtools.toggle) opens an inspector window in the app: Pick (hover outlines an element with its role, name, ref, id and rect; a click selects it and copies @eN), the element tree, frame and wake counters with a live repaint counter, and panels the app provides (devtools::section: the runtime adds its recent logs and the undo history). The inspector is not part of snapshots. Closed, it costs nothing: no frames, no allocations.

MCP tools

ashlar-mcp exposes the same vocabulary as tools. Arguments marked ? are optional; a target is a ref string ("@e3", "e3"), "id=…", "text=…", or an object as in the debugger methods.

ToolMethod
list_commands {query?}engine.commands (compact: id, label, enabled, mutates)
describe_command {command}engine.commands, one entry in full, with its parameter schema
execute {command, params?}engine.execute
batch {commands, keepGoing?}engine.batch
screenshot {target?, annotate?, highlight?, pad?, raise?, id?}ui.screenshot, returned as an MCP image (plus {width, height, annotations?}); id is the legacy crop
ui_snapshot {interactive?, scope?, depth?, compact?, elements?}ui.snapshot: the outline as text; elements: true adds the element list as JSON
ui_inspectui.inspect
ui_click {target?, id?, x?, y?, button?, count?, modifiers?}ui.click
ui_hover {target}ui.hover
ui_focus {target}ui.focus
ui_fill {target, text, submit?}ui.fill
ui_press {keys}ui.press
ui_drag {from, to, steps?, modifiers?}ui.drag
ui_scroll {dx, dy, target?, x?, y?}ui.scroll
ui_scroll_into_view {target}ui.scroll_into_view
ui_get {target, what}ui.get
ui_wait {for, target?, text?, timeout_ms?}ui.wait
ui_highlight {target, ms?}ui.highlight
ui_key {key, modifiers?}ui.key
ui_type {text}ui.type
ui_menu_listui.menu.list
ui_menu_invoke {id}ui.menu.invoke
ui_set {…}ui.set (app-defined keys)
app_stats {reset?}app.stats
app_logs {limit?, level?}app.logs (level: error, warn, info, debug or trace)
ui_clock {pause?, step_ms?, resume?}app.clock
ui_scene {name?}ui.scene
docs_categories {category?}, list_docs {category?, query?, recursive?}, read_doc {path}the app’s documentation library (no control method)
save_doc {path, content, replace_builtin?}, append_doc {path, text}, delete_doc {path}the user’s own pages, when the server has a user folder

screenshot, the ui_* tools (ui_clock and ui_scene included) and app_stats/app_logs are listed only when the backend’s app.version reports their method, so an agent never sees a tool the app cannot serve. The documentation tools are listed when the server has a library or a user folder. With ToolMode::PerCommand every command is also its own tool (dots become _) with its real input schema. crates/mcp/tests/protocol_doc.rs checks this table against the code.

CLI reply contract

<tool> exec <id> [json], describe, commands --json, run, version --json print one line: {"ok":true,"result":…} or {"ok":false,"error":"…"} (plus detail). Exit codes: 0 ok, 1 the command failed, 2 usage error.

Testing and debugging UIs with ashlar-debug

You can drive any ashlar app that serves a control port from outside the process, the way a browser automation tool drives a page. You read the UI as an outline of elements with short refs, act on those refs, wait for the result and check it. Three clients use the same methods:

WhoHow
You, in a terminalashlar-debug (crate ashlar-debug)
A test or CI jobashlar-debug batch file.ui, or ashlar_debug::run from a Rust test
An agentthe ui_* tools of ashlar-mcp (<tool> mcp --connect PORT)

The methods (ui.snapshot, ui.click, ui.fill, ui.wait, …) are specified in control-protocol.md.

A session

ashlar-debug open ./target/debug/my-app      # free port, --control <port>, waits for app.ping
ashlar-debug snapshot -i
# - textbox "Title" [ref=e2]
# - button "Save" [ref=e3]
ashlar-debug fill @e2 "Groceries" --submit
ashlar-debug wait --text "Saved" --timeout 3000
ashlar-debug screenshot --annotate           # → ~/…/ashlar-debug/screenshots/shot-….png
ashlar-debug close

Each command runs as its own process. The session file remembers the port and the key folder, so the next command finds the app again. Use --session NAME to drive two apps side by side. To reach an app that is already running with --control <port>, use ashlar-debug connect <port> or --port <port> (its key is found in the OS settings folders), or --app <name>.

The output is plain text by default. With --json, each command prints one {"ok":true,"result":…} or {"ok":false,"error":…,"detail":…} line. Exit codes are 0 for success, 1 when the app refused, an assertion failed or nothing answered, and 2 for a usage error.

Refs and targets

A snapshot gives every element a ref (e3). Refs are stable: the same element keeps its ref across snapshots for as long as it exists. A ref that no longer exists fails with stale_ref, and you take a new snapshot. Instead of a ref you can also write id=<automation id> (the most robust choice in a test script, since it survives layout changes), text=<label>, <role>=<label> (button=Save), or a point x,y.

If you write an app and want stable test targets, give the important widgets automation ids. Use the ids of the commands they run (file.save), as the menus already do.

UI tests

Write a script with one command per line, plus assertions:

# tests/ui/save.ui
open ./target/debug/my-app
fill id=title "Groceries"
press Mod+S
wait --text "Saved"
assert text id=status "Saved: Groceries"
assert enabled id=edit.delete
assert count role=listitem >= 1
close

ashlar-debug batch tests/ui/save.ui stops at the first failure:

FAIL tests/ui/save.ui:7: assert enabled id=edit.delete
     expected id=edit.delete to be enabled, but enabled is false
error: tests/ui/save.ui:7: expected id=edit.delete to be enabled, but enabled is false
hint: 5 step(s) passed before line 7

Each step’s result is in the --json output (detail.steps). If you add trace start <dir> at the top, every action leaves a screenshot and a snapshot in <dir>, numbered in order (001-click.png, 001-click.txt, one line per step in steps.jsonl). That is the first place to look when a script fails on a CI machine. With --port or --app (no session) the trace lasts for that one batch run; outside a batch they refuse trace with a usage error, since there is no session to keep it in.

Running a script from Rust gives you the same thing in-process. ashlar_debug::run(args, &env, &mut io) takes an Env that points the state and key search at a temporary folder:

let env = ashlar_debug::Env::under(&tmp); // state in tmp/home, keys looked for in tmp/config
let code = ashlar_debug::run(vec!["--port".into(), port.to_string(), "--key-dir".into(), key_dir, "batch".into(), "-".into()], &env, &mut io);

examples/notes/tests/debug.rs does this against the windowed runtime on a headless event loop, so it needs no display. It also has a test that opens a real window, which runs only when ASHLAR_DEBUG_REAL_WINDOW=1 is set.

Scripts in process, on virtual time

Driver::run_ui(script) runs the same script language on a Driver, with no port and no thread. sleep N and wait N let virtual time pass instead of the wall clock, and waits such as wait --text run frames on the virtual clock, so a script gives the same steps on every run and takes milliseconds:

let mut d = Driver::new(notes::app().runtime());
let report = d.run_ui("exec note.add '{\"title\": \"x\"}'\nsleep 2000\nassert exists id=notes.add\n")?;
assert_eq!(report.passed, 3); // report.steps: one {"line", "command", "ok", "result"} per step

A failure is a ScriptError naming the line, with the same detail as batch --json. Verbs about sessions, processes and traces (open, connect, close, sessions, trace, demo) fail in process. To run scripts against something else, implement ashlar_debug::Transport (call, and pause for time) and call ashlar_debug::run_script; the TCP Client is one such transport.

One command: --headless

Every binary whose main calls App::run takes --headless: no window, the app on a virtual clock in a fresh temporary folder (never your settings or documents), real pixels when there is a GPU (grey otherwise, with a note on stderr):

notes --headless --size 1024x700 --scale 2 --theme dark --scene two-notes --script add.ui --at 1.5 \
      --snap out.png --tree tree.json

--size is in points (default 1280x800) and the PNG is that times --scale. --script runs a .ui script first, --at lets that many seconds pass before the picture, and --tree writes the ui.snapshot JSON. Exit codes match ashlar-debug: 0 done, 1 a step failed, 2 bad arguments. examples/notes/tests/headless.rs checks the PNG size and the tree.

Real pixels with no window

A Driver gives flat grey screenshots by default, which keeps tests that need no GPU fast. For real pixels, build it with Driver::with_renderer(runtime, Renderer::wgpu()): frames render on a wgpu device with no window or surface, so ui.screenshot, annotate and trace show what a window would, at the scale set with Driver::pixels_per_point. A software adapter is preferred when there is one. On a machine with no adapter, Renderer::wgpu() falls back to grey and Renderer::reason() says why. Every screenshot reply says which was used: "renderer": "wgpu" or "renderer": "none". Renders of the same state are byte-identical on the same machine, but can differ between GPUs, so compare pixels across machines only with a tolerance. crates/app/tests/headless_render.rs shows the pattern, including how to skip without a GPU.

Named scenes

A scene is a fixed starting state: an empty document, a long list, an error banner, a fake service. Declare them on the app:

App::<Notes>::new("notes", "0.1.0")
    .scene("empty", |_: &mut Session<Notes>| {})
    .scene("two-notes", |s: &mut Session<Notes>| { /* add two notes */ })
  • notes --list-scenes prints the names, one per line.
  • notes --scene two-notes starts the window in it; notes --headless --scene two-notes --snap x.png pictures it (an unknown name exits 2). The scene runs before --theme and --script.
  • Driver::scene("two-notes") applies it in a test.
  • Agents use the ui.scene {name} control method or the MCP tool ui_scene ({} lists the scenes). Both are listed only when the app declares scenes.

Golden snapshots

Driver::assert_snapshot(name) and the script line assert screenshot <name> [target] [--tolerance channel=2,pixels=4] compare a screenshot with tests/snapshots/<name>.png (relative to the working directory, the package folder under cargo test; ASHLAR_SNAPSHOT_DIR overrides it):

  • The comparison is exact by default. A tolerance allows pixels whose channels are off by at most channel, and up to pixels pixels beyond that. A tolerance given when recording is kept in the baseline’s meta and used when a check names none.
  • On a mismatch it writes <name>-new.png and <name>-diff.png (differing pixels magenta), and fails with both paths.
  • ASHLAR_UPDATE_SNAPSHOTS=1 records the baselines instead, with <name>.meta.json saying what rendered them (adapter, backend, scale, size).
  • A baseline from another adapter or backend is skipped with a note on stderr, not failed: GPUs differ by a few pixels. With no GPU renderer (grey screenshots) it is skipped too.

examples/notes/tests/snapshots.rs checks the notes app’s empty and two-notes scenes this way. Egui’s own test harness has a snapshot feature, but it uses its own renderer and a perceptual compare with no adapter metadata, so ashlar keeps a small module (ashlar_debug::snapshot) that works on any ui.screenshot reply, from a Driver or a live app.

Without an app: the demo

ashlar-debug demo --control <port> serves a small fake app with a text field, buttons, a checkbox, a list and a delayed “Load”. It answers every debugger method deterministically. The crate’s own tests drive it, and you can use it to try the CLI or to check an MCP client: ashlar-debug open ashlar-debug -- demo.

Debugging a live app

  • snapshot (full tree) or snapshot --scope @e8 shows what is on screen and why a target does not match: a disabled element shows [disabled] and a focused one [focused].
  • highlight @e3 flashes an outline in the app window, which helps when you are pairing with a human.
  • screenshot --annotate draws each interactive element’s ref on the picture, next to the element rather than over its text. A covered or minimized window, or one on a locked screen, presents no frames; ashlar-app then runs a UI pass of its own while debugger work waits and renders screenshots off screen, so snapshots, input and screenshots work without bringing it to the front. An app without an off-screen renderer has its window raised once for a screenshot, and the request fails after a few seconds if that does not help. Use screenshot --no-raise, a session opened with open --no-raise (or connect --no-raise), or ASHLAR_DEBUG_NO_RAISE=1 for CI and background runs that must never steal focus. The MCP screenshot tool takes raise: false for the same purpose.
  • logs -n 50, stats and devtools open show the app’s recent log lines, its frame and job statistics, and the in-app inspector.
  • clock pause, clock step 16.7 and clock resume hold the app’s animation clock: pause, take screenshots or get rect at an exact moment, step one frame at a time, then resume with no jump. Input still works while paused. The same lines work in .ui scripts. See control-protocol.md for what else stands still.
  • call <method> '{json}' sends any control method as is.

Launch time and memory

  • stats (app.stats) reports launch: to_window_ms (from the first line of run() until eframe has built the window and its wgpu surface), to_first_frame_ms (the end of the first frame) and launch_to_present_ms (that frame painted and presented). The app also logs them once at info. Headless runs report none.
  • stats also reports process: footprint_bytes (macOS physical footprint, Linux private resident + swap, Windows private commit), peak_bytes (the highest footprint seen, sampled at most once a second while frames run and at every reading), cpu_s (CPU seconds of every thread) and threads (Linux only for now). A field the platform cannot report is null.
  • <PREFIX>_READY_FILE=path (the prefix is the app name in capitals, like <PREFIX>_CONTROL_PORT) makes the app write {"pid": …, "ready_ms": …} to path, atomically, once its first frame is presented: a launcher or a test waits for the file instead of sleeping.
  • <PREFIX>_QUIET=1 opens the window without taking the focus (on macOS the app doesn’t activate either), so an agent that rebuilds and relaunches the app in a loop doesn’t pull the user out of the app they are typing in.
  • A document named on the command line opens on the frame after the first one, so the window shows before a slow load runs. eframe keeps the window hidden until that first frame is painted, so there is no white flash.
  • cargo xtask launch builds examples/hello in release, launches it 9 times (--runs N) and prints bench: launch_to_present_p50/p95 and spawn_to_ready_p50/p95 (from spawning the process). cargo xtask launch --memory launches it 5 times (--runs N), lets each idle 10 s (--settle SECS) and prints the lowest and highest RSS, physical footprint and peak (vmmap, macOS), with how many runs fell in the high group. Both open real windows, so they are manual and never run in CI; results swing with machine load, so compare runs made back to back.
  • macOS idle memory comes in two groups (IMP-2.19). A window whose occlusion state never changed idles at about 40 MB (94 MB peak). Once it changes (another window covers it, it is minimized, a Space switch, also by chance right after launch), the system maps two swapchain drawables (CAMetalLayer Display Drawable, shared with WindowServer, 9.5 MB each at 960×640 points 2x) into the process for good, and Metal allocates a second set of about 13 × 4 MB purgeable blocks (the ~95 MB peak jump): about 66 MB idle, 193 MB peak. It persists after 40 s idle and does not depend on present mode or frame latency; nothing in ashlar’s eframe/wgpu options changes it. Compare _max figures, which are what users see.

Frame performance

Debug builds watch their own frames and print one line to stderr, once per site, when:

  • a frame waits: it took ≥ 8 ms of wall time and at least 4× the UI thread’s CPU time, so the thread was blocked (file or network I/O, a lock, a sleep). The line names the command the frame ran, or else the wake reasons it was drawn for. Move that work to a job.
  • the same frame is drawn over and over: 60 frames in a row with identical shapes, drawn only for non-input wake reasons (a timer or an animation that never settles). The line names the reasons; app.stats → wakes counts them.

ASHLAR_FRAME_BUDGET_MS=N (debug or release) prints at most one line per second for frames over N ms: the pass time, the runtime’s own UI time, the UI thread’s CPU time, the command or wake reasons, and how many more frames went over since the previous line. ASHLAR_PERF_WARN=0 silences all of these. A release build with neither variable set installs nothing.

ASHLAR_FRAME_LOG=FILE (any build; Runtime::log_frames(path) in tests) writes one JSON line per frame, for comparing runs:

{"frame":12,"passes":1,"t":0.204,"wall_ms":3.1,"cpu_ms":2.9,"phases":{"pass_ms":3.1,"ui_ms":2.4},
 "reasons":["animation"],"commands":[],"input":false,"dropped":0}

passes is the pass number within the frame (egui sometimes runs a frame twice; each pass gets a line). cpu_ms is the UI thread’s CPU time, phases.ui_ms the runtime’s own part (menus, view, dialogs), reasons the wake reasons and commands the commands the frame ran. A background thread writes the file; it stops at 64 MiB with a last {"end": "size limit reached", …} line, and dropped counts lines lost if the writer falls behind. Not available on the web.

ASHLAR_RECORD=FILE (any build; Runtime::record_to(path) in tests) records a session for replay: one JSON line per frame with its time, screen size and input events (pointer, keys, text, wheel, clipboard, focus), and one line per control request served. A background thread writes it; when the file passes 32 MiB it becomes FILE.old and a new one starts, so the pair holds at most 64 MiB. Driver::replay(FILE) plays it into a fresh driver of the same app, frame by frame at the recorded times, which turns a bug report into a test:

let mut d = Driver::new(my_app().runtime());
d.replay("session.jsonl")?;
assert_eq!(d.call("ui.snapshot", json!({}))?, expected);

Revision numbers come from one process-wide counter, so compare how far a session moved them, not their values. Not available on the web.

Agents (MCP)

ashlar-mcp lists ui_snapshot, ui_click, ui_fill, ui_hover, ui_focus, ui_press, ui_get, ui_wait, ui_scroll_into_view, ui_highlight and screenshot (with target, annotate, highlight, pad and raise) when the app reports these methods in app.version, along with ui_inspect, ui_drag, ui_scroll, ui_key, ui_type, ui_menu_list, ui_menu_invoke and ui_set. app_stats {reset?}, app_logs {limit?, level?} and ui_clock {pause?, step_ms?, resume?} are the CLI’s stats, logs and clock. The full table with every argument is in control-protocol.md. Targets are ref strings ("@e3", "id=file.save") or objects. ui_snapshot returns the outline as text and adds the element list as JSON when you pass elements: true. A typical loop is: snapshot, act on a ref, wait, then snapshot or ui_get to check.

The control key

The control port accepts only clients that hold the app’s key. The app writes it to <key folder>/control-key.local-<port> with mode 0600. The key folder is the app’s settings folder, unless whoever started the app set ASHLAR_CONTROL_KEY_DIR; ashlar-debug open sets it to a private folder inside its session. The CLI never sends the key anywhere but 127.0.0.1.

Panel docking

ashlar_ui::docking arranges an app’s panels: it moves one panel at a time between docked tab groups, side-by-side splits and floating groups. The library owns placement and the gestures that change it. The app owns everything inside a panel, its commands, its documents, and the policy for saving and loading layouts.

The model

A Layout<P> is an optional docked tree (root) plus a list of floating groups, back to front. The tree has three node kinds:

  • Node::Tabs { panels, active }: a tab group, its panels in order and the index of the one shown.
  • Node::Split { axis, size, first, second }: two children side by side (Horizontal) or stacked (Vertical). size is a SplitSize: a ratio of the space, a fixed first size, or a fixed second size.
  • Node::Stack { entries }: an accordion. Each StackEntry has a panel, an open flag and an optional preferred body height.

A Floating<P> is a tab group with a rect [x, y, width, height].

P is the app’s panel id. It must be stable across sessions and languages (an enum or a string key, never a translated title) and implement Clone + Eq + Hash; the renderer also needs Debug + Send + Sync + 'static. A panel appears at most once. Per-document tabs (one tab per open file, say) belong inside a panel, not in the layout.

use ashlar_ui::docking::{Action, DockError, Floating, Layout, Node, Placement};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Panel { Outline, Inspector, History }

fn arrange() -> Result<Layout<Panel>, DockError> {
    let mut layout = Layout {
        root: Some(Node::Tabs { panels: vec![Panel::Outline, Panel::Inspector], active: 0 }),
        floating: vec![Floating { panels: vec![Panel::History], active: 0, rect: [40.0, 60.0, 260.0, 300.0] }],
    };
    // Dock History into the main group, in front of Inspector.
    layout.apply(Action::Move {
        panel: Panel::History,
        anchor: Panel::Outline,
        placement: Placement::Tab { before: Some(Panel::Inspector) },
    })?;
    Ok(layout)
}

Transactions

Every change is an Action, applied with Layout::apply or Layout::apply_with_permissions. A transaction checks the current layout, makes the change on a copy, checks the copy, and only then replaces the layout. When it fails, the layout is exactly as before and a DockError says why: a missing panel or target, a panel moved onto itself, a duplicate id, an active index out of range, too deep or too many (MAX_DEPTH, MAX_PANELS), or geometry that is not finite or out of bounds.

What a successful move guarantees:

  • the moved panel is never lost on the way;
  • each group keeps its selected panel by identity, not by index;
  • groups left empty disappear, and a split left with one child is replaced by that child;
  • an accordion entry keeps its open state and height when it moves to another accordion;
  • sizes of splits the move did not touch stay as they were.

Open adds a panel that is not in the layout yet (or activates it if it is). OpenAt places a new panel at an exact spot, and moves it there if it already exists. Whether a panel id is known to the app at all is for the app’s panel registry to check before it asks.

Permissions and fixed panels

apply_with_permissions takes a function from panel to Permissions (movable, floatable, closable, accepts_tabs). A document canvas is typically Permissions::PROTECTED: it cannot be moved, closed or floated, and nothing can join it as a tab, but other panels can still be split off beside it. A panel that must never leave the docked tree is the app’s rule to enforce. An empty root (root: None, or a Tabs node with no panels) is valid, for example when every panel floats.

Rendering

Apps that draw their own chrome can describe their groups as DropTargets and call resolve_drop with the pointer. Targets are listed back to front, floating groups in their current stacking order after the docked ones. The frontmost group under the pointer decides alone: if it refuses the drop, groups behind it are not considered. drop_zone and zone_rect give the zone rules on their own.

DockArea is the optional built-in renderer. It fits tabs into the strip and moves the rest into an overflow menu, previews drops, cancels drags on Escape or when the window loses focus, and draws split and accordion dividers and the floating groups’ resize corners. It never changes the layout: DockOutput::actions lists what the user asked for, in order, for the app to send through its own command path. With the serde feature Action serializes, so pointer gestures, keyboard commands and remote control can all go through the same path. Panel bodies get an egui id derived from the dock area and the panel only, so their widget state follows a panel when it moves; durable data should still live in the app, not in egui memory.

DockStyle holds the colours, font, metrics and the localized labels of the stock controls (DockStyle::from_ui gives defaults). show_with_limits takes per-panel PanelLimits. The renderer fits the view into the viewport: when there is not enough room, minimums shrink in proportion, and the preferred sizes stored in the layout are never overwritten by that. Divider gestures produce explicit ResizeSplit or ResizeStack actions.

A custom header that starts dragging a panel can hand the gesture to the renderer with begin_drag, using the same area id.

Floating groups are egui areas inside one viewport. Native windows, moving panels between viewports, and monitor coordinates are for the host to build.

Persistence

The serde feature derives serialization for the model; apps can also write their own adapter. The library picks no file location. Before trusting a loaded layout, validate panel ids against the app’s registry, cap the payload size, version the schema, and bound the depth of the deserializer: validate rejects deep trees, but it cannot protect a parser that has already recursed without limit.

Hiding and restoring panels

To hide a panel, capture layout.location(&panel), apply Close, and keep the Location alongside the app’s own visibility state. restore(panel, &location) puts it back using its neighbours’ ids, fixed split sizes, the floating rect, and recreates a closed accordion entry. If the neighbour is gone, the layout is left unchanged and MissingTarget is returned; the app then picks a fallback such as Open. Panels hidden from the same group come back correctly when restored in reverse order of closing.

What the tests cover

The module’s unit tests cover moves, floats and merges, rejected transactions, selection by identity, reordering, accordion state, permissions, location and restore for splits, floating groups and accordions, validation limits, drop resolution, 800 random moves, serde round trips, and the renderer’s gestures: drags with Escape and focus loss, size limits, the resize corner, accordion dividers, custom content, blockers, cancel_drag ownership, chrome_has_focus, and accessible names of the stock menu actions.

crates/ui/tests/docking_workflows.rs drives a document canvas and three populated panels with real pointer and keyboard input: it edits a value, merges a floating panel into a tab group, detaches it again and redocks it, at two widths, two scales and both themes, round-tripping every action through JSON. Set ASHLAR_UI_DOCK_VISUAL_DIR to an absolute path to keep a PNG per stage:

ASHLAR_UI_DOCK_VISUAL_DIR=/abs/path cargo test -p ashlar-ui --all-features --test docking_workflows

These checks run offscreen. They do not replace an app’s own tests of its panels and commands.

Custom chrome and content

show_customized takes one DockContent adapter instead of closures: body draws a panel, panel_menu adds entries at the top of a tab’s context menu, and group_header_width / group_header reserve and draw a strip at the right end of a group’s tab bar (clipped to it). Queue commands from these callbacks rather than changing the layout inside them. Collapsed accordion entries still have their context menu. Collapsing whole tab groups is a host policy.

label returns WidgetText, so a tab can show a shaped galley (icons, mixed styles); the same text is used to measure the tab, to paint it and in menus. The plain string from the label closure stays the accessible name. action_label shapes the text of the stock Float, Move and Close entries; it gets no panel id. Visible text never replaces an accessible name. show and show_with_limits remain for apps without an adapter.

Activation and focus

To bring a panel forward, apply Activate and pass raise_panel(panel) on the next frame: a floating group is raised and the panel’s tab takes keyboard focus. Stacking order is not stored in the layout. A floating group is identified by its first panel.

The 12-point grip at the left of a floating group’s tab strip moves the whole group with MoveFloating; dragging a tab detaches just that panel. DockOutput::group_drag and group_drop report which group is being dragged and where it was released, for hosts that turn a drop outside the window into a native window, say. Escape or focus loss puts the group back where it started.

blocked_rects marks screen areas (menus, dialogs, overlays) where previews, drops and the float fallback are blocked, including overlays that appear in the middle of a drag. Hosts pass the current overlay geometry every frame, or call cancel_drag while something modal is up.

DockArea::chrome_has_focus says whether keyboard focus is on the dock’s own controls: tabs, collapse and overflow buttons, dividers, resize corners and grips. Panel content and custom header or menu widgets do not count, and an invalid layout gives false. A host can use it to let global shortcuts through while Enter, Space, Tab, Escape and the arrow keys stay with the focused control.

Visual regressions

crates/ui/tests/visual_regression.rs renders real components and independent reference painters with the same offscreen wgpu renderer and compares the results. The reference painters never call the components: they draw literal geometry with plain egui painter calls. Together they define the visual contract of each control; they are not screenshots of an app.

Coverage

SuitePairsWhat is compared
Controls372icon button, single-shape button, tab, horizontal and vertical split × light/dark × scales 1, 1.5, 2 × normal and compact sizes × the states normal, hover, press, disabled, focus, plus selected and selected-press for buttons and tabs
Controls and content kit282controls::Button (primary, secondary, ghost, destructive), Switch, a RadioGroup option, Progress::bar (25 %, 100 %, the reduced-motion indeterminate pose), the content::Collapsible header and a dismissible content::Banner (info, warning, danger) × light/dark × scales 1, 1.5, 2 × the states normal, hover, press, disabled, focus, plus selected (on, chosen, open) and selected-press for the switch, radio and collapsible; drawn with the built-in token sets and no labels
Toolbar overflow18three widths × two themes × three scales against literal rects
Widget palette12cells painted from WidgetPalette::apply_to against literal painting
SVG icons12catalog output rasterized by the SVG loader against literal, pre-substituted SVGs

All 696 positive pairs must give identical RGBA pixels. The 684 pairs rendered with wgpu must also give identical meshes: vertices (position, UV, colour), indices, texture ids and clip rects. The SVG pairs compare the loader’s rasters and must contain visible pixels.

Each state is reached with real input, and the test asserts it was reached: the control is hovered, held down (and dragging, for split handles), or focused. Disabled controls must stay unhovered and unfocused under the same input. Selection wins over press. Split handles are driven in both orientations. The compact fixtures repeat every contract at a smaller size.

48 negative comparisons change one thing in the reference (fill colour, spacing, stroke width; the fill of a kit button, switch, radio, bar and banner, the collapsible’s chevron) and must differ in both pixels and meshes, which shows the comparison can fail. There is no tolerance, mask, crop, perceptual metric or automatic update. The toolbar fitting itself (malformed input, reachable overflow, groups kept in order) is covered by unit tests in the layout module.

Running

cargo test -p ashlar-ui --test visual_regression

With ASHLAR_UI_VISUAL_DIR=/absolute/path, each suite writes into its own subdirectory: <case>-before.png (reference), <case>-after.png (component), <case>-difference.png (changed pixels in magenta, 255,0,128) and comparison.tsv with the columns case, width, height, changed_pixels, mesh_equal and expected_change. Rows are appended as each pair finishes, so the file is useful even when a later pair fails. Images are at most 256×144 pixels. A renderer failure fails the test.

Limits

The comparison is same-machine only: both sides go through the same driver in the same run, so it says nothing about pixel identity across GPUs or drivers. It does not replace app screenshots, complete themes, text layout checks, command dispatch, window resizing, tests against real accessibility services, or cross-platform runtime tests. Apps that keep their own baseline images keep their own record of where those came from.

ashlar-trace: telemetry

Crash, performance and usage telemetry for Rust desktop apps, with a backend you host yourself.

  • ashlar-trace — a small client you embed. Safe Rust only, no async runtime, no UI toolkit. It never blocks your frame loop and never prints.
  • ashlar-trace-server — one binary and one data folder: ingest API, SQLite storage, and a dashboard for crashes, performance and usage.
  • ashlar-trace-proto — the wire types both share.

Status: release candidate, load-tested; not yet published. See roadmap.md. Apps built on ashlar use it through ashlar-telemetry, which wires it to the command chokepoint, logging, the frame loop and native dialogs.

What it collects

SignalWhat you get
CrashesPanics with backtrace, handled errors, unclean exits found on next launch, abnormal child-process exits, each with recent log lines and device details.
HangsA report when the main loop stops responding.
PerformanceFrame-time histogram, slow and frozen frames, startup time, process CPU and memory, plus your own counters and gauges.
UsageSessions, daily opens, focused / active / idle time, and the events you declare.

The host app decides what is tracked. Events are declared as enums and checked at build time; anything not declared and listened to is never recorded. End users can switch each category off, and DO_NOT_TRACK=1 is honoured. docs/privacy.md lists every field.

Sketch

ashlar_trace::events! {
    pub enum Category { File = "file", Edit = "edit" }
    pub enum AppEvent {
        FileOpen = "file.open" in File,
        Undo = "edit.undo" in Edit,
    }
}

let tracer = ashlar_trace::Tracer::builder("app-write-key", "my-app", env!("CARGO_PKG_VERSION"))
    .data_dir(my_data_dir)
    .endpoint("https://telemetry.example.com")
    .listen(Category::File)
    .init();

// Nothing is sent until the user has seen your first-run notice.
tracer.notice_shown();

// Once per turn of the main loop:
let _turn = tracer.turn();      // a turn that takes too long is reported as a hang
tracer.track(AppEvent::FileOpen);
tracer.frame(frame_duration);   // once per frame

Documentation

Development

The crates are part of the ashlar workspace and its gate (cargo xtask ci). Heavier runs: cargo xtask trace-stress (the SDK’s benchmarks and stress tests, release build), cargo xtask trace-load (a short load profile against a spawned release server) and cargo xtask trace-demo (checks the windowed demo, which is its own workspace).

Integrating the SDK into a host app

This guide walks through adding ashlar-trace to an application. Every snippet is taken from, or was checked against, one of the two demo hosts in this repository:

  • examples/trace-demo-cli: a scriptable command-line process. It is the one the end-to-end tests drive, so everything it does is tested against a real server.
  • examples/trace-demo-gui: a small eframe/egui window that wires the SDK the way a real GUI host would. It compiles and passes clippy (cargo xtask trace-demo); it has not been run, because it needs a display.

What the SDK may collect is listed in privacy.md. How the pieces fit together is in architecture.md.

1. Add the dependency

[dependencies]
ashlar-trace = "0.2"

Default features are http (a blocking HTTPS client on the SDK’s own thread) and sysinfo (process CPU and memory, device details). Without http, nothing leaves the machine unless you supply a Transport through Builder::transport. Without sysinfo, the CPU, memory and device fields are simply absent.

The SDK needs Rust 1.90, has no async runtime, forbids unsafe, and never writes to stdout.

2. Declare the catalog

Events are declared once, as two enums. A name that is not lower-case snake_case (dots allowed, at most 64 bytes), or that is used twice, fails the build.

ashlar_trace::events! {
    pub enum Category {
        File = "file",
        Edit = "edit",
        View = "view",
    }
    pub enum DemoEvent {
        FileOpen = "file.open" in File,
        FileSave = "file.save" in File,
        EditUndo = "edit.undo" in Edit,
        ViewZoom = "view.zoom" in View,
    }
}

Only what you declare and listen to is ever recorded. Tracer::track takes the enum, so a misspelled event does not compile.

3. Build the tracer

Do it once, early in main, and keep the returned Tracer alive for the whole process. It is cheap to clone and safe to share across threads. Dropping the last clone ends the session and sends what is waiting.

let mut builder = Tracer::builder(&key, "demo", env!("CARGO_PKG_VERSION"))
    .data_dir(&data_dir)
    .listen_all::<DemoEvent>()
    .logger(LoggerMode::Install)
    .process(ProcessKind::Gui);
if let Some(endpoint) = &endpoint {
    builder = builder.endpoint(endpoint);
}
let tracer = builder.init();

What each setting means:

  • Key, name, version. Tracer::builder(write_key, app_name, app_version). The write key comes from ashlar-trace-server app create (see self-hosting.md). It is not a secret: it ships inside the app and only allows ingest.
  • data_dir. The host’s own data directory. The SDK keeps its files in a tracer folder inside it (see section 3.2 of the architecture). Each host picks its own location, so this is required. If it is empty or the folder cannot be created, init returns a disabled tracer rather than failing.
  • endpoint. The server’s base URL, such as https://telemetry.example.com, with no path. Without one the SDK records into its spool and sends nothing.
  • build. An optional build identifier such as a commit hash, shown next to the version.
  • process. Gui (the default), Cli, Service or Child. The dashboard counts “daily opens” from Gui sessions only, so give tools and helper processes their own kind.
  • listen. listen(category), listen_events(&[...]) and listen_all::<E>() choose what is recorded in this run. An event that is not listened to is dropped before it reaches the queue. Each of them also tells the server what the app declares (so it can show events that were never fired and set apart names that were never declared), so they can come in any order and nothing else is needed. catalog::<E>() exists for the rare build that declares events without listening to any.
  • send_before_notice. By default nothing is sent until you call notice_shown() (section 9). Set this to true only if the user sees your notice somewhere else, such as in an installer.
  • gpu. Graphics adapter details are only known to the host. If you have them, pass Gpu { name, vendor, backend, driver } to Builder::gpu. They go into the device details on every envelope.
  • signals / without. All built-in signals are on by default; without(Signal::Hang) switches one off.
  • configure. Adjusts limits and intervals (flush_interval, heartbeat_interval, perf_window, hang_threshold, spool_max_bytes and so on). The demo-cli uses it to shrink every interval to milliseconds for tests:
builder = builder.configure(|config| {
    config.tick_interval = Duration::from_millis(20);
    config.heartbeat_interval = Duration::from_millis(100);
    config.hang_threshold = Duration::from_millis(400);
});

GPU details

In the GUI demo the tracer is built inside eframe’s app-creator callback, because that is where the toolkit’s creation context exists. With the wgpu renderer the adapter is in cc.wgpu_render_state (adapter.get_info() gives the name, vendor and backend). With the glow renderer used by the demo, the renderer string can only be read through an unsafe GL call, which the demo’s workspace forbids, so demo-gui passes no GPU. The SDK does not need one.

4. Feed it from the main loop

The performance and hang signals need a few calls from the loop that draws the UI. All of them are an atomic operation or a short lock; none blocks on disk or network.

CallWhereWhy
turn(), or begin_turn() / end_turn()Around each piece of work the main loop doesHang watchdog: a turn that takes longer than hang_threshold is a hang
frame(duration)Around one UI passFrame-time histogram, slow and frozen frames
first_frame()After the first frame is on screenStartup time since init (recorded once)
set_focused(bool)When the window gains or loses focus, or every turnFocused versus unfocused time
note_input()When the user does somethingActive versus idle time (idle after 120 s of no input by default)

Turns and the hang watchdog

A hang is the main loop being stuck in one piece of work. So tell the SDK when a piece of work starts and ends:

let _turn = tracer.turn(); // ends when the guard is dropped
handle_the_event();

A turn that stays open longer than hang_threshold (2 s by default) is reported once, with how long it has been blocked. The time between turns, while the loop waits for the next event, is never a hang, however long it lasts. That makes this work for hosts that repaint only on input, and for hosts whose event loop belongs to a GUI framework: you do not need to see the wait, only your own callbacks. No timed repaint or wake-up is needed.

begin_turn() and end_turn() are the same thing without the guard. Each is one atomic update. A second begin_turn while a turn is open keeps the original start, and end_turn without an open turn does nothing, so nesting and early returns are harmless.

An immediate-mode GUI

This is the wiring from demo-gui. eframe owns the event loop and runs it only on input. It calls logic once per loop turn (including turns where nothing is drawn) and ui for each frame, and each is marked as a turn:

impl eframe::App for DemoApp {
    fn logic(&mut self, ctx: &eframe::egui::Context, _frame: &mut eframe::Frame) {
        let _turn = self.tracer.turn();
        let (focused, active) = ctx.input(|input| (input.focused, !input.events.is_empty()));
        self.tracer.set_focused(focused);
        if active {
            self.tracer.note_input();
        }
    }

    fn ui(&mut self, ui: &mut eframe::egui::Ui, _frame: &mut eframe::Frame) {
        let _turn = self.tracer.turn();
        let started = Instant::now();
        self.contents(ui);
        self.tracer.frame(started.elapsed());
        if !self.first_frame_done {
            self.first_frame_done = true;
            self.tracer.first_frame();
        }
    }
}

frame measures the cost of producing a frame (here: building the UI), not the time between frames. That is what matters for hosts that repaint only on input.

A loop that runs continuously

A game-style loop that never waits can call heartbeat() once per iteration instead: a last beat older than the threshold is a hang. Do not use it for a loop that waits for input, because every wait longer than the threshold would look like a hang. Once a host has begun a turn, heartbeats no longer count, so the two are not mixed.

Hosts that call neither get no hang reports at all, never false ones. .without(Signal::Hang) switches the watchdog off.

Pausing hang detection

When the main loop is legitimately blocked, for example by a native modal dialog, pause detection around it. Pauses nest, and resuming restarts the measurement, so the paused time is not a hang:

self.tracer.pause_hang_detection();
std::thread::sleep(Duration::from_secs(3));
self.tracer.resume_hang_detection();

The GUI demo has two buttons that block the UI thread for 3 s: one leaves detection on (and produces a hang issue with blocked_ms of about the threshold or more), one pauses it (and produces nothing).

5. Track usage

track records a declared event. track_with adds up to 16 properties, each a bool, integer, float or &'static str of at most 64 bytes. There is deliberately no way to pass a runtime string, so file names and typed text cannot get in by accident.

tracer.track(DemoEvent::FileOpen);
tracer.track_with(
    DemoEvent::FileSave,
    &[("count", Prop::Int(3)), ("format", Prop::Str("native")), ("ratio", Prop::Float(0.5)), ("recent", Prop::Bool(true))],
);

A central command dispatcher

Hosts that run every user action through one function keyed by a command id can record the id there, in one place:

tracer.track_id(Category::Edit, "edit.palette_command");

track_id records an id that is not in the catalog, marked dynamic, under a category you listen to. Rules:

  • Pass ids only, and only fixed program strings (&'static str). Never pass parameters, arguments, paths or anything the user typed.
  • The id must follow the same naming rule as event names. An invalid one is dropped and counted.
  • Dynamic ids are exempt from the server’s catalog check, so a command registered at run time does not have to be in the enum.

Declare an event in the catalog when you want it to appear in the “declared but never fired” list; use track_id for the long tail.

6. Logs, handled errors and child processes

Log lines as breadcrumbs

The SDK keeps the last 100 log lines at Info and above, scrubbed, and attaches them to a crash. They leave the device only inside a crash report.

  • The host has no logger. .logger(LoggerMode::Install) installs the SDK’s logger globally. This is what demo-cli does by default.
  • The host already installs its own logger. Wrap it and install the wrapper in its place. Nothing is lost: every record still reaches the wrapped logger. This is demo-cli --wrap-logger, tested end to end:
let tracer = builder.init(); // LoggerMode::None, the default
let wrapped = tracer.logger(Some(Box::new(MyLogger)));
if log::set_boxed_logger(wrapped).is_ok() {
    log::set_max_level(log::LevelFilter::Info);
}

Only one global logger can exist, so the host must not also install its own. Set the maximum level yourself: log ignores every record until you do. With a disabled tracer, Tracer::logger(Some(inner)) simply returns inner.

Handled errors

For an error you caught and survived. context is a fixed label such as a command id, never the message’s variable parts:

let failure = std::io::Error::other("could not write the preview");
tracer.report_handled(&failure, Some("preview.write"));

It becomes an issue of kind handled, counts towards the session’s errors, and carries the breadcrumbs. The message is scrubbed (home directory, account name) and truncated, but it is still text your code wrote: do not put user content in error messages.

Child processes

If the host spawns helpers that can abort, report how they ended. The SDK records it only when the status is not success:

let status = command.status()?;
tracer.report_child_exit("demo-child", status);

program is a fixed label. The report carries the exit code or the signal (on Unix). A child that links the SDK itself, with ProcessKind::Child and the same data directory, also gets its own session. If it dies without ending it, the next process that starts reports an unclean_exit for it.

7. Counters and gauges on a realtime thread

Register once, on a normal thread (registration locks and allocates). Record from the realtime thread: each call is one atomic operation, with no lock, allocation or system call.

let saves = tracer.counter("saves");   // a Counter, Arc-backed and cheap to clone
saves.add(7);
let layers = tracer.gauge("layers");   // a Gauge
layers.set(4.0);

Counters are reported per performance window as the increase since the previous window (zero increases are left out). A gauge is reported when it was set during the window. There is a cap on how many one process can register; handles over the cap work but are never reported. On a disabled tracer, both return detached handles that do nothing.

8. Short-lived tools

A command-line tool that runs for a moment never reaches the periodic send, and one that ends with std::process::exit never drops its Tracer, so dropping cannot end its session. Use ProcessKind::Cli and end the session yourself on every exit path:

let clean = tracer.shutdown_within(Duration::from_millis(25));
std::process::exit(code);

shutdown_within does what dropping the last handle does: it closes the session as exited, on disk, before it waits; then it gives the worker at most the time you pass for one last send. Later calls, and the final drop, do nothing more, and nothing is recorded after it. It returns true when nothing is left that could be sent. shutdown is the same with the configured shutdown_timeout. What could not be sent is kept (within the spool caps) and goes out with the next process of the same host. flush_blocking still works for a tool that wants to send before it ends.

ProcessKind::Cli also leaves out what only makes sense for an app with a main loop: the hang watchdog, performance windows (frames) and CPU and memory sampling (CLI_OFF), whatever signals says. And it writes events and signal reports to the spool on the calling thread (a file append) instead of queueing them for the worker: a tool may exit while the worker waits on a slow request, and what only the queue held would be lost with it.

A send costs at least one round trip to the server, often several (a new connection each run), which is far more than a tool that agents call many times in a row should add. Keep the wait at exit short and hand what is left to a courier: a background process, started from the same program, built with Builder::courier(true). A courier only delivers what other processes of the host left in the spool, reporting the ones that ended uncleanly as any process does; it opens no session of its own (no start, end, heartbeat or marker) and starts no panic hook, watchdog or logger. ashlar-telemetry packages this (Telemetry::end, Telemetry::hand_off, serve_courier).

A process that records neither sessions nor crashes leaves no session marker, so however it ends it is never reported as an unclean exit.

A stdio protocol server can use the SDK freely: nothing it does touches stdout.

Recording is on by default (opt-out), with three independent categories: Crash, Performance and Usage. Nothing is sent until the user has seen your first-run notice. The SDK records from the first moment and keeps the data on disk, but it makes no request until you call notice_shown() once. That call is remembered, so later runs send from the start. The host owns the user interface:

if self.tracer.notice_pending() {
    // Show your first-run notice, with the three switches if you like. When the user closes it:
    self.tracer.notice_shown();
}

// Settings page: one switch per category.
let mut allowed = self.tracer.consent(ConsentCategory::Usage);
if ui.checkbox(&mut allowed, "Usage").changed() {
    self.tracer.set_consent(ConsentCategory::Usage, allowed);
}

// "Forget me".
self.tracer.forget();
  • If you never call notice_shown(), no data ever arrives. The SDK says so once through the log facade (a warning with target ashlar_trace), and flush_blocking returns false while data is waiting for the notice. A tool without a user interface of its own that shares the data directory with your app needs nothing: it sends once the app has shown the notice.
  • A category the user switches off before closing the notice is deleted from disk and never sent.
  • If your notice is shown elsewhere, for example by an installer, build with .send_before_notice(true).
  • set_consent(category, false) takes effect at once and deletes that category’s unsent items from the spool. The choice is saved and survives restarts. Other running processes of the same host pick it up within a tick.
  • forget() deletes everything stored locally, sends a request asking the server to delete the old install’s data (retried on the next launch if the server is unreachable), and issues a new install id.
  • DO_NOT_TRACK set to anything but empty, 0 or false, or TRACER_DISABLED set the same way, turns everything off for that process: nothing is written to disk, no folder is created, no request is made, and the consent file is not changed. Document this for your users.
  • A host whose telemetry is not optional builds with .force_consent(true): every category is allowed and the notice counts as shown from init, whatever an earlier run saved in consent.json (the file is left as it is), and set_consent and notice_shown do nothing. Nothing in the session is held back, the session start included.
  • Such a host can also build with .honor_do_not_track(false): DO_NOT_TRACK is then ignored. TRACER_DISABLED is always honoured, so developers and CI can still switch the SDK off. If you do this, say so plainly in your own privacy text: users who set DO_NOT_TRACK expect it to work.

10. Inspect mode and the local report

.inspect(true) mirrors every item that is accepted for sending into outbox.jsonl in the SDK’s folder (capped in size), so a user can read exactly what leaves the machine. A “show what is sent” menu item can simply open that file.

write_report(path) writes a plain-text report the user can attach to a bug report: app and version, device, session ids and totals, the last performance window and the recent log lines. It works without a server and without sending anything:

match self.tracer.write_report(&path) {
    Ok(()) => format!("wrote {}", path.display()),
    Err(error) => format!("could not write the report: {error}"),
}

11. Tests and builds without telemetry

Tracer::disabled() is a tracer whose every method does nothing and touches no files or threads. Pass it into code under test, or use it in builds that ship without telemetry:

let tracer = Tracer::disabled();
assert!(!tracer.is_enabled());
tracer.track(DemoEvent::FileOpen);          // nothing happens
assert!(tracer.flush_blocking(Duration::from_millis(1))); // nothing to send
assert_eq!(tracer.install_id(), "");

demo-cli runs its whole normal scenario against a disabled tracer in a unit test.

To test against a real server, run ashlar-trace-server in-process on 127.0.0.1:0 with a temporary data directory, as examples/trace-demo-cli/tests/e2e.rs does. Set .configure(|c| ...) intervals to milliseconds so tests need no long sleeps.

12. The web build

On wasm32 the SDK compiles but does nothing: Builder::init always returns a disabled tracer, so the same code path works unchanged and costs nothing. There is no data directory, no thread and no request on the web target. (cargo xtask wasm keeps ashlar-trace-proto and ashlar-trace building for it.)

13. Cost and limits

Measured with cargo xtask trace-stress (release build, a 15-core laptop, a real Tracer on a temporary folder and a transport that accepts at once). Run it on your own hardware; the tables it prints are the source of these numbers.

Per call on a host thread, one thread (median, 99th percentile):

CallMedianp99Allocations
track, listened to35 ns42 ns0
track_with, 4 properties44 ns60 ns1
track_id38 ns42 ns0
track, not listened to10 ns15 ns0
frame5 ns10 ns0
turn() or begin_turn + end_turn25 ns38 ns0
heartbeat, note_input20 ns48 ns0
set_focused, Counter::inc, Gauge::set1–2 ns2 ns0
a log line kept as a breadcrumb88 ns187 ns4
a log line below the kept level3 ns7 ns0
report_handled8 µs26 µsmany; it is an error path
anything on a disabled tracer1–2 ns2 ns0

The allocation counts are from reading the code, not from a counting allocator (that needs unsafe, which this workspace forbids).

What a host thread can wait for. Nothing that touches the disk or the network. track is an atomic check, a set lookup and a try_send into a queue that was allocated at startup; when the queue is full the event is dropped and counted with one atomic add. frame, the turn calls, note_input, counters and gauges are atomic operations only. Over ten million calls of each, with the worker flushing every 20 ms, the longest single call was a few milliseconds, and so was the longest gap in an empty loop that only reads the clock: on a desktop operating system the scheduler can take a thread away at any point, and no library can promise otherwise. What the SDK promises is that it adds no wait of its own.

Calls that do touch the disk, briefly, on the calling thread: init, set_consent, notice_shown, forget, and the panic hook; in a ProcessKind::Cli process also track, track_with, track_id and report_handled. flush_blocking, shutdown_within and dropping the last Tracer wait for the worker, up to the timeout you give and shutdown_timeout.

With many threads at once the calls stay lock-free but share memory, so they slow each other down: with 8 threads calling the same thing as fast as they can, track is about 55 ns, frame about 200 ns, a kept log line about 2 µs. Counters and gauges on a realtime thread are one atomic operation on memory nothing else writes to, if you give that thread its own counter.

Startup and shutdown. init returns in under 1 ms, whether the folder is empty or holds 1,000 session folders left by earlier runs or a full 8 MiB spool; dealing with those is the worker’s first job (about 0.4 s of its time for the 1,000 folders). Dropping the last Tracer takes about 1 ms when the server answers at once or refuses at once, as long as one request when the server is slow, and never longer than shutdown_timeout (3 s by default).

In the background. An idle app costs about 0.02% of one core for the worker and the watchdog together. At 120 frames a second with 50 events a second the whole process, pacing loop included, used 0.3% of one core. The worker writes about 500,000 items a second to the spool. With the server unreachable for five minutes at that load, resident memory stayed flat and the spool stayed at its cap.

Limits, all enforced, all counted in the drops item when they throw something away:

LimitDefaultWhen it is reached
Queue between host threads and the worker1,024 items (queue_capacity)The new item is dropped: queue_full
Spool on disk, all sessions together8 MiB (spool_max_bytes)The oldest segments go: spool_evicted
Age of spooled data30 days (spool_max_age)Older segments go: spool_evicted
One request1 MiB before compressionA larger backlog is sent as several requests
One item256 KiBDropped: item_too_large; an unclean-exit report that big goes without its log lines
Properties on one event16The rest are left out: prop_dropped
Event, category and property names64 bytes, lower-case snake_case, dots allowedBuild error for declared names; invalid_id or prop_dropped at run time
String property values64 bytesLeft out: prop_dropped
Panic and error messages2 KiBCut, on a character boundary
Backtraces32 KiBCut
Log lines kept as breadcrumbs100 lines of 512 bytes (breadcrumb_capacity)Oldest lines go; long lines are cut
Counters and gauges256 names in allFurther names get a handle that records nothing
Forget requests still owed16The oldest is given up
Inspect mirror outbox.jsonl1 MiBThe older half is discarded

What a power loss can cost. Spooled items are not flushed to the disk one by one, so the last seconds before a power loss can be lost; a crash of the app alone loses nothing that was already written. A consent change or a “forget me” is made durable by the worker within a tick (one second by default).

14. A short checklist

  1. Add the dependency; declare the catalog with events!.
  2. Build the tracer once at startup, with data dir, endpoint, key, version, process kind.
  3. Mark each turn of the main loop with turn(), call frame around each UI pass, first_frame once, and set_focused / note_input from your input state.
  4. Call track / track_id where user actions are dispatched.
  5. Install the SDK’s logger or wrap yours.
  6. Report handled errors and abnormal child exits.
  7. Show the first-run notice and call notice_shown() when the user closes it (nothing is sent before that); add the three switches and a “forget me” action.
  8. In short-lived tools, end with shutdown_within before every exit.
  9. Update the host’s own privacy text from privacy.md.

Privacy and collected fields

This page lists everything the SDK can send. If a field is not here, it is not collected. Change this page in the same commit as any change to what is collected.

Principles

  • The host app chooses. It declares its events at build time and picks which to listen to at startup. Undeclared events cannot be recorded.
  • Nothing is sent before the notice. Telemetry is on by default with a first-run notice. Until the host app reports that the user has seen that notice, data is recorded on the device but nothing leaves it. A category the user switches off in the notice is deleted and never sent. (A host app that shows its notice elsewhere, such as in an installer, can lift this.)
  • The user can switch it off. Each category — crash reports, performance, usage — has its own switch. DO_NOT_TRACK=1 or TRACER_DISABLED=1 in the environment turns everything off and nothing is written or sent. A host can choose to ignore DO_NOT_TRACK (honor_do_not_track(false)) and to make telemetry not optional (force_consent(true): no switches, no notice); it must then say so in its own privacy text. TRACER_DISABLED cannot be ignored.
  • No user content. Event names and property text are fixed strings compiled into the app. There is no API that accepts a file name, project name, document text or typed input.
  • No stable identity. The install id is random, is not derived from hardware, and can be reset. The server never stores the IP address.
  • Visible. With inspect mode on, everything that would be sent is also written to outbox.jsonl in the SDK’s folder.
  • Forgettable. “Forget me” deletes local data, including recent log lines and the last crash held in memory, asks the server to delete everything for the install, and issues a new id.

Fields

FieldExampleCategory
App name, version, build id, process kindmy-app, 1.2.3, abc1234, guiall
Install idrandom 32 hex charactersall
Session idrandom 32 hex charactersall
Operating system name and version, CPU architecturemacos, 26.0, aarch64all
CPU model and core count, RAM sizeExample CPU, 10, 32768 MBall
Graphics adapter name, vendor, backend, driver versionsupplied by the host appall
Localeen-USall
Session start, end, how it endedexited, crashed, abnormalusage
Session duration; focused, active and idle seconds62, 50, 40, 22usage
Declared event name and categoryfile.open, fileusage
Event properties: booleans, numbers, fixed short stringsformat = "native"usage
The list of declared events and which are listened tousage
Frame-time histogram, slow and frozen frame countsperformance
Startup time850 msperformance
Process CPU percentage and memory use42.5, 512 MBperformance
Host-defined counters and gaugesaudio.xruns = 2performance
Panic or error message, source location, thread name, backtracescrubbed, see belowcrash
Hang duration2400 mscrash
Child process label, exit code, signalexporter, 134crash
Recent log lines (level, target, message) — only attached to a crashscrubbed, see belowcrash
Counts of data the client discardedqueue_full = 4all

Scrubbing

Applied on the device, before anything is queued or written, to messages, backtraces and log lines:

  • The user’s home directory becomes ~.
  • The user’s account name is removed where it appears in a path.
  • Long values are truncated.

Log lines and error messages are written by the host app, so the host is responsible for not logging user content.

Retention

The server keeps raw data for 90 days by default and daily totals indefinitely. Both are configurable.

Self-hosting the server

ashlar-trace-server is one binary and one data folder. It serves the ingest API, stores everything in a SQLite file, computes daily rollups, and renders the dashboard. There is no other service to run.

1. Run it

The binary

cargo build --release -p ashlar-trace-server
./target/release/ashlar-trace-server serve

With no settings it listens on 127.0.0.1:8090 and keeps its database in ./data. It logs to stderr at info level and stops on ctrl-c, letting in-flight requests finish.

The container

deploy/trace/Dockerfile builds a small image (build from the repository root) and deploy/trace/compose.yml runs it with its data in a named volume:

docker compose -f deploy/trace/compose.yml up -d

The image tags (rust:1-slim-bookworm, debian:bookworm-slim) are left as they are so that security updates arrive on rebuild. If you need reproducible builds, pin the base images by digest (FROM debian:bookworm-slim@sha256:...) and update the digests on a schedule.

The image runs as an unprivileged user (uid 10001), listens on 0.0.0.0:8090 inside the container, and keeps its data in /data. The compose file publishes the port on 127.0.0.1:8090 only, on purpose: put a TLS proxy in front of it (section 5) instead of exposing it directly.

Check that it is up:

curl http://127.0.0.1:8090/v1/health

2. Settings

All settings are environment variables and all are optional.

VariableDefaultMeaning
TRACER_BIND127.0.0.1:8090Listen address
TRACER_DATA_DIR./dataFolder for the database
TRACER_RETENTION_DAYS90Age at which raw rows are pruned
TRACER_RATE_PER_MIN120Requests per minute per client address on the public routes (ingest, forget and health, unknown keys included); 0 switches the limit off. Dashboard sign-in has its own limit (10 a minute per address) that stays on
TRACER_KEY_RATE_PER_MIN6000Ingest requests per minute for one write key over all addresses. A capacity guard, far above the per-address limit, so that one client cannot use up a key’s allowance for everyone; 0 switches it off
TRACER_MAX_DB_MB10240Ingest answers 507 Insufficient Storage once the database is larger than this many MiB; clients keep their data and retry. The dashboard and sign-in keep working so you can fix it (lower the retention, then prune); 0 switches the ceiling off
TRACER_TRUST_PROXYunsetWhen 1, take the client address from the last entry of the last X-Forwarded-For header line
TRACER_ADMIN_PASSWORDunsetPassword for admin create. Read by that command only; the server never reads it
TRACER_SECURE_COOKIESonDashboard cookies are Secure, and the session cookie is named __Host-tracer_session. Set 0 to sign in over plain HTTP, for example on localhost without a proxy

A value that cannot be parsed stops the command with a message naming the variable. An empty value counts as unset. The repository’s .env.example lists the same names with placeholders; copy it, fill it in, and keep the real file out of version control.

With compose, TRACER_RETENTION_DAYS, TRACER_RATE_PER_MIN, TRACER_KEY_RATE_PER_MIN, TRACER_MAX_DB_MB, TRACER_TRUST_PROXY and TRACER_SECURE_COOKIES are passed through from the shell or from a .env file next to the compose file. The bind address and data folder are fixed by the image.

3. First setup: an app, a key and an admin

Every command below works on the same data folder as serve. Run them with the same TRACER_DATA_DIR. They can run while the server is running.

Create an app. The slug is lower-case letters, digits, - or _. The command prints the app’s first write key:

ashlar-trace-server app create my-app "My App"
created app my-app (My App)
write key: tk_...

Put that key into the host app’s Tracer::builder. Write keys are public by nature (they ship inside the app) and only allow sending data. More keys:

ashlar-trace-server key create my-app
ashlar-trace-server key revoke tk_...      # stops it working at once
ashlar-trace-server app list

Revoking a key makes the server answer 401 to it. The SDK keeps its data and stops sending until the app restarts, so rotate keys by shipping a new one first, then revoking the old one when most installs have updated.

Create a dashboard login. The password is read from TRACER_ADMIN_PASSWORD or, if that is unset, from one line of standard input. It must be at least 8 characters:

TRACER_ADMIN_PASSWORD='a long passphrase' ashlar-trace-server admin create alice

Then open the server’s address in a browser and sign in. The dashboard has an overview, crashes, performance, usage, release reports (downloadable as HTML, Markdown or JSON) and a settings page for keys.

With compose, the container’s entrypoint is the binary, so:

docker compose -f deploy/trace/compose.yml exec tracer ashlar-trace-server app create my-app "My App"
docker compose -f deploy/trace/compose.yml exec -e TRACER_ADMIN_PASSWORD='a long passphrase' tracer ashlar-trace-server admin create alice

4. Point a host app at it

In the host app:

Tracer::builder("tk_...", "my-app", env!("CARGO_PKG_VERSION")).endpoint("https://telemetry.example.com")

The endpoint is the base URL with no path; the SDK appends /v1/ingest and /v1/forget. See integration.md for everything else.

5. TLS and a reverse proxy

The server speaks plain HTTP. Terminate TLS in front of it with any proxy. The SDK uses https and verifies certificates, so use a real certificate.

Caddy, which obtains certificates itself:

telemetry.example.com {
    reverse_proxy 127.0.0.1:8090
}

nginx:

server {
    listen 443 ssl;
    server_name telemetry.example.com;
    # ssl_certificate and ssl_certificate_key as usual

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Then set:

  • TRACER_TRUST_PROXY=1, so the rate limiter sees each client’s address instead of the proxy’s. Without it every request appears to come from the proxy and shares one bucket, and busy installs will see 429. The server uses the last X-Forwarded-For entry, the one the proxy itself appended, so earlier entries a client forges are ignored. Only turn this on when a proxy you control is the only way to reach the server.
  • Leave TRACER_SECURE_COOKIES unset (cookies are Secure by default, which needs HTTPS). Only set it to 0 for plain-HTTP use on a local machine.

The server never stores client addresses; it uses them for the rate limit and nothing else.

Do not expose the port directly. Bind to 127.0.0.1 (the default) or to a private interface, and let the proxy be the public side. The server logs a warning at startup when it is bound beyond loopback without TRACER_TRUST_PROXY=1: behind a proxy that would make every client look like the proxy, so the per-address and sign-in limits would be shared by all of them.

The server itself cuts off clients that take longer than 15 seconds to send request headers (this also closes idle keep-alive connections), and serves at most 1,024 connections at once, waiting for a slot beyond that. Request bodies must arrive within 30 seconds. You can still add stricter limits in the proxy (client_header_timeout, limit_conn, client_max_body_size 1m in nginx), but the server no longer depends on it for slow clients.

6. Backups

Everything is in TRACER_DATA_DIR: the database tracer.db, and while the server runs, two companion files tracer.db-wal and tracer.db-shm. SQLite runs in WAL mode, so recent writes may live only in the -wal file. Copying tracer.db alone while the server is running can give you a stale or inconsistent copy. Use one of these:

  1. Ask SQLite for a snapshot. Safe while the server runs:

    sqlite3 /path/to/data/tracer.db ".backup '/backups/tracer-$(date +%F).db'"
    

    or VACUUM INTO '/backups/tracer-2026-01-01.db' for a compacted copy. The result is a single, self-contained file.

  2. Stop the server, then copy the folder. After a clean stop the -wal file is empty or gone, and copying the whole folder is fine. If you copy while stopped, copy all three files together.

  3. Snapshot the volume (LVM, ZFS, a cloud disk snapshot). These capture the three files at one instant and are safe.

For compose, option 3 or a stopped copy is simplest:

docker compose -f deploy/trace/compose.yml stop
docker run --rm -v tracer_tracer-data:/data -v "$PWD":/backup debian:bookworm-slim tar czf /backup/tracer-data.tgz -C /data .
docker compose -f deploy/trace/compose.yml start

(The volume is named <project>_tracer-data; docker volume ls shows the real name.) Test a restore now and then: stop the server, put the files back into the data folder, start it.

What is lost if you lose the database: history. Clients keep unsent data on their own disks and resend it, and the server ignores anything it has already stored, so a restore plus a few days of resends is not harmful.

7. Retention and housekeeping

  • Raw rows (events, performance windows, crashes, sessions) are deleted when they are older than TRACER_RETENTION_DAYS (default 90). The ids used to ignore duplicates are kept for 35 days. After pruning, SQLite reclaims the space.

  • Daily totals (opens, active installs, event counts, frame histograms, drops) are kept indefinitely, so long-term charts survive pruning. Lowering the retention period does not remove them.

  • Timers. serve recomputes the rollups for the last three days every five minutes and runs retention hourly. A session that is still open a day after its last item is closed as abnormal.

  • By hand, for example from cron on a machine that runs the binary directly:

    ashlar-trace-server rollup --days 7     # recompute recent rollups now
    ashlar-trace-server prune               # run retention once
    

8. Upgrading

  1. Take a backup (section 6).
  2. Build or pull the new version and restart. Database migrations are applied at startup and tracked in the file (PRAGMA user_version). They only ever add; a shipped migration is never edited.
  3. Check /v1/health, which reports the server version.

Run one server process per data folder. Do not point an older binary at a database that a newer one has migrated. The wire format only grows, so older apps keep working against a newer server, and an app newer than the server may send fields the old server ignores. If a client sends a schema higher than the server understands it gets 422 and drops that batch, so upgrade the server before shipping an app that depends on a new schema.

9. Troubleshooting

The SDK stops sending, the server answers 401. The key is missing, mistyped or revoked. The SDK keeps its data and stops sending until the host app restarts. Check the key on the dashboard’s settings page, or create a new one (key create). The key header is x-tracer-key.

429 Too Many Requests. Requests are limited per client address (TRACER_RATE_PER_MIN, default 120 a minute), and each write key also has a much larger overall allowance (TRACER_KEY_RATE_PER_MIN, default 6000). Behind a proxy, set TRACER_TRUST_PROXY=1, otherwise all clients share the proxy’s address. The SDK honours Retry-After and keeps its data until the server accepts it, so an occasional 429 loses nothing. Sustained 429 usually means the proxy setting above, or a test loop. Raise the limit, or set 0 to switch it off.

507 Insufficient Storage. The database passed TRACER_MAX_DB_MB. The SDK keeps its data and retries. Lower TRACER_RETENTION_DAYS and run ashlar-trace-server prune, or raise the ceiling.

400, 413 or 422. The batch was malformed, over 1 MiB after decompression, or from a newer schema. The SDK deletes that batch and counts a server_rejected drop, shown on the dashboard. A proxy that rewrites or truncates bodies (or a body-size limit smaller than 1 MiB) causes this too.

Times look wrong, or events land on the wrong day. The server compares the client’s sent_at with its own clock. If they differ by more than five minutes, item times are shifted by the difference, and no time is accepted more than an hour in the future or older than the retention period. Make sure the server’s clock is right (use NTP); the client’s can be wrong without harm.

Nothing arrives.

  1. curl https://your-host/v1/health from outside. If that fails, it is the network, DNS, TLS or the proxy.
  2. In the host app: is endpoint set (the SDK records locally and sends nothing without it), and is it the base URL with no path?
  3. Is the user’s DO_NOT_TRACK or TRACER_DISABLED set? Then nothing is written or sent, by design. Are all three consent categories switched off?
  4. The SDK does not send at once: it sends when 50 items are waiting, every minute while items are waiting, and at the end of the session. A short run that was killed is sent by the next launch.
  5. Look at the tracer folder inside the host’s data directory. spool/ holds what has not been delivered; outbox.jsonl (inspect mode) shows what would be sent.
  6. The dashboard has a version filter and a date range; check them. Events that are not in the app’s declared catalog are kept, but listed on the usage page under “Undeclared events” rather than among the declared ones.
  7. Watch the server’s stderr while the host app starts.

Sessions show up as “abnormal”. A process ended without closing its session (killed, crashed natively, power loss). The next launch of the app reports it. A session that never ends and never reports again is closed as abnormal after a day.

The container cannot write its data. The image runs as uid 10001. A bind-mounted data folder must be writable by that user; the named volume in compose.yml already is.

10. Capacity and tuning

Numbers below were measured with examples/trace-loadgen on one machine (15 cores, an NVMe disk, release build, client and server on the same host over loopback, other jobs running on the machine). Treat them as an order of magnitude for your own hardware and re-measure with the commands at the end. A “typical” envelope in the generator carries about 40 items (a session start with a catalog hash, heartbeats, 20 to 50 events, a performance window with a real histogram, now and then a crash with a 30-frame backtrace, a session end) and is about 1.2 KB gzipped.

Ingest.

Load (64 connections)Result
Flat outabout 4,300 envelopes a second (170,000 items a second), p50 13 ms, p99 about 100 ms
Fixed 2,000 a secondp50 8 ms, p99 about 60 ms, no errors
Fixed 500 a secondp50 7 ms, p99 about 14 ms
1,000 a second for 5 minutesno errors; p50 steady at 9 ms; p99 drifts from 70 to 230 ms as the database grows to 2.7 GB; server memory 28 to 38 MiB
2,000 a second for 5 minutesheld for about 3 minutes (a 3 GB database), then latency climbs: see “When the database outgrows memory”
9,000 a second for 15 seconds (over twice the capacity)the server answers 503 with Retry-After: 1 to what it cannot take, keeps accepting the rest at its normal rate, answers /v1/health and the dashboard in under 5 ms, and is back to no errors within a second of the load dropping

Why it is fast: ingest requests are queued to one writer thread that stores up to 64 envelopes per transaction and commits once, a separate thread checkpoints the write-ahead log, statements are prepared once, and duplicate detection is one statement per item. The queue holds 256 envelopes; when it is full the server answers 503 and the SDK retries later, so memory stays bounded however hard clients push.

Durability. The database runs in WAL mode with synchronous = NORMAL. A request is acknowledged only after its data is committed to the log, so stopping the process, even with kill -9, loses nothing that was acknowledged (a test does exactly this under load). What NORMAL does not do is fsync each commit, so a power failure or operating-system crash can lose the last moments of acknowledged data. The database is never left corrupt. If you cannot accept that, run the database on a volume with a battery-backed cache or a replicated disk.

The dashboard on a large database. On a database with 1,000,000 sessions, 10,000,000 events, 2,000,000 performance windows, 100,000 crashes in 5,000 issues, 50 versions, 200,000 installs and 90 days (2.5 GB):

BeforeAfter
Slowest page, warm2.1 s (reports), 1.4 s (overview)18 ms
Slowest page, first request after start2.2 s290 ms
Rollup of 3 days (writer held)2.3 s3.5 s (a few ms of it holding the writer)
Full 90-day recompute60 s64 s

Ingest at 300 envelopes a second while the 3-day rollup runs as another process: p99 58 ms against 47 ms without it. While retention deletes half the data it never stops ingest, but it does hurt the tail (p99 about 300 ms against about 40 ms).

When the database outgrows memory. Each stored item touches several B-tree indexes in random order, so once the database is much larger than the memory the operating system can use for caching, every commit reads and writes random pages and throughput falls. On the test machine that started at roughly 3 GB at 2,000 envelopes a second. The remedies are the usual ones: more memory, a shorter TRACER_RETENTION_DAYS, and a fast local disk. Dropping the four per-install indexes delayed the problem a little but did not remove it, so they are kept. TRACER_MAX_DB_MB stops ingest with 507 before the disk fills.

Settings that matter for capacity. TRACER_RETENTION_DAYS (database size is about 220 bytes per stored item including its indexes and dedup record: 12 million items took 2.7 GB); TRACER_RATE_PER_MIN (per client address) and TRACER_KEY_RATE_PER_MIN (per key, a capacity guard); TRACER_MAX_DB_MB. Retention runs hourly in small chunks (100 rows) that yield the writer, so the first run on a very large database can take a long while; that is harmless, and later runs only remove a day’s worth.

Measuring your own server.

cargo xtask trace-load                                   # a short standard profile, prints a table
cargo build --release -p ashlar-trace-server -p loadgen
target/release/loadgen run --spawn --concurrency 64 --duration 30             # flat out
target/release/loadgen run --spawn --rate 500 --duration 60                   # fixed rate, open loop
target/release/loadgen run --url http://host:8090 --key tk_... --db /data/tracer.db --rate 200 --duration 60
target/release/loadgen run --spawn --phases 1500:10,9000:15,1500:10 --concurrency 600 --probe   # overload
target/release/loadgen seed --db /tmp/big/tracer.db --scale 1.0               # 10M events, 1M sessions, ... (2 min, 2.5 GB)
target/release/loadgen read --dir /tmp/big --mode rollup                      # rollup timings
target/release/loadgen read --dir /tmp/big --mode pages                       # every page, cold and warm

The generator is open loop with --rate: requests leave on a clock and latency is measured from when they were due, so a slow server shows up as latency. It ends with a check that the number of items the server stored equals the number it acknowledged. Do not point it at a server you care about: it writes a lot.

Architecture

This is the spec. Code follows it; when they disagree, fix one of them in the same change.

host app ──► ashlar-trace ──► bounded queue ──► worker thread ──► disk spool ──► HTTPS batch (gzip)
                                                                                    │
                                              ashlar-trace-server: ingest ► SQLite ► rollups ► dashboard + reports

1. What the SDK must fit

The host apps set these constraints:

  • Rust edition 2024, minimum Rust 1.90, unsafe forbidden, no unwrap/expect/panic outside tests.
  • Immediate-mode GUI with a per-frame callback. Some hosts repaint only on input, so frame cost matters more than frame rate.
  • The log facade only. A host may have no logger, or its own. Only one global logger can exist, so the SDK must be able to wrap one.
  • No async runtime in the GUI process. Background work is a plain thread; HTTP is blocking.
  • Each host also ships a short-lived command-line binary, a stdio protocol server (stdout must stay clean), a web build, and sometimes child processes that can abort.
  • One host has a realtime audio thread, where the SDK must never lock, allocate or make a system call.
  • Each host chooses its own data directory, so the SDK takes it as a parameter.

2. Crates

CrateRoleMay depend on
ashlar-trace-protoWire types, constants, name validation. No I/O.serde
ashlar-traceThe client.ashlar-trace-proto, serde_json, log, thiserror, flate2, getrandom, optional ureq, optional sysinfo
ashlar-trace-serverThe backend binary.ashlar-trace-proto, axum, tokio, rusqlite, maud, argon2, and small helpers

3. SDK

3.1 Shape

client.rs holds the public Tracer handle and Builder. Behind it are two halves that meet only at the Sink trait (sink.rs):

  • pipeline implements Sink. It owns consent, identity, the session, the queue, the spool, the worker thread and sending.
  • signals produces data: crash, hang, perf, sys, logs, realtime counters, scrubbing, the local report. It calls Sink and nothing else.

The public API in client.rs and the Sink trait are frozen. Changing either needs a note in PROGRESS.md.

Builder::init never fails. On the web target, with no data directory, or when the SDK’s folder cannot be created, it returns a disabled tracer whose methods do nothing.

3.2 Files on disk

Everything lives in <host data dir>/tracer/:

PathOwnerContent
install.jsonpipeline{"install_id": "…", "created": ms, "pending_forget": ["…"]}. pending_forget lists earlier install ids whose forget request has not been delivered yet (at most 16); it is absent when empty.
install.lockpipelineEmpty. Locked for the moment it takes to change install.json.
consent.jsonpipeline{"crash": bool, "performance": bool, "usage": bool, "notice_shown": bool}
selection.jsonpipelineCatalog hash, listened categories and events, enabled signals for the last run. Also the catalog hashes the server has acknowledged, per app version.
spool/<session_id>/lockpipelineEmpty. The process that runs the session holds an exclusive advisory lock on it for its whole life.
spool/<session_id>/header.jsonpipelineThe envelope header for that session (app, install id, device), plus created, which orders sessions.
spool/<session_id>/session.jsonpipelineMarker for the running session: envelope header, start time, time of the last rewrite, last heartbeat totals, error count, panicked flag. Removed on clean shutdown.
spool/<session_id>/<seq>.jsonlpipelineItems in segments, one per line: a consent-category byte (c, p, u, or - for none) followed by the item as JSON.
spool/<session_id>/breadcrumbs.jsonlsignalsRecent log lines of that session, rewritten periodically.
outbox.jsonlpipelineInspect mode only: a capped mirror of every item that was accepted for sending.

Files are written to a temporary name and renamed. A file that fails to parse is treated as absent, never as an error.

Nothing in the SDK’s folder is trusted. Every file is read as possibly damaged or planted: a state file that does not parse is treated as absent and replaced; a file is read only if it is a regular file of plausible size (state files up to 1 MiB, segments up to 8 MiB), so a symbolic link is never followed out of the folder and an oversized file is never loaded into memory; a session folder is only ever a real folder, never a link; a segment far larger than the SDK writes is deleted unread when its folder is adopted and counted as spool_damaged. If the SDK’s folder, or the running session’s folder, is deleted under a running session, the worker puts back the folder, lock, header, marker and the install and consent files on its next pass; what was recorded in between is lost uncounted.

Durability. Nothing waits for the disk on a host thread. Spool segments are appended with one write per item and are never flushed with fsync: a crash of the process loses nothing that was written, but a power loss or a crash of the operating system can lose the last seconds of items, or leave a cut-off last line, which readers skip. consent.json and install.json are written by temporary file and rename on the calling thread and then flushed to the disk, with their folder, by the worker within a tick; until then a power loss can bring back the previous consent choice or install id, or an empty file, which reads as absent (consent then starts from the defaults). The marker and selection.json are not flushed.

Several processes, one directory. A host’s GUI, command-line tool and child processes may all run on the same data directory at once, so every session folder has exactly one owner:

  • A running process owns spool/<its session id>/. It creates the folder at init, takes the lock, and appends only there. The operating system releases the lock when the process ends, however it ends.
  • A folder whose lock is held belongs to a live process and is left completely alone by everyone else: not sent, not evicted, not purged, not deleted.
  • A folder whose lock can be taken is an orphan. The process that takes the lock adopts it and keeps the lock until the folder is gone, so no two processes work on the same orphan. Orphans are looked for at startup, before every send, and every flush_interval.
  • An orphan recorded under an install id other than the current one was forgotten by the user and is deleted unsent. A folder with neither header nor marker is deleted once it is a minute old (younger, it may belong to a process that is still starting). A scan never creates a lock file in a folder younger than that, since the folder may be one its owner is deleting at that moment.
  • install.json, consent.json, selection.json and outbox.jsonl are shared. Processes that start together on a fresh directory agree on the install id: the file is created with create-if-absent, and the losers read what the winner wrote. The worker re-reads consent.json and install.json on every tick, so a consent change or a forget in one process reaches the others within a tick. Changes to install.json are read-modify-write under install.lock; changes to consent.json start from the file’s current content, without a lock.
  • Where the filesystem does not support locks (try_lock fails with an error other than “would block”), a folder counts as an orphan only when nothing in it has been written for 24 hours, and no unclean exit is ever reported from it: its marker is discarded and its items are sent. A running session rewrites its marker every heartbeat_interval, which keeps its folder fresh.
  • Default is on for all three categories. Signal::consent() maps each signal to its category.
  • DO_NOT_TRACK (any value other than empty, 0 or false) or TRACER_DISABLED set the same way disables everything for the process: allows is false for every signal, nothing is written to the spool, and no request is made. The consent file is not changed.
  • Sink::allows(signal) is true only when the host enabled the signal, the kill switches are off, and the category is allowed.
  • set_consent(category, false) takes effect at once and also deletes that category’s unsent items from the spool.
  • A choice made in this process is the authority in memory until it has been saved. If consent.json cannot be written, the worker tries again on every tick and does not read the file in the meantime, so a file that could not be updated never switches an opt-out back on.
  • notice_pending() is true until notice_shown(). Until then the SDK records and spools as usual (opt-out model) but sends nothing: no ingest request and no forget request leaves the machine before the user has seen the host’s notice. notice_shown() is saved in consent.json, so later runs send from the start, and other running processes of the host see it within a tick. A category the user switches off before the notice is acknowledged is deleted from the spool and so is never sent. flush_blocking returns false while sending is held and data is waiting. The first time that happens the worker logs one log::warn! with target ashlar_trace, so a host that forgot to call notice_shown() can find out why nothing arrives; the SDK’s own logger forwards lines with that target but never keeps them as breadcrumbs.
  • Builder::send_before_notice(true) lifts the hold, for hosts that show their notice somewhere else, such as an installer. It does not mark the notice as shown.
  • Under a kill switch set_consent and notice_shown change nothing on disk.
  • forget() deletes this process’s unsent items, every orphan, its breadcrumbs and the outbox, sends a ForgetRequest for the old install id, and creates a new install id. It also calls Signals::forget(), which empties what the signal modules hold in memory from before: the breadcrumb ring, the last crash and the last performance window. Other running processes of the host see the new id on their next tick and do the same to their own session folder and memory; items they had queued under the old id are discarded, which is decided under the spool lock so that none can land under the new header.
  • The rotated install record is the authority in memory. If install.json cannot be written, the new id and the pending forget stay in memory, the worker tries the save again on every tick, and a file that still names an id this process has rotated away from is treated as behind and overwritten, never taken back.
  • The forget request is best effort and never holds up ingest. A network error, a 5xx or a 429 is retried later with its own backoff, in this run and after later launches. A 401 or 403 stops all sending, as for ingest, and keeps the request. Any other 4xx is final: the request is given up and counted as forget_rejected.

3.4 Identity and ids

Install and session ids are 16 random bytes from getrandom, as 32 lower-case hex characters. If the random source fails, the SDK falls back to a mix of time, process id and a counter rather than failing.

Item ids have the same form but are not drawn from the system each time, because that is a system call per item: the first half is random and fixed for the process, the second half is a per-process counter passed through a mixing function that gives every count a different value. They are unique, which is all the server’s duplicate check needs. The two items that report an adopted session’s unclean end are the exception: their ids are derived from the session id (§3.5).

3.5 Session

  • One session per process run. session_start is submitted during init.
  • The worker emits session_heartbeat every heartbeat_interval with running totals, and rewrites the marker, spool/<session_id>/session.json. A heartbeat is never written after the session’s end.
  • Time accounting uses a monotonic clock, sampled once a second by the worker: a second counts as focused if the host last said set_focused(true) (hosts that never call it count as focused), as active if focused and note_input() was called within idle_after, otherwise idle. note_input and set_focused are single atomic stores.
  • Clean shutdown (last Tracer clone dropped) writes session_end on the calling thread, removes the marker, tries one final send, and releases the session folder. If everything was sent the folder is deleted; otherwise it stays, without a marker, as an orphan that the next process to look simply sends. Status is crashed if a panic was seen and the thread dropping the tracer is panicking, otherwise exited.
  • errors counts handled errors and survived panics. The panic hook counts every panic when it happens, because it cannot know then whether the process will survive it. When a session ends crashed, by clean shutdown while unwinding or as reported from a marker with panicked set, the panic that ended it is taken off again: crashed already says so, and it is not counted twice.
  • The time one tick may add to the session’s totals is capped at five tick intervals, and at no less than a minute. A longer gap means the machine was asleep (some monotonic clocks keep counting through sleep), and that time is not credited as focused, active or idle.
  • An orphan (§3.2) that still has its marker belonged to a process that ended uncleanly. The process that adopts it writes into that folder, under that session’s header: a session_end with status crashed if the marker’s panicked flag was set, else abnormal, with the last saved totals; and, when not panicked, a crash of kind unclean_exit carrying signals::saved_breadcrumbs(<that folder>). Both carry the time of the marker’s last rewrite, or the present if the marker claims a later time. It then removes the marker, then the saved breadcrumbs, and sends the folder’s items like any other. The breadcrumbs are deleted last on purpose: if the adopter dies after reading them and before the report is on disk, the next adopter still finds them.
  • An unclean end is reported once: only the lock holder reports, and the marker is removed after the report is on disk. The ids of the two items are derived from the session id, so if the adopter itself dies between those two steps and the report is written again, the server stores it once.
  • Short-lived processes (ProcessKind::Cli, Child) behave the same; they should call flush_blocking before exit.
  • Sink::session_summary() gives the local report the install id, session id, start time, running totals and error count.

3.6 Event catalog

The host declares events once:

ashlar_trace::events! {
    pub enum Category { File = "file", Edit = "edit" }
    pub enum AppEvent {
        FileOpen = "file.open" in File,
        Undo = "edit.undo" in Edit,
    }
}
  • A declarative macro; no procedural macro crate.
  • It generates both enums (Copy, Clone, Debug, PartialEq, Eq, Hash), and implements EventCategory and Event with ALL lists.
  • At build time, through const assertions using ashlar_trace_proto::validate_name and const_str_eq, it rejects an invalid name and a duplicate name within either enum. trybuild compile-fail tests cover both.
  • listen(category), listen_events(&[..]) and listen_all::<AppEvent>() choose what is recorded, and each declares the catalog itself, so their order does not matter and no separate call is needed. listen(category) reaches the events through EventCategory::EVENTS, which the macro fills in. Builder::catalog::<AppEvent>() declares the events without listening to any. A hand-written EventCategory that leaves EVENTS empty needs that call; init logs one warning when categories are listened to and no event is declared.
  • track drops an event that is not listened to before it reaches the queue. The check must be cheap: a lookup in a small set built at startup.
  • track_id(category, id) records an id that is not in the catalog, marked dynamic, when its category is listened to. The id is validated with validate_name at run time; an invalid one is dropped and counted.
  • Properties: at most MAX_PROPS, keys validated like names, string values &'static str of at most MAX_PROP_STR_LEN bytes. Anything over a limit is dropped and counted.
  • session_start always carries catalog_hash (a stable hash of the sorted catalog), listening and signals. It carries the full catalog until the server has acknowledged that hash for this app version; an ingest reply with need_catalog: true clears the acknowledgement so the next session resends it.

3.7 Queue, spool and sending

  • submit does try_send on a bounded channel of queue_capacity. When full it drops the item and counts queue_full with one atomic add. It never blocks and takes no lock.
  • track hands the worker exactly what it was given: the two &'static str names, a timestamp, and the properties copied into one boxed slice (no allocation at all without properties). Checking the limits, building the wire event, assigning the id, serialising and writing are done by the worker. frame is three atomic operations on a histogram of atomics. No call on a host thread takes a lock the worker holds across disk or network I/O.
  • Builder::init does only what must precede the first call: creates the folders, reads the three small state files, takes the session folder’s lock, writes header and marker and the session_start, and starts the worker. Looking through what earlier runs left behind, however much, is the worker’s first job.
  • When the worker drains the queue for a flush or at shutdown it takes at most one queue’s worth, so host threads that keep the queue full cannot keep it from sending, ticking or answering the flush. At shutdown the drop counts are written to the spool whether or not anything can be sent, so they are not lost with the process.
  • One worker thread drains the queue, appends items to its own session’s spool segment, and ticks every tick_interval (1 s by default): session accounting, re-reading the shared consent and install files, Signals::tick. Every other interval is effectively rounded up to a multiple of the tick.
  • submit_now serialises the item and appends it to the spool on the calling thread, without going through the queue. It is used from the panic hook, so it must not panic, must tolerate a poisoned lock, and gives up (counting spool_busy) rather than wait on a lock the panicking thread may itself hold.
  • The spool is capped by spool_max_bytes and spool_max_age, across all session folders: the folders of other live processes count towards the size, as of the last look at them. To make room a process evicts the oldest segments it may touch (adopted orphans first, oldest session first, then its own oldest) and never anything of another live process; if that is not enough, the new item is dropped. Evicted and expired items are counted as spool_evicted.
  • The worker sends when batch_items are waiting or flush_interval has passed, and once at startup for anything earlier runs left. It sends the orphans it has adopted, oldest session first, then its own session. One request per session folder, at most MAX_ENVELOPE_BYTES after serialisation; larger backlogs are split. What a request carried is deleted when the server has answered for it, and an orphan’s folder is deleted when it is empty.
  • Bodies are gzip-compressed JSON, at the fastest level: on a 1 MiB batch of events it takes an eighth of the time of the default level for 5% more bytes.
  • Reply handling follows the table in docs/wire-format.md. Retries use exponential backoff with jitter, from retry_min (5 s) doubling up to retry_max (15 min), each wait a random point in the upper half of its step. A Retry-After given in seconds is honoured, never for less than retry_min (so Retry-After: 0 does not mean “on every tick”) and up to a day; an explicit flush skips the backoff but never the server’s Retry-After.
  • Only an ingest reply that parses says anything about the catalog. A 2xx with an empty or foreign body counts as delivered but neither acknowledges the catalog nor asks for it.
  • A segment is read as bytes and each line is judged on its own. A tail without a newline (a write cut short by a crash) and a line that is not valid UTF-8 are skipped and counted as spool_damaged; the lines around them are sent. A segment that cannot be read at all is left out of the batch and stays on disk; it does not hold up the sessions after it.
  • No interval can overflow the clock: tick_interval is clamped to at most an hour and the retry waits to at most a week when the worker starts, and a flush_blocking timeout or shutdown_timeout too long to add to the clock means “no deadline”.
  • Drop counts are sent as a drops item with the next batch, while at least one consent category is allowed. The reasons are: queue_full, spool_evicted, spool_busy, item_too_large, io_error, server_rejected (a batch the server refused with 400, 413 or 422), spool_damaged (a stored line that was cut short or is not valid UTF-8), forget_rejected, invalid_id, prop_dropped, tick_panicked, and from the signal modules breadcrumbs_write.
  • With no endpoint configured the SDK still spools (within the caps), still adopts orphans, and inspect mode still works, but nothing is sent. This holds even when the host supplies its own Transport.
  • flush_blocking(timeout) asks the worker to drain and send now and waits for it. It is true when this process has nothing left that it could send. shutdown does the same with shutdown_timeout, then stops the worker.
  • The built-in transport is ureq 3 with rustls, behind the http feature, with connect and overall timeouts and redirects off. Hosts and tests can supply their own Transport.

3.8 Signals

ModuleBehaviour
crashA panic hook that chains to the previous hook. It captures message, location, thread name and Backtrace::force_capture, scrubs them, attaches breadcrumbs and the last perf window, calls submit_now with a crash of kind panic, then count_error and mark_panicked. It must never panic itself and must guard against re-entry. report_handled submits kind handled and calls count_error. report_child_exit submits kind child_exit with exit code or signal, only for an unsuccessful status. fatal is set where it can be known: true on a child_exit and on the unclean_exit the pipeline writes for a dead session; false on handled and hang; and false on a panic at capture, because the hook cannot know whether the host catches it. The server marks a panic fatal when its session ends crashed.
hangA watchdog thread, driven in one of two ways. Turns (preferred): begin_turn() when the main loop starts handling something and end_turn() when it is done, or turn() for a guard that does both. A turn open for longer than hang_threshold is a hang; the time between turns, when the loop waits for work, never is. This is the only way that works for a loop that runs only on input or that a GUI framework owns. Heartbeats: heartbeat() once per iteration of a loop that runs continuously; a last beat older than the threshold is a hang. Once a host has begun a turn, heartbeats no longer count for that process. Each call is one atomic update. A second begin_turn while a turn is open keeps the original start; end_turn without an open turn does nothing. Either way one crash of kind hang is submitted per hang, with blocked_ms as observed so far, and nothing more until the next turn or beat. Watching starts with the first turn or beat, so hosts that call neither get no reports. pause/resume nest; resuming restarts the open turn or the last beat, and stores that before lifting the pause. If the watchdog finds far more time has passed than its own poll interval, it was suspended with the rest of the machine: it restarts the measurement instead of reporting.
perfframe(duration) records into a FrameStats histogram behind a lock that is held only for the update. first_frame() records startup time since init once. Every perf_window the tick submits one perf item if it has anything to report, and keeps a copy as “last perf” for crash reports.
sysWith the sysinfo feature: this process’s CPU and memory every sys_interval, folded into the perf window (mean CPU, last and peak memory), and static device details once for the envelope header. Without the feature, those fields are absent.
logsA log::Log that keeps the last breadcrumb_capacity lines at or above breadcrumb_level in a ring, scrubbed, and forwards every record to the wrapped logger if there is one. LoggerMode::Install installs it globally; otherwise the host wraps its own logger with Tracer::logger. The ring is saved to breadcrumbs.jsonl in the session folder on the tick when it changed. Log lines leave the device only inside a crash. Lines with the SDK’s own target, ashlar_trace, are forwarded but not kept.
rtCounter and Gauge are Arc<AtomicU64>. Registration allocates and locks; recording is one atomic operation. The tick reads them into the perf window (counters as the increase since the last window).
scrubReplaces the home directory with ~, removes the account name from paths, truncates long values (messages 2 KiB, backtraces 32 KiB, log lines 512 bytes).
reportwrite_report(path) writes a plain-text report: app, version, device, session ids and totals, last perf window, recent breadcrumbs.

Known limit for 0.1: a backtrace from a release build stripped of symbols contains unresolved frames. Symbolication arrives with native crash support.

3.9 Threads

ThreadDoes
Host threadsCall Tracer methods; each is an atomic store, a short lock, or a try_send.
tracer-workerDrains the queue, writes the spool, ticks, adopts orphaned session folders, sends. The only thread that touches the network.
tracer-watchdogHang detection only. Started when the Hang signal is enabled.

4. Server

4.1 Shape

One binary, ashlar-trace-server, with subcommands:

CommandDoes
serveRuns the HTTP server, the rollup timer and the retention timer.
app create <slug> <name>Creates an app and prints its first write key.
app listLists apps.
key create <slug> / key revoke <key>Manages write keys.
admin create <username>Creates a dashboard login; reads the password from TRACER_ADMIN_PASSWORD or stdin.
pruneRuns retention once.
rollup [--days N]Recomputes rollups once.

Settings come from the environment (all optional):

VariableDefaultMeaning
TRACER_BIND127.0.0.1:8090Listen address
TRACER_DATA_DIR./dataFolder for the database
TRACER_RETENTION_DAYS90Age at which raw rows are pruned
TRACER_RATE_PER_MIN120Requests per minute per client address on the public routes (ingest, forget, health), unknown keys included; 0 switches the limit off. Dashboard sign-in has its own limit, which stays on
TRACER_KEY_RATE_PER_MIN6000Ingest requests per minute for one write key over all addresses: a capacity guard far above the per-address limit; 0 switches it off
TRACER_MAX_DB_MB10240Ingest answers 507 once the database is larger than this many MiB; the dashboard and sign-in keep working; 0 switches the ceiling off
TRACER_TRUST_PROXYunsetWhen 1, take the client address from the last X-Forwarded-For entry of the last such header line
TRACER_ADMIN_PASSWORDunsetPassword for admin create; read by that command only
TRACER_SECURE_COOKIESonCookies are Secure and the session cookie uses the __Host- prefix. Set 0 to allow plain-HTTP use on a local machine

4.2 Storage

  • SQLite in WAL mode, schema in migrations/, applied at startup and tracked with PRAGMA user_version. 0001_init is the schema; 0002_dashboard adds indexes for the dashboard queries; 0003_hardening makes catalogs additive (catalog_events.hash), adds events.declared, daily_events.declared, sessions.catalog_hash and issues.created_day; 0004_scale adds the indexes the large-database measurements called for (sessions_by_version, the partial perf_with_metrics), daily_active (daily, weekly and monthly active installs) and dirty_days.
  • All access goes through a Store trait so a second backend can be added.
  • Writes: group commit. Ingest requests go through a bounded queue (256 envelopes) to one tracer-writer thread. It takes whatever has queued up (at most 64 envelopes), stores them in one transaction, commits once and only then answers each caller, so a request is acknowledged only after its data is committed. When the queue is full the answer is 503 with Retry-After: 1: memory cannot grow, and the client keeps its data. If a group fails, its envelopes are stored one by one, so a bad envelope cannot take the others down; a failure that concerns the whole database (locked past the busy timeout, disk full, read-only) is reported at once instead (503 for a lock, 500 otherwise).
  • Durability. WAL with synchronous = NORMAL: a commit is in the log file but not fsynced. A crash or kill -9 of the process loses nothing that was acknowledged; a power failure or operating-system crash can lose the last moments of acknowledged data (the database stays consistent, never half-written). A separate tracer-checkpoint thread folds the log into the database file every second, so the writer never pays for that fsync inside a commit; the log is allowed to grow to about 200 MiB before the writer would checkpoint it itself.
  • Reads use a small pool of query_only connections and never wait for the writer (WAL).
  • Signals. SIGINT and SIGTERM both stop the server gracefully: it stops accepting, lets in-flight requests finish (up to 10 s) and exits with status 0.
  • Conventions are in the header of 0001_init.sql.

4.3 Ingest

POST /v1/ingest, in order:

  1. Take one request from the client address’s token bucket (in memory, bounded, IPv6 by /64) before anything else, so unknown keys and the health route are limited too; over the limit is 429 with Retry-After. The address is used here, for the sign-in limit and nowhere else. A request the address bucket refuses does not touch the key bucket.
  2. Look up x-tracer-key; unknown or revoked is 401. Then take one request from the key’s own bucket (TRACER_KEY_RATE_PER_MIN, a capacity guard); over it is 429.
  3. When the database is over TRACER_MAX_DB_MB, answer 507. The client keeps its data and retries.
  4. Read at most MAX_ENVELOPE_BYTES after gunzip; larger is 413. Parse; failure is 400. A schema above the server’s is 422.
  5. In one transaction:
    • Upsert the install (first/last seen, versions, device).
    • Upsert the session only when the envelope carries a session item (session_start, session_heartbeat or session_end). Crashes, performance windows and events keep their session_id as plain text on their own rows, so a user who switched session reporting off never creates a session that the stale closer would turn abnormal.
    • For each item, skip it if (app_id, id) is in items_seen (a duplicate), else store the item by type and record the id:
      • session_start: remember the named catalog_hash on the session. A carried catalog is stored next to the others, keyed by (app, version, hash), never in place of one (at most 8 per version and 200 per app); a hash the server does not hold and did not receive sets need_catalog.
      • session_heartbeat: raise the session totals (never lower them) and last_seen.
      • session_end: raise the totals and set the status, unless the stored status is already terminal. When the status is crashed, the session’s most recent panic crash is marked fatal.
      • event: validate the name. An event is never refused for being undeclared: the catalog is client input, and a refused event is deleted by the client. A non-dynamic name is stored with declared = 1 when it is in any catalog held for that app version (which includes the one the session’s session_start named), else declared = 0. With no catalog held it is declared = 1 and need_catalog is set.
      • perf: store.
      • crash: compute the fingerprint, upsert the issue, store. At most 20 crashes are taken per envelope (the rest count as rejected). An app creates at most 200 new issues per day; further new fingerprints share one “Too many distinct crashes” issue for that day, so nothing is dropped silently. A panic is marked fatal when its session has ended crashed (in either arrival order; the SDK cannot know at capture time whether the host survives). A fatal panic from the client also marks an ok session crashed.
      • drops: add to daily_drops. Each count is clamped to 1,000,000, reasons must be valid names, at most 64 distinct reasons are kept per app per day (the rest count as other) and a stored total is capped.
      • unknown types: skip, count as rejected.
  6. Item times are corrected for clock drift by received_at - sent_at when the difference is over five minutes, then clamped to the retention window in the past and to one hour in the future. Item ids longer than 64 bytes are rejected.

Fingerprint: kind, plus the first frames of the backtrace that belong to the app (skipping std, core, alloc, panic and backtrace machinery, and with hex addresses and hash suffixes removed); when there is no usable backtrace, the location; when there is neither, the message with digits removed. The issue title is the message’s first line, or the kind.

POST /v1/forget deletes the install’s rows from installs, sessions, events, perf, crashes and daily_installs, and removes issues left with no crashes. It answers 200 {"ok":true} whether or not the install existed, so it cannot be used to probe for install ids.

Connections. The server runs hyper directly. A client has 15 seconds to send a request’s headers, which also bounds an idle keep-alive connection, and at most 1,024 connections are served at once; further clients wait in the accept queue. The body of a request must arrive within 30 seconds.

4.4 Rollups and retention

  • Ingest marks every (app, day) it stores data for in dirty_days. A timer (every five minutes) recomputes the daily_* tables for exactly those days, oldest first, and clears each mark unless new data arrived meanwhile; rollup --days N recomputes a fixed window by hand. Late data for an old day (a client replaying a spool) is therefore rolled up too.
  • One day of one app is built on a private connection that only reads the raw tables and collects the results in an in-memory scratch database; the write is then one short transaction that replaces that day’s rows (for the install list only the difference is applied). Ingest is never held up by the aggregation itself.
  • daily_active holds the daily, weekly and monthly active installs per day. The weekly and monthly figures cost a distinct count over up to 30 days, so they are computed for the newest two days only (older days store -1, and readers fall back to counting).
  • Per app, only the 50 busiest versions, 20 busiest operating systems and 500 busiest event names keep their own rollup rows; the long tail is folded into other, so made-up values cannot grow the rollups.
  • daily_events is keyed by declared as well: declared and undeclared uses of one name are separate rows.
  • Retention deletes raw rows older than TRACER_RETENTION_DAYS, daily_drops and catalogs not seen within that time, and items_seen older than 35 days, then lets SQLite reclaim space. Rollups other than drops are kept. Big tables are deleted in chunks of 2,000 rows, each its own short transaction with a pause of three times the chunk’s duration, so ingest keeps getting the writer; space is returned 2,000 pages at a time.
  • Sessions still ok whose last_seen is over a day old are closed as abnormal.

4.5 Dashboard

Server-rendered HTML with maud, charts as inline SVG generated on the server, one small embedded stylesheet, no script build. Login is a username and password (argon2), with a random session token in an HttpOnly, SameSite=Lax cookie; only its hash is stored. Every state-changing form carries a CSRF token. The login cookie lasts 14 days. The login form itself is protected by a short-lived SameSite=Strict cookie paired with a hidden field, and by a limit of 10 attempts a minute per address. Dashboard responses carry Content-Security-Policy: default-src 'self' (no inline script or style), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and Cache-Control: no-store.

PageShows
OverviewPer app: crash-free sessions and installs, daily opens, daily / weekly / monthly active installs, newest issues.
CrashesIssue list with counts, affected installs and versions; filter by kind, version, status. Issue page with backtrace, breadcrumbs, device, last perf window, and resolve / ignore.
PerformanceFrame-time percentiles (from merged histograms), slow and frozen frame rates, startup time, CPU and memory, by version and by operating system.
UsageActive installs, sessions per day, session length, focused / active / idle time, events ranked by use, and declared events that were never fired.
ReportsA stability and usage report for one release: summary, top issues, performance, feature use. Download as HTML, Markdown or JSON.
SettingsApps and write keys.

Definitions:

  • Crash-free sessions = 1 − (crashed + abnormal) ÷ sessions that have ended.
  • Crash-free installs = 1 − installs with a crashed or abnormal session ÷ active installs. Counted per day and summed over the selected range (install-days), because rollups cannot give distinct installs across a range.
  • Daily opens = sessions started that day with process kind gui. Kept in the rollup as daily_sessions.opens, since raw sessions are pruned.

5. Later phases

See ROADMAP.md. The designs above leave room for them: the Store trait for Postgres, the Transport trait for the web target, and an attachment route for minidumps.

Wire format

Schema version 1. The types live in crates/trace-proto; the fixtures in its tests/fixtures/ are the reference examples.

Transport

IngestPOST /v1/ingest, body is one envelope as JSON, usually Content-Encoding: gzip
ForgetPOST /v1/forget, body {"schema":1,"install_id":"…"}
HealthGET /v1/health
Authx-tracer-key: <app write key> on ingest and forget
Limits1 MiB per body after decompression

Replies to ingest are JSON: {"accepted":n,"duplicates":n,"rejected":n,"need_catalog":bool}. rejected counts items the server refused as malformed (a bad name, an unknown type, an over-long id, more than 20 crashes in one envelope); an event is never refused for not being in the catalog. POST /v1/forget replies 200 {"ok":true} whether or not the install existed.

The server limits requests per client address (429), and the address is used for that and nothing else.

StatusMeaningClient does
200Stored (see counts)Deletes the batch
400, 413, 422Malformed or too largeDeletes the batch and counts a drop
401, 403Unknown or revoked keyKeeps data, stops sending until restart
429Rate limitedWaits for Retry-After, then retries
507Server storage is fullKeeps data, retries with backoff (like any 5xx)
other 5xx, network errorServer troubleRetries with backoff

Envelope

One envelope carries items from one session of one install. Data from an earlier session, such as a crash found on the next launch, goes in its own envelope with that session’s header.

FieldTypeNotes
schemainteger1
appobjectname, version, optional build, process (gui, cli, service, child)
install_idstringRandom, resettable. Never derived from hardware.
session_idstringOne per process run
deviceobject, optionalos_name, os_version, arch, cpu_brand, cpu_cores, ram_mb, gpu, locale
sent_atintegerClient clock in Unix milliseconds, for drift correction
itemsarraySee below

Every item has id (unique, client-generated, at most 64 bytes; the server ignores an id it has already stored), ts (Unix milliseconds) and type. The server moves ts onto its own clock when the client clock is more than five minutes off, and clamps it to the server’s retention window in the past and one hour ahead.

The server creates or updates the session record only for envelopes that carry a session_start, session_heartbeat or session_end. Events, perf, crash and drops in an envelope without one are stored with their session_id as plain text, so a client that has switched session reporting off creates no session.

Item types

typeFields
session_startcatalog_hash, catalog (events[] of name, category), listening[], signals[]
session_heartbeatseq, duration_s, focused_s, active_s, idle_s — running totals
session_endstatus (exited, crashed, abnormal), the same totals, errors
eventname, category, dynamic, props (bool, integer, float or short string values)
perfwindow_s, frames, cpu_pct, rss_mb, rss_peak_mb, startup_ms, counters, gauges, incidents[]
crashkind, fatal, message, location, thread, backtrace, blocked_ms, exit_code, signal, context, breadcrumbs[], perf
dropsreasons — what the client discarded, by reason

crash.fatal. A client cannot know at capture time whether a panic ends the host, so it sends fatal: false for panic, handled and hang, and fatal: true for unclean_exit and child_exit (which are found after the fact). The server decides for panics: when a session ends with status: "crashed" (a session_end, in the same or a later envelope, before or after the crash item arrives), that session’s most recent panic is stored as fatal. A fatal: true panic from a client also marks its ok session crashed.

Catalog. session_start.catalog is client input and is treated as unverified: catalogs are stored next to each other by catalog_hash, never replacing one. Each stored event carries a declared flag, set when its name is in a catalog held for the app version. declared = false events are kept and shown separately; they are not an error.

crash.kind is one of panic, handled, hang, unclean_exit, child_exit.

crash.fatal says the process could not continue. The client sets it where it can know: true for unclean_exit and child_exit, false for handled and hang. A panic is sent with fatal: false, because at capture the client cannot know whether the host catches it; the server marks it fatal when the session it belongs to ends crashed.

session_end.errors counts handled errors and panics the process survived. The panic that ends a crashed session is not included.

perf.frames is a histogram: count, sum_us, max_us, slow (over 16.667 ms, the 60 Hz frame budget; slow includes frozen), frozen (over 700 ms), buckets, one count per upper bound in FRAME_BUCKET_BOUNDS_US plus an overflow bucket, and the optional over_budget (over 8.333 ms, DISPLAY_BUDGET_US, the 120 Hz frame budget; it includes slow). Histograms merge by adding buckets.

over_budget was added within schema 1. Clients that predate it leave it out, and older servers ignore it. Because 8.333 ms is one of the bucket bounds, a reader counts it from the buckets when it is missing (FrameStats::over_budget_frames), and the server always counts it from the buckets it stores, so old and new clients report the same figure.

perf.incidents lists the incident rules that fired in the window, at most one entry per rule: rule (long_frames: 3 frames over 8.333 ms within 1 s; stall: one frame of 250 ms or more; memory_growth: 5 rising per-minute memory floors, the last at least 64 MiB and 20% above the first), at_ms (when it first fired, in milliseconds since process start), count (how often it fired in the window) and value (the worst figure: the slowest frame in microseconds for long_frames and stall, the growth in MiB for memory_growth). It was added within schema 1: clients that predate it leave it out, older servers ignore it, and readers treat an unknown rule as other.

Names

Event and category names are lower-case snake_case, optionally dotted (file.open), start with a letter, and are at most 64 bytes. The same rule is checked at build time in the SDK and again by the server.

Compatibility

  • Fields are only added, and new fields are optional.
  • Readers ignore unknown fields, and treat an unknown item type as something to skip.
  • Unknown enum values map to other where the enum has one.
  • A breaking change increases schema.

Telemetry (trace) roadmap

Status, 2026-09-09: release candidate, load-tested. The SDK (ashlar-trace), server (ashlar-trace-server), dashboard, demo hosts and guides are built; the SDK is tested end to end against the real server; a bug review of the SDK and a security review of the server are done and their findings fixed. Moved into ashlar with its history; apps use it through ashlar-telemetry. Not yet published.

Version 0.1

IdMilestoneState
M0Foundation: workspace, gate, wire format with fixtures, SDK and server skeletons, schema, docsDone
M1SDK pipeline: consent, identity, session, queue, spool, transport, event catalog, usageDone
M2SDK signals: panic hook, handled errors, unclean exit, child exit, hang watchdog, frame and system metrics, breadcrumbs, local reportDone
M3Server core: ingest, write keys, rate limits, dedup, SQLite store, rollups, retention, admin commandsDone
M4Dashboard and reports: overview, crashes, performance, usage, per-release report exportDone
M5Demo hosts and end-to-end testsDone
M6Guides and packaging: integration guide, self-hosting guide, container imageDone
M7Hardening and 0.1.0 releaseRelease candidate
M8Tests, stress and load: edge-case and fuzz suites, SDK hot-path and server ingest optimisation, large-database read pathDone; two load targets missed (below)

Still open

  • Find why ingest latency collapses at 2,000 envelopes a second once the database passes about 3 GB. Suspected: index inserts outgrowing the cache; not proven.
  • Cut ingest tail latency while retention deletes a large backlog (p99 about 300 ms against about 50 ms).
  • Repeat the load runs on Linux and on a quiet machine; all numbers so far are from one busy macOS machine.
  • Build and run the container image once (deploy/trace/Dockerfile, deploy/trace/compose.yml). Written but never built.
  • Run the windowed demo (examples/trace-demo-gui). It compiles and passes lints but has never been opened.
  • Check the dashboard on a narrow (360 px) screen.
  • Check the login cookie (Secure, __Host- prefix) in a browser over real HTTPS.
  • Decide whether to prove “no allocation” for the realtime counters, which needs an unsafe exemption for one test file.

Later

MilestoneNotes
Native crashesOut-of-process monitor, minidumps, symbol upload and server-side symbolication, in an isolated crate that is the only one allowed unsafe.
PostgresA second Store implementation for larger deployments.
Web targetA transport for wasm32, where the SDK is currently a no-op.
AlertsEmail or webhook on a new issue or a crash-rate spike.
OTLP exportForward stored data to OpenTelemetry collectors.

Releasing

Every crate in crates/ is published to crates.io with the same version (the workspace version in the root Cargo.toml). The umbrella crate is published as ashlar-rs, because the ashlar name on crates.io belongs to an unrelated project; its library name is still ashlar, so apps write use ashlar::prelude::*. Examples and xtask are publish = false.

One-time setup

  1. On crates.io, create an API token with the publish-new and publish-update scopes. Pick no expiry (or a long one), so it is set once.
  2. Store it as the repository secret CARGO_REGISTRY_TOKEN:
    gh secret set CARGO_REGISTRY_TOKEN -R Himasnhu-AT/ashlar
    
    (paste the token when asked). The token never needs to be on a developer machine.
  3. Optional, later: switch to crates.io trusted publishing and delete the token.

What triggers a release

Only pushing a tag of the form vMAJOR.MINOR.PATCH (for example v0.2.0). Nothing else publishes: pushes to main, pull requests and Dependabot never do, and running the release workflow by hand (Actions → release → Run workflow) is always a dry run.

Before anything is uploaded, the workflow refuses to go on unless:

GuardWhy
the tag equals v + the workspace versionno mismatched or mistyped tags
the tagged commit is on mainnothing from an unmerged branch
the full CI gate passes on that exact commit (all three OSes, MSRV)no broken or half-done state
CHANGELOG.md has a ## [x.y.z] - YYYY-MM-DD sectionevery release is documented
that version of ashlar-rs is not on crates.io yetno accidental re-release

Then it runs cargo publish --workspace --locked (every crate in dependency order), skipping the crates that already have this version on crates.io, and creates the GitHub release with that version’s changelog section as the text. crates.io lets an account create only a few new crates at once and then about one every 10 minutes, so a release that adds new crates (the first one adds all of them) waits and tries again until every crate is there; that can take a few hours. Any other publish error stops the job.

Cutting a release

  1. Make sure main is green on CI and everything for the release is merged.
  2. Run cargo xtask release x.y.z (add --date YYYY-MM-DD to date it other than today, UTC). It bumps version in [workspace.package] and every internal crate’s version = "…" in [workspace.dependencies] together, renames “Unreleased” in CHANGELOG.md to ## [x.y.z] - YYYY-MM-DD under a fresh empty “Unreleased”, updates the compare links at the bottom, and runs cargo check to update Cargo.lock. It refuses when the versions disagree, the section already exists or “Unreleased” is empty, and it never commits, tags or publishes.
  3. Read the diff; bump any version shown in docs snippets (for example docs/trace/integration.md).
  4. Check that everything packages: cargo publish --workspace --locked --dry-run.
  5. Commit to main and push. Wait for CI to pass.
  6. Tag and push the tag:
    git tag -a v0.2.0 -m "ashlar 0.2.0"
    git push origin v0.2.0
    
  7. Watch the release workflow. If a guard fails, nothing was published: fix the cause, delete the tag (git push origin :refs/tags/v0.2.0, git tag -d v0.2.0) and tag again.

If publishing stops halfway (a network error, a timeout), crates.io keeps the crates that made it. Fix the cause and run the failed publish job again from the workflow run: it publishes only what is missing. If the fix changes the workflow itself, a re-run still uses the old one; push the fix to main and move the tag to that commit (git tag -fa vX.Y.Z -m "ashlar X.Y.Z", git push -f origin vX.Y.Z). That is safe only while the crates’ own files are unchanged, since the published crates came from the old commit.

Versioning

ashlar follows Semantic Versioning. Before 1.0, a minor version (0.x) may break the API; patch versions never do. The items marked CONTRACT in ashlar-command change only additively.

Security policy

Supported versions

ashlar is in early development. Security fixes go into the latest released minor version and into main.

VersionSupported
0.1.x (latest)yes
olderno

Reporting a vulnerability

Please don’t report security vulnerabilities in public issues, discussions or pull requests.

Report them privately through GitHub: open a private security advisory.

Please include:

  • the affected crate(s) and version or commit
  • a description of the issue and its impact
  • steps to reproduce, or a proof of concept
  • any suggested fix

You can expect an acknowledgement within a few days. We’ll keep you updated while we work on a fix, and credit you in the advisory unless you’d rather stay anonymous. Please give us reasonable time to release a fix before you disclose the issue publicly.

Scope

Areas where security matters most in ashlar:

  • the agent control port (ashlar-control) and MCP over HTTP (ashlar-mcp-http): authentication, Host and Origin checks, input limits
  • downloading and verifying integrations (ashlar-integrations) and update checks (ashlar-update)
  • telemetry (ashlar-trace, ashlar-trace-server): data scrubbing, ingest limits, the dashboard
  • parsing of untrusted documents and files

Dependencies are checked against the RustSec advisory database on every CI run.

Changelog

All notable changes to ashlar are documented in this file.

The format is based on Keep a Changelog, and this project follows Semantic Versioning. Before 1.0, a minor version (0.x) may contain breaking changes.

Unreleased

Added

  • Documentation site (site/, mdBook, deployed at https://ashlar.himanshuat.com): a user guide (first app, documents, commands, views, jobs, native features, agents, testing, web, a cookbook, an FAQ, building with an AI assistant), the developer docs and this changelog, plus llms.txt, llms-full.txt and each page’s markdown for AI agents.

0.2.0 - 2026-11-11

Added

  • cargo xtask release <x.y.z> [--date YYYY-MM-DD]: bumps the workspace and internal crate versions together, dates the changelog’s “Unreleased” section, updates the compare links and refreshes Cargo.lock (docs/releasing.md step 2). It never commits, tags or publishes.

  • ashlar_ui::sidebar (IMP-5.14): a source list of folding sections with counts and rows with an icon, detail and badge, built on list::Row and virtualized. One Tab stop: arrows, Home/End, Left/Right to fold, Enter to activate, F2 to rename in place, type-ahead; click, double click and context clicks reported in SidebarResponse. Automation ids <id>, <id>.section.<s>, <id>.section.<s>.item.<i>, <id>.rename; labels UiLabels::sidebar (ui.sidebar.rename, ui.sidebar.items, English and German). list::Row gains icon, detail, badge and focusable. The gallery has a Sidebar page.

  • CodeView and DiffView follow-ups (IMP-5.20): unwrapped long lines scroll sideways (horizontal wheel or Shift+wheel, and a draggable bar along the bottom; a split diff’s sides move together); both views register the new automation role Role::Document ("document" in ui.inspect). The gallery has a Code page. An open dialog no longer copies its shapes for the exit animation on frames where they did not change, and reuses the copy’s buffer when they did (perf_dialog_open_frame_allocs: per-frame overhead over a plain area 173 → 21 allocations, 24.9 → 5.5 KB).

  • Controls follow-ups (IMP-5.19): controls::Skeleton placeholder rows (pulse while visible, static and frame-free under reduced motion, a busy progress node named by the new UiLabels::controls.loading, English and German); content::Accordion keeps at most one section open; Segmented::disabled and RadioGroup::disabled dim options that cannot be picked and that the keys skip; the ⇧ keycap is drawn as an outlined arrow (the default fonts lack the glyph). Visual regression adds reference-painter pairs for the progress circle and the spinner’s static poses.

  • Toasts (IMP-5.17): F6 moves keyboard focus into the newest toast (its first action, else its close button) and back to where it was; Toasts::take_actions_where drains only the actions a caller accepts; App::run_toast_actions(true) makes the runtime run a clicked toast action whose command is registered (other actions stay for the view). The gallery’s Overlays page demos action and progress toasts.

  • ashlar-rs: the tray and hotkey features pass through to ashlar-app, so an app on the umbrella crate can use App::tray and App::global_shortcut.

  • Windows menu shortcuts (IMP-6.17): NativeMenuBar::attach_window subclasses the window and runs TranslateAcceleratorW with the bar’s table on key-downs, so a native menu’s key equivalents fire as item activations (once; the key press doesn’t also reach the window). Compile-checked for x86_64-pc-windows-msvc; not run on Windows yet.

  • Context menus and drag-out (IMP-6.15): ashlar_native::menu::popup::show(window, &spec, at, waker) opens a MenuSpec as a native context menu (macOS: posted to the run loop so it never opens inside the UI pass; Windows: opens at once, call it between frames) and reports Some(id) or None on a receiver. ashlar_native::drag_out::start(window, DragItem { files, png, text }, on_end) (feature drag-out, in full) drags files, a PNG or text into other apps on macOS; Unsupported elsewhere.

  • ashlar_native::mac_shell (default feature mac-shell, IMP-6.14): guard_appearance() checks the window delegate’s effectiveAppearance changes before winit casts them, so a nil appearance while the screen is captured no longer ends the app; listen_reopen(waker) reports Dock reopen clicks; quiet_requested(prefix) reads <PREFIX>_QUIET. An ashlar app installs both after its first frame, shows and focuses its window on reopen, and with <PREFIX>_QUIET=1 opens without taking the focus.

  • ashlar_native::hotkey (feature hotkey, IMP-6.16): system-wide shortcuts on global-hotkey (macOS, Windows, Linux on X11). GlobalShortcuts::new(waker), register(id, &chord) with the keymap’s chords (a chord without Ctrl, Alt or ⌘ is refused), unregister, drain(); dropping it releases every chord. system::bring_to_front(window) restores and focuses a window through the OS (Windows borrows the foreground thread’s input when the foreground lock refuses), and a second launch on Windows now calls AllowSetForegroundWindow for the first (its pid travels in the endpoint file). App::global_shortcut(command, chord) (feature hotkey of ashlar-app) runs the command and brings the window forward; forwarded launches and tray clicks also bring it forward through the OS.

  • ashlar_native::tray (feature tray, IMP-6.11): a tray / status-bar icon with a menu built from a MenuSpec (macOS status bar, Windows notification area, Linux StatusNotifierItem over D-Bus with no GTK). Tray::new(TraySpec { id, tooltip, icon, template, menu }, waker), events() (Activate, Menu(id)), set_tooltip / set_icon / set_menu; dropping it removes the icon. The menu bar, trays and later popups now share one process-wide menu-event router, so a tray’s items never reach the menu bar. App::tray(spec) (feature tray of ashlar-app) runs the chosen item’s command and shows the window on a click.

  • cargo xtask launch --memory launches 5 times (--runs N) and prints the min and max idle and peak footprint and how many runs fell in the high group; one launch landed in either of the two macOS groups at random (IMP-2.19).

  • ashlar_native::memory reports the OS’s lifetime peak footprint (ProcessMemory::peak: macOS lifetime max phys footprint, Windows peak private commit) and thread_count() (macOS, Windows, Linux); app.stats → process.peak_bytes uses the OS peak, so spikes between samples count, and process.threads is filled on macOS and Windows too (IMP-6.18).

  • ashlar_native::display::monitors() lists the monitors before any window exists (macOS, Windows); a restored window placement is checked against them at launch instead of only on the first frame (IMP-6.18).

  • Web (IMP-6.8): App::canvas_id; when a web app cannot start (no such canvas, no WebGL) a visible error (role="alert") appears next to the canvas or at the top of the page; examples/web-hello (with an index.html for trunk) is checked for wasm32 by cargo xtask wasm.

  • i18n follow-ups (IMP-6.19): plural forms for the toast “+N more” line and the keymap editor’s “N commands share a shortcut” (new key ui.keymap.shared), through labels::Plurals, labels::install_i18n, labels::count_text and labels::PLURAL_KEYS (the label structs are unchanged); ashlar_native::system::preferred_languages() (macOS NSLocale.preferredLanguages, Windows GetUserPreferredUILanguages), which ashlar_i18n::system_locales() now appends after the environment’s languages in apps (set_native_languages, with_native).

  • Record and replay (IMP-4.11): ASHLAR_RECORD=path (or Runtime::record_to) records each frame’s time, screen and input events plus the control requests served to a JSONL ring of at most 64 MiB (written off the UI thread), and Driver::replay(path) plays it into a fresh driver; see docs/debugging-uis.md.

  • Cx polish (IMP-3.8): cx.tokens(); cx.motion(id, target, curve), which animates unless the document just changed by the user’s own hand; Origin (User, Drag, Undo, Agent, Job) recorded per change (Session::origin, Session::with_origin, Cx::origin), with control requests marked Agent and job applies Job; ashlar_ui::layout::{row, column, inset} taking a tokens::Space, SpaceScale::get; Radii::{sm, md, lg}() as f32.

  • Telemetry incident rules (IMP-2.13): ashlar-trace flags 3 frames over the 120 Hz budget within 1 s (long_frames), a frame of 250 ms or more (stall) and 5 rising per-minute memory floors that grow by at least 64 MiB and 20% (memory_growth), once per rule and window, in the new optional perf.incidents list (Incident, IncidentRule in ashlar-trace-proto; older readers ignore it). The trace server counts them as incidents.<rule> metrics and lists them on a crash’s performance panel.

  • ashlar_ui::markdown v2 (IMP-5.4): links take keyboard focus (Tab, then Enter or Space opens them) with a focus ring, have the AccessKit Link role and automation role Link (new), and answer the pointer on every row they wrap onto; tables size columns to their content and wrap them down to their longest word, with a “N more rows” footer (<id>.table.<n>.more) past MAX_TABLE_ROWS (500) instead of dropping rows silently; the Markdown builder adds .on_task(|index, checked| ..) (clickable task checkboxes, <id>.task.<n>), .find(query) (highlighted matches, counted in MarkdownResponse::matches; find_ranges gives the byte ranges), .streaming(true) (an unclosed **, *, ~~, ` or link at the end is drawn as already styled, so markers never flash; also parse_streaming) and .labels(&MarkdownLabels) for “Copy”, “Copied”, “code” and the table footer, by default from the installed UiLabels (ui.markdown.copy, .copied, .code, .more_rows; English and German). MarkdownStyle gains find_bg.

  • ashlar_ui::markdown::show_with_revision and Markdown::revision: a caller that keeps a revision of the text skips hashing it each frame. Inline parsing is linear in the paragraph’s length (a 50 KB paragraph: bench: md_long_paragraph_parse, about 0.4 ms), and a drawing snapshot (tests/markdown_snapshot.rs) checks what markdown paints (IMP-5.18).

  • Rich clipboard: clipboard::set_rich / rich (text, HTML, PNG, file lists; read HTML is sanitized by clipboard::sanitize_html, every form size-capped), clipboard::set_secret(text, clear_after) (kept out of clipboard history where supported, cleared only if still ours) and a Backend trait with MemoryClipboard for tests.

  • Dock ▸ Quit, logout, restart and shutdown go through the “Save changes?” check and flush settings, autosave and telemetry (ashlar_native::session_end, feature session-end, on by default): macOS answers applicationShouldTerminate: later until the user chooses; Windows handles WM_QUERYENDSESSION / WM_ENDSESSION and, since it can’t wait for a dialog, keeps unsaved changes for recovery. The flow is the pure session_end::step.

  • Gesture handoff: value fields, sliders and knobs keep going when a drag is let go while moving and ease to rest (clamped, snapped; the commit carries the resting value, so one drag is still one undo step); .fling(false) turns it off. VirtualList::fling(true) drags the list to scroll it, with fling and rubber band past either end. A pause of 40 ms before release, or reduced motion, means no fling.

  • ashlar_ui::rolling::RollingText: counters and readouts that roll instead of jumping. Only the changed characters move (prefix/suffix, then LCS for short text), up when .value(..) rises and down when it falls; the width follows; .tabular(true) gives digits one width. Reduced motion crossfades. The accessible value is the whole new text at once; nothing animates once settled.

  • Windows reopen where they were: App::remember_window(bool) (default on) saves position, size, maximized and full screen state in the settings under "window" when they change, and the next launch opens the window there, sizes bounded and a window left on no monitor moved back (ashlar_app::window_state::clamp). App::viewport(|builder| …) changes the window builder (minimum size, decorations…) and App::icon(png_bytes) sets the window icon.

  • ashlar-i18n (new crate, rank 0, builds for wasm32): translations without a full Unicode library. Flat JSON catalogs per locale, parsed on first use and layered (later catalogs override key by key); a bad catalog is reported and acts as empty, never a panic. I18n::{text, format, plural, integer, decimal, set_locale, negotiate}: {name} templates, CLDR plural categories for about 50 languages (key.one, key.few, …), per-locale group and decimal separators (including Indian grouping), fallback locale → language → source (zh-TW → zh-Hant), POSIX and BCP 47 tags (de_DE.UTF-8 → de-DE), system_locales() from LANGUAGE/LC_ALL/LANG, and I18n::check() for missing keys and mismatched placeholders.

  • ashlar_ui::labels: one UiLabels installed on the context holds every built-in component string (toasts, form, chat, diff view, keymap editor, tab strip, print panel). Components use it unless the app passes its own labels, which still win. English and German catalogs ship for the ui.* keys; UiLabels::from_i18n builds the labels for any locale.

  • Perf warnings in debug builds (ashlar_app::perf_warn): one stderr line per site when a frame waits (≥ 8 ms wall at ≥ 4× its CPU time; names the command or wake reason) or when 60 identical frames in a row are drawn for non-input wakes. ASHLAR_FRAME_BUDGET_MS=N (any build) prints at most one line a second for frames over N ms with the phase split; ASHLAR_PERF_WARN=0 silences.

  • ASHLAR_FRAME_LOG=FILE and Runtime::log_frames(path): one JSON line per frame (wall and UI-thread CPU time, phases, wake reasons, commands run, pass number), written by a background thread and capped at 64 MiB with a final "end" line (ashlar_app::frame_log).

  • app.stats (and the MCP app_stats tool) reports process: footprint_bytes, peak_bytes, cpu_s and threads (ProcessStats; null where the platform cannot say).

  • ashlar-native notifications: stable ids (Notification::id; the same id replaces a shown notification), notify::show (returns the id), notify::remove, notify::on_click (a Receiver<Click> with the notification’s info map), silent, and notify::set_app_identity. Windows now shows toasts (AppUserModelID registered under HKCU for unpackaged apps, escaped XML, tags over 64 characters hashed); macOS installs a notification-center delegate so banners show while the app is frontmost and clicks are reported; Linux sends the desktop-entry hint and reports clicks from ActionInvoked.

  • ashlar_ui::chat: a chat kit over plain data. Transcript is a virtualized, variable-height message list (heights kept per message key, revision and width; a frame costs O(visible)) that follows the newest message while it streams, stays put once the user scrolls up and then offers “Jump to latest”; it is a polite AccessKit log. Message draws a role and a markdown body with a streaming state, ToolCard a tool call with folded details and keyboard-reachable Approve/Deny (<id>.approve, <id>.deny), and Composer a text field where Enter sends, Shift+Enter adds a line, input-method composition never sends, and @ / / open a PopupList of suggestions. Strings come from ChatLabels. The gallery has a Chat page.

  • ashlar-diff (new crate, rank 0, no dependencies, builds for wasm32): line diffs with Myers’ algorithm under a cost cap (past the cap, a split at lines unique to both sides, then a coarse delete/insert, flagged coarse), word-level emphasis for paired changed lines, hunks with context and the unchanged ranges between them (FileDiff::gaps, expandable when the new text is known), and parse_patch / write_patch for unified and git diff patches (renames, copies, modes, binary markers, quoted paths, \ No newline at end of file); malformed patches are an error, never a panic. About 2 ms for a 10k-line file.

  • ashlar_ui::diff_view::DiffView: unified and split diffs, virtualized (about 0.4 ms per scrolled frame at 50k lines), syntax-coloured per file language, word-level emphasis, “expand N lines” on collapsed ranges, foldable files, a sticky file header, line selection, N/P (or F7) between hunks. Automation <id>.file.<f>.hunk.<h> and friends for visible rows; AccessKit document, headings and labelled lines.

  • app.stats reports launch: to_window_ms, to_first_frame_ms and launch_to_present_ms, counted from the first line of run() (FrameStats::launch, LaunchTimes). Native windows only.

  • <PREFIX>_READY_FILE=path: the app writes {"pid", "ready_ms"} there atomically once its first frame is presented.

  • cargo xtask launch measures examples/hello launch time over 9 launches (p50/p95), and cargo xtask launch --memory its idle RSS and physical footprint (macOS). Manual, opens windows.

  • App::frame_latency(frames): how many frames the GPU may queue ahead of the screen (1 to 3).

  • Motion and jank gate for tests: ashlar_ui::motion_gate::MotionRecorder records values animated through ashlar_ui::motion and per-frame automation rects (Driver::record does both for an app), and check(&MotionRules) flags overshoot of opacity and colour, reversals without bounce, pops, flicker and frames requested after everything settled, with a per-id allow-list. The gallery’s dialog, toast, theme crossfade and tab switches pass it.

  • ashlar_ui::overlay: Select (a button with a list of options), Combobox (a text field whose options are filtered with the command palette’s fuzzy matcher; input-method composition never chooses), and Popover (anchored, flips above at the window edge, Escape or an outside click closes, focus moves in and returns). Option lists are virtualized, fade with the enter_exit motion token and register <id>.option.<n> with ListBox/option roles.

  • ashlar_ui::help(response, text): a tooltip after the new motion.tooltip_delay token, also given to screen readers as the control’s description.

  • ashlar_ui::commands::menu::context(response, &spec): a context menu built from a MenuSpec.

  • Automation roles ComboBox, ListBox, ListOption and Tooltip.

  • ashlar_ui::form: the settings form pattern. Sections of rows (label, description, control) with a modified marker and reset button, a filter that hides non-matching rows and empty sections, highlights matches and shows “No settings match”, and Field with a required marker and an announced error. Row controls get automation ids <section>.<row>; strings come from FormLabels. The gallery has a Settings page.

  • Golden snapshots: Driver::assert_snapshot(name) and the .ui line assert screenshot <name> [target] [--tolerance channel=N,pixels=N] compare with tests/snapshots/<name>.png, exact by default; a mismatch writes -new.png and a magenta -diff.png; ASHLAR_UPDATE_SNAPSHOTS=1 records baselines with a .meta.json (adapter, backend, scale); a baseline from another adapter is skipped with a note. Screenshot replies from a Driver now carry adapter, backend and scale (IMP-4.8).

  • Named scenes: App::scene(name, |s| ..), --scene NAME (in a window or --headless), --list-scenes, Driver::scene, the ui.scene control method and the MCP tool ui_scene (listed only for apps with scenes) (IMP-4.9).

  • App::single_instance(true): a second launch hands its arguments to the running app and exits 0; the running app opens the first file with file.open (a dirty document asks to save first) and comes to the front. Desktop only.

  • Files from Finder (macOS odoc / GURL Apple events: a double-click, a drop on the Dock icon) open with file.open while the app runs and at launch.

  • App::accept_drops(bool) (on by default when the app sets a format or a file extension): the first dropped file with a known extension opens with file.open; the others reach the view as Cx::dropped_files(). Driver::drop_files and Driver::forward_launch test both paths.

  • app.clock control method: {pause: true}, {step_ms} (one frame that much later) and {resume: true} hold the time an ashlar-app window’s frames see, so an animation can be screenshotted or measured at an exact moment; input still runs while paused and resume does not jump. ashlar-debug clock pause|step <ms>|resume (also in .ui scripts) and the MCP tool ui_clock send it; ashlar_control::{APP_CLOCK, ClockOp} parse it.

  • examples/hello, the smallest ashlar app, and a cargo xtask bench step that reports its stripped release size as hello_stripped_bytes.

  • Features accesskit and headless on ashlar-rs and ashlar-app, both on by default (no change for existing apps). default-features = false drops AccessKit and --headless for a binary about 1 MB smaller.

  • A release-small profile in the workspace (fat LTO, one codegen unit, opt-level = "s", stripped): the hello app goes from 13.6 MB to 9.0 MB stripped on macOS arm64.

  • ashlar-ui: screen readers see every visible row of VirtualList, VirtualTable and TreeView: list items, table rows and tree items named by row_label (the tree’s labels), with selected state, position and set size, and level and expanded state for tree items. Only visible rows get nodes, and only while AccessKit is on.

  • ashlar-ui: selection::Selection, an app-owned, serializable multi-selection (anchor, cursor, ranges). VirtualList::selection(&mut sel) and VirtualTable::selection handle Shift-click, Shift+arrows, ⌘/Ctrl-click and ⌘A; selected and is_selected keep working without it.

  • ashlar-ui: VirtualList::type_ahead(&text) and VirtualTable::type_ahead: letters typed within a second of each other jump to the next row starting with them.

  • ashlar-app: --headless for every App::run binary: --size WxH --scale S --theme light|dark --script file.ui --at SECONDS --snap out.png --tree tree.json writes a screenshot (real pixels when there is a GPU) and the UI tree with no window, in a temporary folder. Exit codes 0/1/2 as in ashlar-debug. ashlar-debug exports save_png.

  • ashlar-app: Driver::run_ui(script) runs a .ui script (the ashlar-debug batch language) in process, with no port or thread. sleep and wait N let virtual time pass, so two runs give the same steps. ashlar-debug gains the Transport trait and run_script to run scripts against any target.

  • ashlar-ui: cache::label (a ui.label with its galley cached under a key: no layout and no String on steady frames), cache::row_label (the same, not selectable, for text inside a clickable row) and cache::button_text (a button’s text as a cached galley).

  • ashlar_ui::tokens::set_mode_animated: switches theme with an OKLab crossfade, surfaces over 0.4 s and text leading at half that; primary and secondary text keep 3:1 on the panel at every frame, and once settled the theme’s own visuals are back and nothing more runs. Tokens::mix blends two token sets.

  • ashlar_ui::tabs::indicator: where to paint a tab strip’s selection underline or pill, sliding by its edges to the newly selected tab (paint only; tabs keep their rects for hit testing).

  • ashlar_ui::tabs::TabStrip: a standalone tab strip with one Tab stop (arrows, Home and End move the selection), close by button, middle-click or ⌘W, an overflow menu for tabs that do not fit, drag or ⌘⇧ + arrow reordering (tabs::reorder applies a move), the animated selection indicator, an AccessKit tab list with positioned tabs, and automation ids <id>.tab.<n>. The gallery has a Tabs page.

  • ashlar_ui::tabs::Tab::automation: registers a tab under a dotted automation id.

  • ashlar_ui::motion::presence: keeps an item drawn while it leaves (Some(progress) until its exit settles, then None), entering and leaving along a token pair as an opacity.

  • ashlar_ui::code_view::CodeView: a read-only code view with virtualized lines (O(visible) per frame), a line-number gutter, optional wrapping, mouse selection, select all and copy, search marks and syntax colours from SyntaxTheme::from_tokens. Line galleys are cached per line and revision; lexer states are computed lazily. Automation <id>.line.<n> for visible lines only; AccessKit document and paragraphs. About 0.06 ms per scrolled frame at 100k lines.

  • ashlar_ui::markdown: fenced code blocks in a known language are syntax-coloured.

  • ashlar-syntax (new crate, rank 0, no dependencies, builds for wasm32): syntax highlighting with hand-written, linear-time lexers for Rust, TypeScript/JavaScript, Go, C, C++, Java, C#, Python, shell, SQL, JSON, TOML, YAML, HTML (with script and style), CSS, Markdown (fenced code in its language) and diffs. highlight returns ordered byte ranges that cover the input; highlight_line carries a State from line to line for incremental views; lang_for_path; a Highlighter trait so a bigger engine can plug in. About 0.5 ms per 1,000 lines of Rust.

  • ashlar-app: Driver::with_renderer(runtime, Renderer::wgpu()) renders real screenshots with no window, on a wgpu device with no surface, at the driver’s display scale. Without an adapter it falls back to the grey image. Screenshot replies from a Driver say which was used ("renderer": "wgpu" or "none"), and grey screenshots now have the screen’s size in pixels instead of points.

  • ashlar_ui::list::Row: a cheap selectable list row for plain (non-virtual) lists: one allocation, one interact, and a cached galley only while on screen. AccessKit list item with label and selection; focusable, Space and Enter click; opt-in automation id. A 1k-row plain list scrolls in about 0.10 ms per frame with it, against about 0.7 ms with Button::selectable rows.

  • ashlar_ui::list::checked: wraps a list’s content and, in debug builds, warns once per call site when it builds more than 2,000 interactive widgets in a frame, pointing to VirtualList (ASHLAR_QUIET_LISTS silences it).

  • ashlar-motion (new crate, rank 0, no dependencies, builds for wasm32): closed-form springs set by duration and bounce (Spring, never early settle_time), Curve (spring, cubic-bezier tween, decay, instant), Animated<T> values whose retargets keep position and velocity and that land exactly on their target, Class settle thresholds and reduced-motion rules (opacity and colour never overshoot), MotionPrefs, motion tokens (exits shorter than enters), an OKLab/OKLCH color module with gamut mapping, premultiplied mixing and WCAG contrast, and gesture helpers (velocity tracker, fling projection, rubber band).

  • ashlar-app: frame stats split the frame into phases: app.stats keeps the UI time as p50_ms/p95_ms/max_ms and adds pass, tessellate and frame_cpu (the whole frame on the CPU: eframe’s own figure in a window). Driver tessellates every frame like a window and takes the display scale (Driver::pixels_per_point).

  • ashlar_ui::motion: animated values for egui in one call per frame (value, f32, opacity, color in OKLab, rect by edges, set, release), kept in the context and forgotten when unused. Nothing animates on first show, frames are requested (Reason::ANIMATION) only while a value moves, and settled values are exactly their target. prefs/set_prefs set reduced motion (also turning off egui’s widget animations) and a time scale; active counts moving values.

  • ashlar_ui::tokens: motion (spring per kind of change), elevation (popup and window shadows, no longer hard-coded in visuals()) and stroke (hairline, focus) tokens; Tokens::high_contrast(); contrast_ratio; TOKEN_SPEC, the OKLCH source of every built-in colour, checked against the hex values by a test.

  • ashlar-app follows the OS preferences: reduced motion (through ashlar_ui::motion::set_prefs), the accent colour (Tokens::with_accent) and increased contrast (Tokens::high_contrast), read at startup and again when the window gains focus (on Linux off the UI thread). Tokens an app installs itself win. App::follow_system_accent(bool) (default on) and App::motion(MotionPrefs) override; Runtime::set_preferences injects preferences in tests, and App::runtime() ignores the OS so test runs match on every machine. tokens::of and tokens::apply_colors are new.

Changed

  • File dialogs open over the app window (a sheet on macOS) and no longer block a frame: the frame that asks for one finishes, the next shows the dialog and acts on the pick. Saving an untitled document from the “Save changes?” dialog now goes on to the held-back open or quit after Save As. ashlar_native::dialogs::FileDialog::parent(&window) parents any picker.

  • ashlar-native macOS notifications play the default sound unless silent is set, and ask for sound permission only then.

  • A document named on the command line opens on the frame after the first, so the window shows before the load runs (it used to load before the first paint).

  • Windows queue at most one frame ahead of the screen by default (App::frame_latency(1)): lower input latency and one swapchain image fewer (about 10 MB less idle footprint for a 960×640 window on a Retina screen). Apps with heavy per-frame GPU work can ask for 2.

  • ashlar_ui::list::Row gains indent, text_style and weak. The devtools Elements list draws its rows with it (about 6.7× cheaper per row than its old selectable labels: 0.10 ms against 0.67 ms for 1k rows), and the tab strip’s overflow menu runs inside list::checked.

  • ashlar_ui::docking: group tab strips use the shared tab strip: one Tab stop per group with arrow keys, an AccessKit tab list, and the animated selection underline.

  • ashlar_ui::controls: button, switch, segmented and radio hover and press fills ease over about 0.1 s, like egui’s own widgets (tokens::state_color, Tokens::interact_fill_animated); they change at once when egui’s animation_time is 0, which reduced motion sets. Visual regression tests now run with reduced motion.

  • ashlar_ui::dialog: on open the backdrop fades in and the panel fades in while growing from 97 %; when the app stops showing a dialog, its last frame fades out a little faster without taking input. No growing under reduced motion; hit testing uses the resting layout.

  • ashlar_ui::docking: the drop preview moves quickly (0.1 s) from one drop target to the next while a tab is dragged, and appears in place when a drag first shows it.

  • ashlar_ui::toasts: a new toast fades and slides 8 pt in, a dismissed one slides back out and is painted until its exit settles, and the toasts behind it glide into the gap. Toasts queued before the stack is first shown appear at once; hit testing and automation rects use the resting layout; reduced motion keeps only the fade.

  • Tokens::with_accent derives hover, focus, selection and selection text in OKLCH (same hue, lightness steps, capped chroma) instead of sRGB mixing, flips text.on_accent dark when white falls below 4.5:1 and pushes the focus ring to 3:1 on every surface. The dark theme’s accent is darker (#276ee0, was #3b82f6) so white text on it reaches 4.5:1; its derived states follow.

  • The status bar’s indeterminate progress holds still (and asks for no frames) under reduced motion, and its sweep follows the motion time scale.

  • Fewer allocations per frame (IMP-2.16): a frame woken only by a caret blink in the notes app makes 550 allocations instead of 958, a scroll frame 345 instead of 472, a keystroke 1,935 instead of 3,720. The menu bar and the notes list draw cached galleys, and an edit that changes no command’s enabled state no longer rebuilds every command’s info and the menus.

  • ashlar-app: a command run from the keyboard (or anything else before the view draws) no longer asks for a document frame: the frame that ran it already shows the change. The runtime also stops copying and comparing the view state and formatting the window title on frames that did not change them.

  • ashlar-ui: variable-height VirtualList and VirtualTable keep row heights in a Fenwick tree, and changed_rows(&[row]) says which rows changed height: those are measured again in O(log rows) each instead of re-summing every row (1M rows: about 2 ms → 1 µs). A full rebuild (new revision or row count) costs about a third more than before.

  • ashlar-ui: markdown::show keeps its parse while the text is the same and parses streamed text again only from its last open block; each block’s layout is kept per width and style, and blocks outside the clip rect only reserve their space (a 50 KB document: about 1.9 ms → 0.02 ms a frame). Automation ids (<id>.link.<n>, <id>.code.<n>) now exist only for blocks drawn, with the same numbering. MdBlock, Span and Marker implement Hash.

  • ashlar-ui: the galley cache (cache::galley, RevisionCache, MeshCache) hashes with a fast fixed-seed hasher (cache::hash_of values differ from before), reads the shared cache under the context’s read lock, and when full drops its least recently used half instead of everything, so labels in use are not laid out again.

  • ashlar-ui: a controls kit (controls): Button (primary, secondary, ghost and destructive variants, three sizes, an icon, a loading state that shows a spinner and reports busy), Switch, Segmented and RadioGroup (one Tab stop; arrows, Home and End move the choice), Badge with tones, Keycap (a cap per key of a shortcut), Progress::bar and Progress::circle (determinate or indeterminate) and Spinner. Each registers a dotted automation id and fills its AccessKit node; indeterminate indicators ask for frames (wake::Reason::ANIMATION) only while on screen and hold a static pose with reduced_motion(true). New automation roles: switch, radio, radio_group, progress, label, group, disclosure, alert. The gallery has a Controls page, and the visual regression suite compares the kit against reference painters (222 pairs).

  • ashlar-ui: dialogs move the keyboard focus inside when they open (the body’s first field, else the default button, else the cancel button; Dialog::initial_focus chooses) and give it back to the widget that had it when they close, however they close (Dialog::return_focus_to names it for a dialog opened by a click). Screen readers get a modal dialog node named by the title, with Dialog::description (also shown under the title) as its description.

  • ashlar-ui: content components (content): EmptyState (icon, title, text, action), Banner (a tone, title, action and close button; a live region that screen readers announce, assertively for errors), Card (a titled group) and Collapsible (a header that shows or hides its content; reports expanded to AccessKit, arrows open and close it). IconButton::tooltip gives icon-only buttons a visible name on hover, and controls::Button::part registers a component’s button as <id>.<part>. The gallery has a Content page; the visual regression suite covers the collapsible header and the banner.

Changed

  • ashlar-ui: dialog buttons are controls::Buttons (primary for the default, destructive for a destructive one); a disabled one is drawn at the disabled opacity instead of in the sunken colours, and its automation entry no longer reports the default button as selected.

  • ashlar-ui: wake::wake, Waker::wake and a zero-delay wake_after/reschedule ask egui for one frame instead of two (egui adds a second frame to every zero-delay repaint request). A command sent through the control port now costs 2.2 frames instead of 3.2.

  • ashlar-app: the control port no longer asks for a frame after answering a request; the frame that serves it runs before the view draws and already shows the change. An engine command sent through the port now costs about 1.3 frames (was 2.2). Driver::request wakes for the control reason as the port does.

  • ashlar-update: the check asks for this platform’s build (?platform=macos|windows-x64|linux-x86_64|linux-aarch64, PLATFORM, latest_url_for), so a Windows or Linux app is never offered a Mac download.

Fixed

  • Linux: copied text and images no longer vanish when the copy call returns; one clipboard lives on a worker thread for the life of the process.

  • ashlar-ui lists: scroll_to read the scroll offset under the wrong id, so it never scrolled up to a row above the view and moved the list for a row already in view. It now reads the scroll area’s own id, as the fling does.

  • ashlar-ui accessibility: lists, tables and trees take a .label(text) that screen readers announce; table cells are Cell nodes and the header row holds ColumnHeader nodes (with sort direction); resize handles, tree disclosure arrows and the fling drag surface no longer show up as unnamed nodes. The keymap editor’s table, its actions column and its shortcut and clear buttons have names (new KeymapLabels::table and actions, English and German).

  • ashlar-ui accessibility: search fields and the command palette input have a name, not only a placeholder (SearchField::label, new UiLabels::search with English and German); banners announce their text once instead of as both name and value.

  • ashlar-ui devtools: AccessKit that the debugger turned on to answer a request (ui.click, ui.snapshot, a script) goes back off once nothing needs the tree, so a driven app no longer builds a screen reader’s tree on every later frame (a caret-blink frame in the notes app: 272 allocations instead of 652). This happens only where no screen reader can exist (a Driver or --headless run, or a build without the accesskit feature); in a window it stays on, and devtools::set_platform_accesskit sets which applies.

  • Curve::Tween started from rest follows its easing exactly: a linear tween is linear (it used to start at zero velocity, so a 2 s linear 0→100 tween read ≈12 at 0.525 s instead of 26.25). Retargets mid-motion still keep velocity continuous.

  • Telemetry: a run whose shutdown wait ran out no longer takes back its own unsent session folder (and other leftover ones) as its worker finishes, so a courier delivers them instead of seeing them as in use until the process exits.

  • notes example: a click on a note’s title in the list selects the note (the title was selectable text that took the click).

  • native-menu: picks in the OS menu bar no longer run while a dialog (such as “Save changes?”) or the command palette is open.

  • ⌘Z / Ctrl+Z in a text field undoes the text, not the document. Text-editing shortcuts (undo, redo, select all, copy, cut, paste) are left to a focused text field (works_while_typing); with native-menu they have no OS key equivalent, and menu picks of commands bound to them are skipped while typing.

  • native-menu on Windows: the menu bar is attached to the window; until it is (or if that fails) the in-window bar shows, so the app never ends up without a menu.

  • native-menu on macOS: Quit is in the application menu, and a Window menu has Minimize, Zoom and Bring All to Front.

  • ashlar-debug: trace start <dir> inside --port P batch - (or --app NAME) recorded nothing, since the trace was kept only in a session file. The trace now lives in that batch run; outside a batch, trace with --port/--app is a usage error instead of doing nothing.

  • docs/control-protocol.md: the MCP tools table listed screenshot {id?} and left out the debugger tools; it now lists every tool with its arguments, and a test keeps it in step with the code.

  • ashlar-app: a focused text field no longer keeps an idle app drawing: egui’s caret blink cost about 6 frames a second for as long as the field had focus. The runtime blinks the caret itself, one frame per phase change credited to the new caret wake reason, and leaves it solid after 10 s without input (ashlar_app::caret); input starts it again. 30 s idle with a focused field: 194 frames before, 21 after.

  • ashlar-ui: a value field’s typed entry lost what was typed in the frame its editor opened (a value typed straight after the click, as the control channel does), and while its window did not have the operating system’s focus (an app driven without being activated) the editor selected all its text again every frame, so each entry replaced the last. The editor takes the keyboard before it is drawn, and keyboard handling here asks egui’s focus alone (edit::has_keyboard): value fields, sliders, trees, swatches, resize handles, virtual lists and the search field. A person typing in a focused window was not affected, except that an editor left open while they switched windows had its text selected again when they came back.

  • ashlar-ui: ui.type / ui.fill with a target no longer click a text field that has the keyboard already: two requests in a row made a double click, which selected a word for the next text to replace.

  • ashlar-assistant: a saved chat reopened after a relaunch could be refused (“Invalid signature in thinking block … bound to a different conversation”): its thinking blocks were replayed even when the system prompt, the tools or the saved messages (results cut, pictures left out) differed from the request that produced them. Each Anthropic reply now records a fingerprint of its prefix (Native::binding, anthropic::request_parts, anthropic::bind), and thinking goes back only while it matches; chats saved before this replay none. A refusal anyway drops all earlier thinking (Conversation::forget_thinking) and the step is asked once more.

  • ashlar-assistant: OpenAI-format requests carry at most anthropic::MAX_IMAGES pictures (the newest), like Anthropic ones; they carried every picture of the chat.

  • ashlar-ui: idle::FrameRate::fps counts the frames of the last second (two frames 11 ms apart on an idle app read 91 fps); idle::Activity::of documents that an unfocused window is the background signal that arrives (winit rarely reports a covered one).

  • ashlar-native: memory is listed as an FFI module (the deny-panics check refused it); it no longer calls macOS’s malloc_zone_pressure_relief, which released next to nothing on macOS 26, so release_free_memory is a no-op there; memory::space_efficient_malloc() tells whether the process runs with MallocSpaceEfficient=1, which hands freed memory back at once.

Added

  • ashlar-ui: toasts v2. Each toast is an AccessKit live region (polite; assertive for errors), so screen readers announce it. New on Toast: title, action(label, command) (up to three buttons, reachable as toast.<id>.action.<n>; clicks arrive from Toasts::take_actions or show_actions), key (pushing a toast with a queued toast’s key updates it in place), and progress; Toasts::progress(key, text, fraction) keeps a progress toast up to date and dismiss_key removes it. Escape dismisses the focused or newest toast (not while a field or a popup has it), keyboard focus pauses a toast’s timer, close and action buttons show a focus ring, the close target is a full control high, toasts past max_visible show as “+N more”, and the words come from ToastLabels (English by default). Toast gained public fields, so code that builds one with a struct literal must add them (Toast::new is unchanged).
  • ashlar-trace-proto, ashlar-trace, ashlar-trace-server: telemetry counts frames over the 120 Hz budget. DISPLAY_BUDGET_US (8.333 ms, already a bucket bound) and the optional FrameStats::over_budget are new within schema 1: older clients leave the field out, older servers ignore it, and FrameStats::over_budget_frames counts it from the buckets when it is missing. The SDK sends it and shows it in its local report; the server counts it from the stored buckets (no new column) and shows “Over 120 Hz budget” on the performance page and “Frames over 8.3 ms” on issue pages.
  • ashlar-mcp: app_stats {reset?} and app_logs {limit?, level?} tools over the runtime’s app.stats and app.logs, listed only when the app reports those methods.
  • ashlar-assistant: HistoryStore::offload_images keeps only a chat’s newest pictures in memory (older ones stay in its history folder as file references, which saving keeps, loading reads back and requests leave out); HistoryStore::read_image and Image::is_offloaded for showing them.
  • ashlar-telemetry: TelemetryConfig::build: a build identifier (such as the commit a release was packaged from) sent with every report, so a server can tell release sessions from developer ones.
  • ashlar-assistant: Conversation::memory(): the bytes a chat holds (pictures dominate) and how many pictures, for memory readouts.
  • ashlar-ui: idle: IdleTrim fires once per quiet spell (30 s idle, 10 s in the background) so an app can trim its caches, MemoryReport (named cache sizes plus the process footprint for perf.stats), texture_memory, and FrameRate, a frame rate that reads 0 while no frames run; wake::Reason::IDLE.
  • ashlar-native: display (feature, on by default): check() says when this process cannot show a window at all (Windows: a service’s window station, as in session 0), where wgpu would panic configuring the window’s surface. ashlar-app checks it before opening its window and returns Error::NoDesktop instead of crashing.
  • ashlar-native: memory (feature, on by default): process_memory() reads the process’s footprint and resident size the way the OS’s tools do; release_free_memory() returns the allocator’s free pages to the system (macOS keeps freed regions dirty otherwise).
  • ashlar-assistant: saved chats (history: HistoryStore, HistorySaver; versioned, crash-safe, pictures in their own files), @ mentions (mention, Block::Mention), / commands with saved prompts (slash), and /compact summaries and chat titles (compact).
  • ashlar-assistant: skills (Skills: skills listed by name and description in the system prompt, loaded with the load_skill / read_skill_file tools, picked as /name; from docs categories, skill folders and integrations), SlashAction::Skill and SlashAction::External, and feature integrations (integration: an integration’s skills and MCP prompts as commands).
  • ashlar-agents: skills::parse and skills::load_dir read SKILL.md skill folders back from disk.
  • ashlar-integrations: MCP prompts (Integrations::prompts, get_prompt) and resources (skill_resources, read_resource), and skill folders in a bundle (manifest skills, skill_dirs).
  • ashlar-ui: markdown, Markdown for chat replies (headings, lists, tables, code blocks with Copy, inline styles and links), safe for text that is still streaming in.

Security

  • ashlar-trace-server and the trace examples use rusqlite 0.41 (bundled SQLite from libsqlite3-sys 0.39), fixing RUSTSEC-2026-0459 (CVE-2026-119375, stale memory read through a malicious database and -wal file pair); the yanked async-executor 1.14.0 is replaced by 1.14.1.

0.1.0 - 2026-10-27

First public release, on crates.io.

Added

  • The umbrella crate, published as ashlar-rs (imported as ashlar), and the ashlar-* crates: app runtime, UI components on egui, documents with undo and autosave, commands, background jobs, files and settings, logging and crash safety, native OS features, the agent control port, CLI, MCP (stdio and HTTP), agent setup, an in-app assistant harness, integrations, UI debugging, agent docs, update checks, printing, string obfuscation, and self-hosted telemetry.
  • Examples: notes, gallery, and the telemetry demos.
  • A release workflow that publishes to crates.io only from a version tag on main, after the full CI gate passes on that commit.

Changed

  • HTTPS in ashlar-telemetry, ashlar-update, ashlar-assistant and ashlar-integrations uses rustls with the ring provider, replacing the unmaintained rustls-rustcrypto.

Fixed

  • ashlar-trace-server sends Connection: close with a refusal made before the request body is read (bad key, rate limit, storage full) and with every dashboard form response, so a client never reuses a connection the server is about to drop.
  • ashlar-trace on Windows reads each spooled file’s size from the file itself, not the folder listing, which can show a stale size for a file still being written; a segment that looked empty was deleted.
  • ashlar-trace keeps the count of a batch the server refuses while the tracer shuts down, so the next process reports it instead of the count being lost.