A lightweight desktop sticker application for notes, timers, command output, and file previews. Built with GPUI, powered by SQLite.
- Overview
- Project Structure
- Build Configuration
- Module Architecture
- Data Flow
- Database Schema
- Sticker Types
- Platform Support
- Key Design Patterns
- Development Guide
Rustickers is a cross-platform desktop application that allows users to create persistent "stickers" on their desktop. Each sticker is a small window that can display notes, timers, command output, file previews, or freehand drawings.
- Markdown Notes: Quick editing with preview mode and Ctrl+S save
- Timers: Countdown timers with audible alerts
- Command Stickers: Pin command output with optional cron scheduling
- File/URL Preview: Preview files, folders, or URLs
- Paint Stickers: Freehand drawing space
- UI Framework: GPUI (Zed editor's UI framework)
- WebView: WRY (Webview Runtime) for HTML/PDF/URL rendering
- Database: SQLite with SQLx
- IPC: interprocess crate for single-instance enforcement
- Hotkeys: rdev for global hotkey listening
- Logging: tracing with daily rotation
rustickers/
├── .cargo/
│ └── config.toml # Platform-specific build config (static CRT on Windows)
├── .github/workflows/
│ └── release.yml # CI/CD for release builds
├── .vscode/
│ └── settings.json # Editor settings
├── assets/
│ ├── icons/ # 31 SVG icons for UI
│ ├── icon.ico, icon.png # Application icons
│ └── design.pptx # Design documents
├── migrations/ # SQLx database migrations
│ ├── 0001_init.sql # Initial schema
│ ├── 0002_add_top_most.sql
│ └── 0003_add_display_id.sql
├── packaging/
│ └── linux/ # Linux desktop integration (.desktop, .service files)
├── screenshots/ # Application screenshots
├── src/
│ ├── lib.rs # Library root, module declarations
│ ├── rustickers.rs # GUI application entry point
│ ├── rusticker.rs # CLI application entry point
│ ├── ipc.rs # Inter-process communication
│ ├── cli/ # CLI commands
│ ├── model/ # Data models
│ ├── native/ # GPUI native code
│ ├── storage/ # Persistence layer
│ └── utils/ # Utility functions
├── build.rs # Windows resource compilation (icon embedding)
├── Cargo.toml # Project manifest
└── Cargo.lock # Locked dependencies
[features]
default = ["ui"]
ui = [
"dep:gpui", "dep:gpui_platform", "dep:gpui-component", "dep:gpui-wry",
"dep:wry", "dep:sqlx", "dep:rdev", "dep:notify", "dep:rodio", ...
]
cli = ["dep:clap", "dep:futures", "dep:sqlx"]| Binary | Path | Features | Description |
|---|---|---|---|
rustickers |
src/rustickers.rs |
ui |
Full GUI application |
rusticker |
src/rusticker.rs |
cli |
CLI-only tool |
[profile.release]
strip = true # Strip symbols
opt-level = "z" # Optimize for size
lto = true # Link Time Optimization
codegen-units = 1 # Maximum optimization
panic = "abort" # No stack unwinding┌─────────────────────────────────────────────────────────────┐
│ rustickers.rs │
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ SingleInstance │ │ HotkeyListener │ │ IPC Server │ │
│ │ (lock acquire) │ │ (Ctrl+Alt+R) │ │ (commands) │ │
│ └────────────────┘ └─────────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ run_native() │
│ ┌──────────────┐ │
│ │ MainWindow │ │
│ │ StickerWindow│ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
Startup Flow:
- Initialize
AppPaths(data directory resolution) - Setup logging with daily rotation
- Acquire single-instance lock
- Start IPC server thread
- Start global hotkey listener
- Run GPUI native loop
fn main() {
let app_paths = AppPaths::new();
let cli = Cli::parse_from(normalize_argv_for_view_alias());
cli::run(cli, &app_paths);
}Special Handling: Single file path/URL argument is automatically aliased to view <source>. Trailing flags (like --width, --height, --color) are forwarded to the view command.
Purpose: Single-instance enforcement and inter-process communication.
Uses platform-specific named sockets:
- Windows: Named Pipes (
GenericNamespaced) - Unix/macOS: Unix domain sockets (
GenericFilePath)
pub struct SingleInstance {
listener: Option<interprocess::local_socket::Listener>,
}Corpse Socket Handling: On Unix systems, stale socket files are automatically cleaned up on retry.
| Command | Format | Description |
|---|---|---|
Show |
SHOW |
Activate main window |
ToggleFilePreview |
TOGGLE_FILE_PREVIEW |
Quick file preview from selection |
OpenSticker |
OPEN_STICKER <id> |
Open closed sticker by ID |
PreviewFile |
PREVIEW_FILE <json> |
Create file/URL preview sticker. JSON payload: {"source":"...","width":400,"height":300,"color":"yellow"} |
CloseSticker |
CLOSE_STICKER <id> |
Close sticker by ID |
pub enum StickerType {
Markdown, // Text notes with markdown
Timer, // Countdown timer
Command, // Shell command output
Paint, // Freehand drawing
File, // File/URL preview
}
pub enum StickerState {
Open, // Visible on desktop
Close, // Hidden but persisted
}
pub enum StickerColor {
Yellow, Green, Blue, Pink, Gray
}StickerBrief - List view summary:
pub struct StickerBrief {
pub id: i64,
pub title: String,
pub state: StickerState,
pub color: StickerColor,
pub sticker_type: StickerType,
pub created_at: i64,
pub updated_at: i64,
}StickerDetail - Full sticker data:
pub struct StickerDetail {
pub id: i64,
pub title: String,
pub state: StickerState,
pub left: i32, // Window position
pub top: i32,
pub width: i32, // Window size
pub height: i32,
pub top_most: bool, // Always on top
pub color: StickerColor,
pub sticker_type: StickerType,
pub content: String, // JSON-encoded type-specific content
pub created_at: i64,
pub updated_at: i64,
pub display_id: Option<u32>, // Multi-monitor support
}CommandContent:
pub struct CommandContent {
pub command: String,
pub environments: String, // KEY=VALUE pairs
pub working_dir: String,
pub scheduler: Option<Scheduler>, // Cron or interval
pub run_immediately: bool,
pub result: CommandResult,
pub stream_result: bool,
}FileStickerContent:
pub struct FileStickerContent {
pub sources: Vec<String>, // File paths or URLs
}#[async_trait::async_trait]
pub trait StickerStore: Send + Sync {
async fn insert_sticker(&self, sticker: StickerDetail) -> anyhow::Result<i64>;
async fn delete_sticker(&self, id: i64) -> anyhow::Result<()>;
async fn get_sticker(&self, id: i64) -> anyhow::Result<StickerDetail>;
async fn update_sticker_color(&self, id: i64, color: String) -> anyhow::Result<()>;
async fn update_sticker_title(&self, id: i64, title: String) -> anyhow::Result<()>;
async fn update_sticker_bounds(&self, id: i64, left: i32, top: i32, width: i32, height: i32, display_id: Option<u32>) -> anyhow::Result<()>;
async fn update_sticker_content(&self, id: i64, content: String) -> anyhow::Result<()>;
async fn update_sticker_state(&self, id: i64, state: StickerState) -> anyhow::Result<()>;
async fn update_sticker_top_most(&self, id: i64, top_most: bool) -> anyhow::Result<()>;
async fn query_stickers(&self, search: Option<String>, order_by: StickerOrderBy, limit: i64, offset: i64) -> anyhow::Result<Vec<StickerBrief>>;
async fn count_stickers(&self, search: Option<String>) -> anyhow::Result<i64>;
async fn get_open_sticker_ids(&self) -> anyhow::Result<Vec<i64>>;
async fn list_stickers(&self, state: Option<StickerState>, search: Option<String>) -> anyhow::Result<Vec<StickerListItem>>;
}- Uses SQLx with compile-time query verification
- Connection pooling via
Arc<SqlitePool> - Migrations applied on first open
Cross-platform data directory resolution:
| Platform | Path |
|---|---|
| Windows | %LOCALAPPDATA%\rustickers\data\ |
| macOS | ~/Library/Application Support/rustickers/data/ |
| Linux | ~/.local/share/rustickers/data/ |
GPUI application initialization:
pub fn run_native(
app_paths: AppPaths,
ipc_events_rx: mpsc::Receiver<IpcEvent>,
sticker_events_tx: mpsc::Sender<StickerWindowEvent>,
sticker_events_rx: mpsc::Receiver<StickerWindowEvent>,
)Initialization Steps:
- Create GPUI Application with platform backend
- Set dark theme
- Spawn IPC event pump (20ms polling)
- Open SQLite store
- Restore open stickers from database
- Open main window
MainWindow - Sticker list/manager:
- Search and filter stickers
- Sort by created/updated date
- Create new stickers via dropdown menu
- Double-click to open, delete button to remove
StickerWindow - Individual sticker:
- Bounds persistence (debounced 200ms)
- Color picker footer
- Title editing with Enter to save
- Type-specific view component
Every live window is tracked in OPEN_STICKERS (sticker/open.rs) under an open id: the database
id for a saved sticker, a hash of the previewed sources for an unsaved file preview, and a large
positive id for the throwaway window a selection run opens. Opening an id that already has a window
raises that window instead of creating a second one, so any number of previews of different files
can be open at once while previewing the same files twice is idempotent. New previews are centred
on the primary display and then cascaded 32px at a time until they no longer sit exactly on top of
an open sticker.
Embedded Assets:
#[derive(RustEmbed)]
#[folder = "./assets"]
#[include = "icons/**/*.svg"]
pub struct Assets;IconName Enum: 31 predefined icons (play, pause, close, search, etc.)
| Sticker | File | Features |
|---|---|---|
| Markdown | markdown.rs |
Edit/preview modes, syntax highlighting, Ctrl+S save |
| Timer | timer.rs |
Countdown, audible alert, pause/reset |
| Command | command/mod.rs |
Process execution, cron scheduling, env vars, streaming output |
| Paint | paint.rs |
Freehand drawing, eraser, color selection |
| File | file/mod.rs |
Multi-file preview, file watching, type-specific renderers |
Command Sticker Sub-components:
runner.rs- Resolving, spawning and reaping the child process. No GPUI, so the headless half reuses itschedule.rs- Cron parsing, next fire time, cancellation token. No GPUIactivity.rs- Process-wide registry of which command stickers are running or scheduledbackground.rs- Supervisor that runs armed schedules while their windows are closedexecution.rs- The window's own run/schedule/stop behaviour and output streamingform.rs- Settings formoutput.rs- Result rendering, footer controls and the running overlay
File Sticker Sub-components:
preview.rs- File type detection and routingeditor.rs- Text/code editor with syntax highlightingaudio.rs- Audio player with metadata displaywatcher.rs- File system change notificationssummary.rs- File/folder summary display
WRY-based WebView for rendering:
- HTML content
- PDF files
- URLs (via local:// protocol)
Platform-specific global hotkey listeners: a keycode-only low-level hook on Windows, a Quartz event tap on macOS, and rdev on Linux. Recognized key chords are debounced, and action keys are consumed on Windows and macOS so they are not also handled by the active application. GPUI window-activation observers identify the exact focused transient window that owns Esc; the native event is consumed and an internal GPUI Esc is dispatched so existing save and cleanup handlers still run.
| Hotkey | Action |
|---|---|
Ctrl + Alt + R |
Show main window |
Ctrl/Cmd + Alt |
Toggle file preview from selection |
Ctrl/Cmd + Space |
Open a command for the selected text |
Platform-specific modifiers:
- Windows/Linux: Ctrl required
- macOS: Cmd or Ctrl accepted
Extracts selected files from:
- Windows: Explorer windows (including tabs)
- macOS: Finder selection via AppleScript
Reqwest wrapped for GPUI's HttpClient trait.
The module has two halves: one file per subcommand under commands/, and shared infrastructure
alongside it. A command module owns both its arguments and its behaviour, so adding a subcommand
touches one new file plus two lines in mod.rs.
| Command | File | Description |
|---|---|---|
list |
list.rs |
Find stickers by state, type or text |
show |
show.rs |
Everything stored about one sticker |
result |
result.rs |
What a command sticker last produced |
open / close |
window.rs |
Show or hide a sticker's window |
delete |
delete.rs |
Remove a sticker permanently |
view |
view.rs |
Create a file/URL preview sticker |
markdown |
markdown.rs |
Create a markdown note sticker |
cmd |
cmd.rs |
Create a command sticker |
skill |
skill.rs |
Catalogue of worked examples |
| File | Responsibility |
|---|---|
output.rs |
The human/JSON split. Format renders a report or emits one JSON object with an ok field. Also the table and truncation helpers. |
runtime.rs |
Opening the store and talking to the running app. Delivery distinguishes "sent over IPC" from "the app is not running". |
draft.rs |
The single sticker-creation path: the shared --width/--height/--left/--top/--color/--top-most/--closed group, insertion, and the "created" report. |
shell.rs |
--shell wrapping and fail-fast validation of a command string, its program and its cron expression. |
skills.rs |
The static skill catalogue and the expansion of a skill into real rusticker arguments. |
pub fn run(cli: Cli, app_paths: &AppPaths) -> anyhow::Result<()> {
let format = cli.format(); // --json is a global flag, accepted anywhere
match cli.command {
Commands::List(args) => commands::list::run(app_paths, args, format),
Commands::Result(args) => commands::result::run(app_paths, args, format),
Commands::Cmd(args) => commands::cmd::run(app_paths, args, format),
// ...one arm per module, each taking its own Args struct
}
}skill run expands a catalogue entry into an argument vector, re-parses it through
Cli::try_parse_from and recurses into run. That is what keeps skill show's "equivalent
command" honest: it is the command that actually executes, and a unit test asserts every skill in
the catalogue parses.
A command sticker does not use a shell. The stored string is split with Windows argv rules and
the program is resolved on PATH. --shell wraps it in pwsh -NoProfile -Command … — not
cmd /c, because the string is later re-escaped by std::process::Command and cmd.exe cannot
recover an embedded ", silently mis-splitting something like git log --format="%h %s".
started_at is what arms a command sticker. Without it the window opens and sits idle. The CLI
sets it on creation unless --idle or --accept-selection is given — a selection sticker is
triggered by the hotkey, which sets its own start time.
An armed cron schedule keeps running with the sticker closed. command/background.rs polls the
store for stickers that are armed and have a cron expression, fires the ones that are due, and
writes the output back into the sticker's content. It does not care about StickerState, which is
what makes a closed sticker keep ticking; rusticker result <id> then reads the latest run.
The open window still drives its own schedule, because only it can stream output into the view. The
two are kept apart by command/activity.rs: a sticker window claims its sticker for as long as it
lives, and the supervisor skips anything claimed. The claim is released when the sticker entity is
dropped, so the schedule loop holds a weak handle — a strong one would outlive the window and
lock the supervisor out permanently. The same registry is what the main window's list reads to draw
a spinner on a running sticker and the next fire time on a scheduled one; it polls a generation
counter on the event pump it already runs rather than taking another channel.
pub struct LoggingGuards {
_file_guard: tracing_appender::non_blocking::WorkerGuard,
_error_guard: Option<tracing_error::ErrorLayer>,
}Configuration:
- Daily rotation
- Environment variables:
RUSTICKERS_LOG>RUST_LOG> default - Debug builds:
trace - Release builds:
info
File type detection by extension:
- Images: png, jpg, jpeg, gif, webp, svg, ico, bmp
- Video: mp4, webm, mkv, avi, mov, flv, wmv
- Audio: mp3, wav, ogg, flac, aac, m4a
- Code: rs, py, js, ts, go, java, cpp, c, h, cs, php, rb, sh
- Documents: md, txt, json, yaml, toml, xml, html, css
- URL detection regex
local://protocol helper for WebView
- Unix timestamp in milliseconds
- Human-readable formatting
┌─────────────────────────────────────────────────────────────────┐
│ rustickers.rs │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ SingleInstance│ │ HotkeyListener│ │ IPC Server Thread │ │
│ └──────┬──────┘ └──────┬───────┘ └───────────┬────────────┘ │
│ │ │ │ │
│ └────────────────┴───────────────────────┘ │
│ │ │
│ IpcEvent Channel │
│ │ │
│ ┌────────────────▼────────────────┐ │
│ │ run_native() │ │
│ │ ┌──────────┐ ┌────────────┐ │ │
│ │ │ MainWindow│ │StickerWindow│ │ │
│ │ └────┬─────┘ └─────┬──────┘ │ │
│ │ │ │ │ │
│ │ └──────┬───────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ StickerStore │ │ │
│ │ │ (SqliteStore) │ │ │
│ │ └────────┬────────┘ │ │
│ └─────────────┼──────────────────┘ │
└───────────────────────┼─────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ stickers.db │
│ (SQLite) │
└─────────────────┘
- IPC Events (
mpsc::Sender<IpcEvent>): External commands from CLI or second instance - Sticker Events (
mpsc::Sender<StickerWindowEvent>): Internal state sync between windows
pub enum StickerWindowEvent {
Created { id: i64 },
TitleChanged { id: i64, title: String },
ColorChanged { id: i64, color: StickerColor },
Closed { id: i64 },
}CREATE TABLE IF NOT EXISTS stickers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
state TEXT NOT NULL,
left INTEGER NOT NULL,
top INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
color TEXT NOT NULL,
type TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stickers_created_at ON stickers(created_at);
CREATE INDEX IF NOT EXISTS idx_stickers_updated_at ON stickers(updated_at);
CREATE INDEX IF NOT EXISTS idx_stickers_title ON stickers(title);ALTER TABLE stickers ADD COLUMN top_most INTEGER NOT NULL DEFAULT 0;ALTER TABLE stickers ADD COLUMN display_id INTEGER;- Content: Raw markdown text
- Features: Edit/preview toggle, syntax highlighting, Ctrl+S save
- Default Size: 400x300
- Content: JSON with duration, remaining time, running state
- Features: Countdown, pause/resume, audible alert
- Default Size: 200x100
- Content:
CommandContentJSON - Features: Process execution, cron scheduling (also while closed), environment variables, working directory, streaming output
- Default Size: 400x300
- Content: Serialized drawing data
- Features: Freehand drawing, eraser, color selection
- Default Size: 300x300
- Content:
FileStickerContentJSON with sources array - Features: Multi-file preview, file watching, type-specific renderers (image, video, audio, text, PDF)
- Default Size: Varies by content
| Platform | Status | Notes |
|---|---|---|
| Windows | Full | Static CRT linking, icon embedding, Explorer integration, Named Pipes IPC |
| macOS | Full | Finder integration via AppleScript, Cmd+Alt hotkeys, .app bundle |
| Linux | Partial | GTK/Webkit dependencies, .desktop file, .service file provided |
Windows:
#[cfg(target_os = "windows")]
use windows::Win32::UI::Shell::{IShellWindows, IWebBrowserApp, ...};macOS:
#[cfg(target_os = "macos")]
use cocoa::foundation::{NSAutoreleasePool, NSString};
#[cfg(target_os = "macos")]
use objc::{class, msg_send, sel, sel_impl};StickerStore trait allows swapping storage backends without changing business logic.
GPUI-based with Entity<T> for reactive state management. Views re-render automatically on state changes.
mpsc channels for cross-thread communication between:
- IPC server thread → Main thread
- Hotkey listener → Main thread
- Sticker windows → Main thread
Window bounds saved after 200ms of inactivity to avoid excessive writes.
Named socket prevents multiple app instances. Corpse socket cleanup on Unix.
Separate UI and CLI builds from same codebase via Cargo features.
Sticker content stored as JSON strings, allowing type-specific data without schema changes.
-
Define content structure in
src/model/content.rs:pub struct NewStickerContent { // fields }
-
Add to StickerType enum in
src/model/sticker.rs:pub enum StickerType { // ... NewType, }
-
Create sticker component in
src/native/components/stickers/new_type.rs:pub struct NewTypeSticker { /* ... */ } impl Sticker for NewTypeSticker { /* ... */ } impl StickerView for NewTypeSticker { /* ... */ }
-
Register in StickerWindow::create_sticker_view():
StickerType::NewType => Box::new(StickerViewEntity::new(cx.new(|cx| { NewTypeSticker::new(id, color, store, content, window, cx, sticker_events_tx) }))),
-
Add migration if new database fields needed:
sqlx migrate add add_new_type_fields
-
Add icon to
assets/icons/andIconNameenum
-
Create the module
src/cli/commands/new_command.rs. It owns its arguments as well as its behaviour, and must handle both output modes:#[derive(clap::Args, Debug)] pub struct NewCommandArgs { /// Doc comments become the help text, so write them for a reader #[arg(long, value_name = "TEXT")] pub option: String, // Creating a sticker? Flatten the shared appearance group instead of re-declaring it. #[command(flatten)] pub geometry: Geometry, } pub fn run(app_paths: &AppPaths, args: NewCommandArgs, format: Format) -> anyhow::Result<()> { format.emit(json!({ "option": args.option }), |_| { println!("did the thing"); }) }
-
Declare it in
src/cli/commands/mod.rs, and add a variant plus a dispatch arm insrc/cli/mod.rs. Nothing else needs updating: therusticker <file>shorthand and the help text both read the subcommand list from the parser. -
Consider a skill. If the command has a use worth demonstrating, add an entry to
SKILLSinsrc/cli/skills.rs. The catalogue tests will check the template declares every variable it uses and expands to something the parser accepts.
Enable verbose logging:
RUSTICKERS_LOG=trace cargo runView logs:
- Windows:
%LOCALAPPDATA%\rustickers\data\logs\ - macOS:
~/Library/Application Support/rustickers/data/logs/ - Linux:
~/.local/share/rustickers/data/logs/
Database inspection:
sqlite3 "$(pwd)/target/debug/data/stickers.db"Send command to running instance:
echo "SHOW" | nc -U /tmp/rustickers-<user>.sock # Unix
echo "SHOW" > \\.\pipe\rustickers-<user> # Windows| Term | Definition |
|---|---|
| Sticker | A persistent desktop window displaying content |
| StickerWindow | GPUI window entity for a single sticker |
| MainWindow | Main application window showing sticker list |
| IpcEvent | Inter-process communication command |
| StickerStore | Trait for sticker persistence |
| AppPaths | Cross-platform data directory resolver |