Appearance
Build your first Wish app (Rust)
A walkthrough from cargo new to a working note-taking program that uses identity, document storage, the local query path — and finishes with two instances syncing peer-to-peer, because sync is the payoff the rest builds toward. No UI; that builds cleanly on top. When you're done, the repo's docs/build-an-app/tauri.md is the next step — the same pattern grown into a cross-platform Tauri app.
What you'll build
A small Rust program that:
- Connects to wish-core
- Asks the Wish Dashboard for an identity grant
- Opens a document store rendering into a SQLite table you query directly
- Writes a
notedocument - Reads notes back with plain SQL
- Syncs notes with a second instance of itself — same identity, zero sync-specific schema work
How it's shaped: main stays a short list of named steps; each step below adds one function and one line to main. By the end main reads top-to-bottom as exactly the six things above. The complete program is at the bottom of the page if you'd rather read the destination first.
Prerequisites
- Rust (stable) and CMake (
just doctorin the repo checks both). - The wish-stack repo — the Rust crates are consumed by
pathtoday (no crates.io publish yet), so this lane assumes you have the repo. - The Wish desktop app running (
just dev wishfrom the repo, or the installed app) — it provides wish-core on~/.wish/core.sockand the Dashboard for the identity grant.
Set up the project
bash
cargo new my-first-wish-app
cd my-first-wish-appCargo.toml — point the two path deps at your wish-stack checkout:
toml
[dependencies]
wish-sdk = { path = "<wish-stack>/wish/core/wish-sdk-rs" }
document-store = { path = "<wish-stack>/wish/core/document-store-rs", default-features = false, features = ["sqlite"] }
rusqlite = { version = "0.31", features = ["bundled"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
ciborium = "0.2"(tokio because the store's API is async; ciborium for CBOR values — documents are CBOR all the way down.)
main is async from the start (the store is an async API), and most steps add a small helper above it. Start src/main.rs with the imports we'll grow into:
rust
use std::sync::{Arc, Mutex};
use wish_sdk::{App, Identity};Step 1 — Connect to wish-core
Two helpers: one to read the instance name and data dir from the command line (we'll use the second argument in Step 3, and both matter in Step 6), and one to open the connection.
rust
/// App name + data dir from argv. Two instances need distinct values:
/// cargo run -> "MyFirstApp" ./data
/// cargo run -- MySecondApp data2 -> its own registration + state
fn parse_args() -> (String, String) {
let mut args = std::env::args().skip(1);
let app_name = args.next().unwrap_or_else(|| "MyFirstApp".into());
let data_dir = args.next().unwrap_or_else(|| "./data".into());
(app_name, data_dir)
}
/// Connect to wish-core (~/.wish/core.sock by default; WISH_SOCKET overrides).
fn connect(app_name: &str) -> Result<App, Box<dyn std::error::Error>> {
Ok(App::connect(app_name)?)
}main calls them, then confirms by listing our identities:
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let names: Vec<_> = app.identity().list()?
.into_iter().filter_map(|i| i.name).collect();
println!("Connected. Identities: {names:?}");
Ok(())
}(cargo warns that data_dir is unused — Step 3 uses it. Each step below shows the whole of main, so just replace main as you go.)
bash
cargo runConnected. Identities: []The empty list is correct — Wish apps don't own identities; they're granted by the user via the Dashboard. Open the Dashboard's Apps page and you'll see MyFirstApp registered, with nothing granted.
(Already ran this before? A previously granted app keeps its grant — you'll see your identity here instead of []. That's persistence, not a bug.)
Step 2 — Request an identity
Fold that inline listing into a helper that returns our identity, asking the Dashboard for one if we don't have it yet:
rust
use ciborium::value::Value;
/// Our own identities, granted by the user via the Dashboard. If none is
/// granted yet, ask for one and wait for the grant.
fn ensure_identity(app: &mut App) -> Result<Identity, Box<dyn std::error::Error>> {
if let Some(id) = app.identity().list()?.into_iter().find(|i| i.privkey) {
return Ok(id);
}
println!("No identity granted yet — approve the request in the Wish Dashboard.");
app.rpc().request::<Value>(
"admin.request",
Value::Array(vec![Value::Text("identity".into())]),
)?;
// Poll until the grant lands. (Real apps subscribe to the `signals`
// stream instead of polling.)
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
if let Some(id) = app.identity().list()?.into_iter().find(|i| i.privkey) {
return Ok(id);
}
}
}main now:
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let me = ensure_identity(&mut app)?;
println!("Acting as: {}", me.name.as_deref().unwrap_or("(unnamed)"));
Ok(())
}Run it, approve in the Dashboard (Apps → MyFirstApp → Approve):
Acting as: Aliceme.uid is a 32-byte identity hash — the primary key for everything from here on. privkey: true marks your own identities (vs contacts).
Step 3 — Open a document store
The document store is the database. It's content-addressed and signs every write. The store owns the canonical log (every signed write, in its own SQLite file); your app reads from a rendered view that a render handler maintains. The default handler gives every document type its own table:
sql
CREATE TABLE note ( -- table per type, named after the type
hash BLOB PRIMARY KEY, -- document root hash
uid BLOB, -- owning/authoring identity
luid BLOB, -- which of YOUR identities holds this copy
parent BLOB, -- parent document (the built-in hierarchy)
createdAt INTEGER, -- genesis time
updatedAt INTEGER, -- latest edit time
content TEXT NOT NULL -- the full document as JSON
)The standard fields are real columns (sort and filter with plain SQL); your app-specific fields live in content, queried with SQLite's json_extract. When you outgrow this — your own columns, indexes, joins — you write your own render handler; that's the "own your read model" pattern in the Document Store guide. Today the default carries us all the way to sync.
The helper opens both files and returns the store plus a shared handle to the rendered DB (the render handler writes through it; our reads share it):
rust
use document_store::{Document, DocumentStore, Hash, SqlitePersistence, SqliteRenderHandler};
use wish_sdk::signing::WishSigningExt;
/// Our app's database connection, shared by the render handler (writes)
/// and our own reads.
type Db = Arc<tokio::sync::Mutex<rusqlite::Connection>>;
async fn open_store(
data_dir: &str,
app: &Arc<Mutex<App>>,
) -> Result<(Arc<DocumentStore>, Db), Box<dyn std::error::Error>> {
std::fs::create_dir_all(data_dir)?;
let db: Db = Arc::new(tokio::sync::Mutex::new(
rusqlite::Connection::open(format!("{data_dir}/rendered.db"))?,
));
let storage = Arc::new(SqlitePersistence::open(format!("{data_dir}/ds.sqlite")).await?);
let store = Arc::new(
DocumentStore::new(storage)
.with_render_handler(Arc::new(SqliteRenderHandler::new(Arc::clone(&db))))
// Sign with wish-core's keys — unsigned docs are rejected by peers.
.with_wish_signing(Arc::clone(app)),
);
store.register_type("note", Default::default())?;
Ok((store, db))
}Three lines carry the architecture: the render handler turns store writes into your queryable rows; with_wish_signing makes every write a signed envelope (keys never leave wish-core); register_type declares the document type — the table appears on first write.
main wraps app so the store and (later) sync can share it, then opens the store:
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let me = ensure_identity(&mut app)?;
println!("Acting as: {}", me.name.as_deref().unwrap_or("(unnamed)"));
// The App becomes shared from here on — signing (and Step 6's sync)
// both reach wish-core through it.
let app = Arc::new(Mutex::new(app));
let (store, db) = open_store(&data_dir, &app).await?;
Ok(())
}This compiles with unused-variable warnings for store and db — Steps 4 and 5 use them.
Step 4 — Write a note
Documents are CBOR maps; the cbor! macro builds them with JSON-like syntax:
rust
use ciborium::cbor;
/// Write one shared note (`share: self` = sync to my own devices only).
async fn write_note(
store: &DocumentStore,
me: &Identity,
title: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let content = Document::from_value(cbor!({
"uid" => Value::Bytes(me.uid.clone()),
"title" => title,
"body" => "This note syncs.",
"share" => { "self" => true },
})?)?;
let hash = store.add("note", content, me.uid.clone()).await?;
println!("Wrote note: {}…", hex(&hash.as_bytes()[..6]));
Ok(())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}Binary fields need Value::Bytes
A CBOR sharp edge: inside cbor!, a raw Vec<u8> serializes as an array of integers, not as bytes. Wrap every binary value — uid, hashes, "parent" => Value::Bytes(hash.clone()) — explicitly, as the uid line does.
What's going on: uid is the owner (required); share: { self: true } means "syncs to my own other devices, nobody else's" — without a share field the document is local-only and won't even sync between your own devices; the returned hash is the document's content address. The trailing me.uid.clone() on add is the luid — which local identity this write belongs to (for local writes, the same as the owner; streamlining add to derive it is on the roadmap).
main adds one line:
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let me = ensure_identity(&mut app)?;
let app = Arc::new(Mutex::new(app)); // shared from here on
let (store, db) = open_store(&data_dir, &app).await?;
write_note(&store, &me, &format!("Hello from {app_name}")).await?;
Ok(())
}(db is still unused until the next step — one warning remains.)
Step 5 — Read notes back
Plain SQL: standard fields are columns, your fields come out of the JSON.
rust
/// Read note titles from our rendered table.
async fn print_notes(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
let g = db.lock().await;
let mut stmt = g.prepare(
"SELECT json_extract(content, '$.title') FROM note ORDER BY updatedAt DESC",
)?;
let titles: Vec<String> = stmt.query_map([], |r| r.get(0))?.collect::<Result<_, _>>()?;
println!("Notes ({}):", titles.len());
for t in &titles {
println!(" • {t}");
}
Ok(())
}main:
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let me = ensure_identity(&mut app)?;
let app = Arc::new(Mutex::new(app)); // shared from here on
let (store, db) = open_store(&data_dir, &app).await?;
write_note(&store, &me, &format!("Hello from {app_name}")).await?;
print_notes(&db).await?;
Ok(())
}bash
cargo runActing as: Alice
Wrote note: de6fd72e8849…
Notes (1):
• Hello from MyFirstAppRun it again — Notes (2):. You've written signed, content-addressed, sync-ready documents from Rust and read them back with one SQL query.
Step 6 — Sync
Everything so far described one instance. Now the payoff: run a second instance, and the notes converge — through the same render handler you already have. Synced documents arrive exactly like local writes; nothing above changes.
First, the protocol. Peers find each other by protocol name — it's the app-to-app channel sync rides on. That's why this lands in connect: the connection is the attachment point. Replace your connect body:
rust
use wish_sdk::ConnectOptions;
fn connect(app_name: &str) -> Result<App, Box<dyn std::error::Error>> {
Ok(App::connect_with(
app_name,
ConnectOptions::default().with_protocols(["notes"]),
)?)
}main doesn't change for this — every instance now speaks notes and can discover the others. (This is also why Step 1 took an instance name and data dir: two instances need distinct identities on the machine.)
Second, a sync helper. sync_runner::start wires the whole machine: it serves your documents to peers (respecting every share policy — that's why share: { self: true } mattered), pulls theirs, and drives both from peer online/offline events. The one genuinely app-specific input is get_roots — your answer, in SQL, to "which document trees do I want pulled". Note it finally uses that luid column from Step 3.
rust
use std::sync::atomic::{AtomicBool, Ordering};
use wish_sdk::{sync_runner, SyncRunner, SyncRunnerOptions};
/// Start peer-to-peer sync. Returns the runner plus a flag the run loop
/// watches: `get_roots` names which trees we want pulled (our notes);
/// `on_sync` flips the flag when something new arrives.
fn start_sync(
app: &Arc<Mutex<App>>,
store: &Arc<DocumentStore>,
db: &Db,
) -> (SyncRunner, Arc<AtomicBool>) {
let arrived = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&arrived);
let roots_db = Arc::clone(db);
let runner = sync_runner::start(
Arc::clone(app),
"notes",
Arc::clone(store),
SyncRunnerOptions {
on_sync: Some(Arc::new(move |injected| {
if injected {
flag.store(true, Ordering::Relaxed);
}
})),
on_peer_online: Some(Arc::new(|peer, _rpc| {
println!("Peer online: {}…", &hex(&peer.ruid)[..8]);
})),
..SyncRunnerOptions::new(Arc::new(move |luid| {
// Runs inside a pull task — try_lock, never block.
let Ok(db) = roots_db.try_lock() else { return Vec::new() };
let Ok(mut stmt) = db.prepare("SELECT hash FROM note WHERE luid = ?1") else {
return Vec::new(); // first run: no table yet
};
stmt.query_map([luid], |r| r.get::<_, Vec<u8>>(0))
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
.unwrap_or_default()
.into_iter()
.filter_map(|h| Some(Hash::from(<[u8; 32]>::try_from(h).ok()?)))
.collect()
}))
},
);
(runner, arrived)
}Third, stay alive. Sync only works while the app runs — a loop that re-lists whenever something arrives:
rust
/// Stay alive so sync keeps running; re-list whenever notes arrive.
async fn run_until_quit(
db: &Db,
me: &Identity,
runner: &SyncRunner,
arrived: &Arc<AtomicBool>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Running — syncing with peers (Ctrl-C to quit).");
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if arrived.swap(false, Ordering::Relaxed) {
print_notes(db).await?;
// Trees we just received become pull roots; nudge a pull so
// deeper updates chain in.
runner.trigger_pull(&me.uid);
}
}
}main reaches its final shape — the six steps, top to bottom:
rust
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let me = ensure_identity(&mut app)?;
let app = Arc::new(Mutex::new(app)); // shared from here on
let (store, db) = open_store(&data_dir, &app).await?;
write_note(&store, &me, &format!("Hello from {app_name}")).await?;
print_notes(&db).await?;
let (runner, arrived) = start_sync(&app, &store, &db);
run_until_quit(&db, &me, &runner, &arrived).await
}Note the last line has no ; and no trailing Ok(()) — unlike every step before it. run_until_quit is main's return value now (its loop runs until you Ctrl-C, or returns an error), so it's the tail expression. Adding ; Ok(()) after it would discard that Result and earn an "unused Result" warning — drop the old Ok(()) when you paste this.
Run two instances
Terminal A:
bash
cargo runTerminal B — different name, different data dir, same identity:
bash
cargo run -- MySecondApp data2The Dashboard pops a second request — grant the same identity (sync with share: { self: true } is "my own devices and instances"; both must act as the same person). Then watch both terminals:
Peer online: ed091192…
Notes (2):
• Hello from MySecondApp
• Hello from MyFirstAppBoth instances converge on both notes, in both directions. Kill B, restart A (it writes its note again), start B again — it catches up on reconnect. That's the model: no server, signed envelopes pulled peer-to-peer, rendered into the table you've been querying since Step 3.
If the peers don't see each other
Grant-change signaling has known rough edges. If no Peer online line appears within a few seconds of granting, restart the two app instances — they re-login with the grants in place, which is the reliable path. The kernel and Dashboard can stay up.
To watch sync work, run with WISH_SYNC_LOG=1 — the SDK then prints each discover/pull cycle ([sync] …). It's off by default so your app's own output stays clean.
What just happened
You wrote a Wish app. It connected to wish-core through a Unix socket, requested an identity from the Dashboard (the human gatekeeper for which apps act as you), opened the document store with a rendered SQLite view, wrote signed content-addressed documents, read them back with plain SQL — and then synced them between two instances with one sync_runner::start call and a share policy. No server anywhere. The same program on two machines with the same identity does the same thing over the network.
Optional: peek inside the databases
sqlite3 ships with macOS (Linux: apt install sqlite3). Two files, two worlds:
Your rendered table — standard fields as columns, content as JSON:
bash
sqlite3 ./data/rendered.db '.mode column' \
"SELECT hex(substr(hash,1,6)) AS hash, json_extract(content,'$.title') AS title,
updatedAt FROM note ORDER BY updatedAt;"The store's canonical log — what sync actually exchanges:
bash
sqlite3 ./data/ds.sqlite '.tables'
# _ds_bans _ds_documents _ds_log _ds_trash
sqlite3 ./data/ds.sqlite \
'SELECT count(*) AS changes, hex(substr(hash,1,6)) AS first_hash FROM _ds_log;'Every write is a signed CBOR envelope in _ds_log — the content-addressed history peers pull from. After the two-instance run, the other instance's envelopes are in there too, signature and all. Don't write to either _ds_* table by hand — the log is hash-chained and signed.
What's next
- The Tauri lane —
docs/build-an-app/tauri.mdin the repo: this pattern as a real cross-platform app onwish-app-base(which provides the same default render handler, generic write endpoints, and the frontend bridge so you write much less of the above). - First feature in Reason —
apps/reason/docs/first-feature.md: make a change in the flagship app. - Document Store guide — the database underneath, in depth — including writing your own render handler when you want your own columns, indexes, or a different database entirely.
- Building in Node.js instead? The same tutorial exists for the Node lane.
The whole program, in one block:
rust
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use ciborium::{cbor, value::Value};
use document_store::{Document, DocumentStore, Hash, SqlitePersistence, SqliteRenderHandler};
use wish_sdk::signing::WishSigningExt;
use wish_sdk::{sync_runner, App, ConnectOptions, Identity, SyncRunner, SyncRunnerOptions};
/// Our app's database connection, shared by the render handler (which
/// writes rendered rows) and our own reads.
type Db = Arc<tokio::sync::Mutex<rusqlite::Connection>>;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (app_name, data_dir) = parse_args();
let mut app = connect(&app_name)?;
let me = ensure_identity(&mut app)?;
let app = Arc::new(Mutex::new(app)); // shared from here on
let (store, db) = open_store(&data_dir, &app).await?;
write_note(&store, &me, &format!("Hello from {app_name}")).await?;
print_notes(&db).await?;
let (runner, arrived) = start_sync(&app, &store, &db);
run_until_quit(&db, &me, &runner, &arrived).await
}
/// App name + data dir from argv. Two instances need distinct values:
/// cargo run -> "MyFirstApp" ./data
/// cargo run -- MySecondApp data2 -> its own registration + state
fn parse_args() -> (String, String) {
let mut args = std::env::args().skip(1);
let app_name = args.next().unwrap_or_else(|| "MyFirstApp".into());
let data_dir = args.next().unwrap_or_else(|| "./data".into());
(app_name, data_dir)
}
/// Connect to wish-core. The `notes` protocol is the app-to-app channel
/// peers find each other on — that's what sync rides on.
fn connect(app_name: &str) -> Result<App, Box<dyn std::error::Error>> {
Ok(App::connect_with(
app_name,
ConnectOptions::default().with_protocols(["notes"]),
)?)
}
/// Our own identities, granted by the user via the Dashboard. If none is
/// granted yet, ask for one and wait for the grant.
fn ensure_identity(app: &mut App) -> Result<Identity, Box<dyn std::error::Error>> {
if let Some(id) = app.identity().list()?.into_iter().find(|i| i.privkey) {
return Ok(id);
}
println!("No identity granted yet — approve the request in the Wish Dashboard.");
app.rpc().request::<Value>(
"admin.request",
Value::Array(vec![Value::Text("identity".into())]),
)?;
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
if let Some(id) = app.identity().list()?.into_iter().find(|i| i.privkey) {
return Ok(id);
}
}
}
/// Open the document store: canonical log in ds.sqlite, our rendered view
/// in rendered.db (the default handler gives each type its own table).
async fn open_store(
data_dir: &str,
app: &Arc<Mutex<App>>,
) -> Result<(Arc<DocumentStore>, Db), Box<dyn std::error::Error>> {
std::fs::create_dir_all(data_dir)?;
let db: Db = Arc::new(tokio::sync::Mutex::new(
rusqlite::Connection::open(format!("{data_dir}/rendered.db"))?,
));
let storage = Arc::new(SqlitePersistence::open(format!("{data_dir}/ds.sqlite")).await?);
let store = Arc::new(
DocumentStore::new(storage)
.with_render_handler(Arc::new(SqliteRenderHandler::new(Arc::clone(&db))))
.with_wish_signing(Arc::clone(app)),
);
store.register_type("note", Default::default())?;
Ok((store, db))
}
/// Write one shared note (`share: self` = sync to my own devices only).
async fn write_note(
store: &DocumentStore,
me: &Identity,
title: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let content = Document::from_value(cbor!({
"uid" => Value::Bytes(me.uid.clone()),
"title" => title,
"body" => "This note syncs.",
"share" => { "self" => true },
})?)?;
let hash = store.add("note", content, me.uid.clone()).await?;
println!("Wrote note: {}…", hex(&hash.as_bytes()[..6]));
Ok(())
}
/// Read note titles from our rendered table — plain SQL.
async fn print_notes(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
let g = db.lock().await;
let mut stmt = g.prepare(
"SELECT json_extract(content, '$.title') FROM note ORDER BY updatedAt DESC",
)?;
let titles: Vec<String> = stmt.query_map([], |r| r.get(0))?.collect::<Result<_, _>>()?;
println!("Notes ({}):", titles.len());
for t in &titles {
println!(" • {t}");
}
Ok(())
}
/// Start peer-to-peer sync. `get_roots` answers "which document trees do I
/// want pulled" (our notes, by local identity); `on_sync` flips a flag
/// when something new arrives. Returns the runner + that flag.
fn start_sync(
app: &Arc<Mutex<App>>,
store: &Arc<DocumentStore>,
db: &Db,
) -> (SyncRunner, Arc<AtomicBool>) {
let arrived = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&arrived);
let roots_db = Arc::clone(db);
let runner = sync_runner::start(
Arc::clone(app),
"notes",
Arc::clone(store),
SyncRunnerOptions {
on_sync: Some(Arc::new(move |injected| {
if injected {
flag.store(true, Ordering::Relaxed);
}
})),
on_peer_online: Some(Arc::new(|peer, _rpc| {
println!("Peer online: {}…", &hex(&peer.ruid)[..8]);
})),
..SyncRunnerOptions::new(Arc::new(move |luid| {
let Ok(db) = roots_db.try_lock() else { return Vec::new() };
let Ok(mut stmt) = db.prepare("SELECT hash FROM note WHERE luid = ?1") else {
return Vec::new(); // first run: no table yet
};
stmt.query_map([luid], |r| r.get::<_, Vec<u8>>(0))
.and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
.unwrap_or_default()
.into_iter()
.filter_map(|h| Some(Hash::from(<[u8; 32]>::try_from(h).ok()?)))
.collect()
}))
},
);
(runner, arrived)
}
/// Stay alive so sync keeps running; re-list whenever notes arrive.
async fn run_until_quit(
db: &Db,
me: &Identity,
runner: &SyncRunner,
arrived: &Arc<AtomicBool>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Running — syncing with peers (Ctrl-C to quit).");
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if arrived.swap(false, Ordering::Relaxed) {
print_notes(db).await?;
runner.trigger_pull(&me.uid);
}
}
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}