The Rust facade
iceblock-sdk is the crate an app builds on. Its first part is Built: what’s marked so below. The rest is Planned, and the names may still change while the first app tries it.
The aims:
- Small. An app that keeps a Collection and shares it doesn’t meet rosters, Links, levels or relays.
- Steady. The core changes every release; the facade changes only when an app’s needs do.
- Safe to call from a UI’s workers. Calls block, so a UI calls them off its main thread. Whatever waits on the network goes through the core’s queue, so the app’s own calls aren’t held up behind a sync.
- The same whether or not the user has Iceblock. The app calls the same functions alone, in a group, and after joining the user’s network; what changes is what it’s allowed.
Starting Built
let app = Iceblock::start(Config {
data_dir, // the app's own, private
app_id: "com.example.notes".into(), // fixed, every install; on Android, the package name
manifest: Manifest::new([Permission::Devices, Permission::Wake]),
app_name: "Notes".into(), // its Collection's name
owner_name: Some("Sam".into()), // the user's name: groups need it
port: None, // any free one
link_base: None, // the app's own address for invitations
})?;
let status = app.status(); // standing, network name, syncing
app.stop();
The first start makes the device, a network of one, and the app’s Collection; later starts open them again. The app id must be in reverse-domain form, or the app doesn’t start. The manifest says what the app may ask the user’s Iceblock for, and what it requires: Iceblock shows it when the user pairs the app, and won’t take a request for anything else (a Collection needs no listing). Built, in r76: it’s on the wire when an app pairs, so it’s breaking. status().standing is Alone, OwnNetwork { devices } (the app on several of the user’s devices) or App { network } (an app device of the user’s Iceblock). start_with_keystore keeps keys elsewhere, as tests do. Without an owner name, set_owner_name before making or joining a group.
The app’s Collections Built
let notes = app.collection()?; // the app's own
let all = app.collections(); // its own, its groups', those given it
let trip = app.create_collection("Trip")?;
notes.write("2026/10/plan.md", &bytes)?; // a new file, or a new version
notes.write_from("video.mp4", path)?; // from disk, a block at a time
let bytes = notes.read("2026/10/plan.md")?; // fetching what isn't here
notes.read_to("video.mp4", &mut out)?;
notes.read_range("video.mp4", offset, len)?; // only the blocks needed
let entries = notes.list("2026/10"); // folders, then files, one level
let paths = notes.files(); // every file, at every depth
let info = notes.info("plan.md"); // size, time, writer and network, versions, here
notes.rename("a.md", "b.md")?;
notes.delete("b.md")?; notes.restore_deleted("b.md")?;
notes.versions("plan.md")?; notes.read_version("plan.md", 0)?; notes.restore("plan.md", 0)?;
notes.create_folder("2027")?;
notes.sync(); // this Collection, soon
Personal Collections, the user’s data. The app’s own Collection and any it makes with create_collection are personal: its user’s own data, such as an app’s memories of them. They belong to the user’s network, and stay on it: adopted into the user’s Iceblock, the app’s personal Collections come into the user’s network with it; moving the app to another group, taking it out of one, or handing the group to another home moves only the group’s Collections, never its personal ones; removing the app leaves them with the user. A group’s Collections, made with group.new_collection, are the group’s instead. Making a new personal Collection once the app’s in the user’s Iceblock: Built, in r91 (#777). The app device asks the user’s network for it, and the first of their Full-trust devices to see the ask makes it there and gives it to the app’s installs, without asking the user (it’s the app’s own); create_collection returns once it’s made, and errs if none of the user’s devices can be reached.
Paths are inside the Collection: the app never sees how the core names them. A file’s writer is the device that wrote its current version; its network, in a group’s Collection, the network that did, as the group’s home checked it (None: the Collection’s own network).
Watching Built
let mut watch = notes.watch();
let changes = watch.wait(Duration::from_secs(30)); // or watch.changes(), at once
for c in changes { /* c.path, c.kind: Added, Changed or Removed */ }
A watch looks whenever the device has saved something: changes made here and those that arrived from other devices alike. It asks the Collection’s documents what changed since it last looked, and looks up only those files, however many the Collection holds; a Collection linked inside it included, under the Link’s path. It looks at the whole Collection only when that can’t say: when the Links under it change, say. Built, the change feed in r89 (#766).
Settings Built, in r102
let settings = app.settings()?;
settings.set("sort", SettingValue::Text("by date".into()))?; // on every device
settings.set_here("columns", SettingValue::Integer(3))?; // on this one alone
let sort = settings.get("sort"); // Option<SettingValue>
let mut watch = settings.watch();
for c in watch.wait(Duration::from_secs(30)) { /* c.key, c.here */ }
The app’s settings and small state (#801): values under keys that follow the user across the app’s installs, never shown as files. A value is text, a whole number, a number, a flag or bytes; a key up to 200 characters, a value up to 64 KiB, and 1 MiB in all: anything larger belongs in a file. For each key, the later write wins, wherever it was made. A key kept _here is that device’s alone, so each install can keep its own layout. Iceblock keeps them sealed in a hidden store of the app’s own, which its installs and the user’s own devices hold, and nothing else: never the user’s other apps, never a group. The user sees under the app, in My network’s Applications, how much it keeps, and can reset it. Once the app’s in the user’s Iceblock, the first settings() waits for the user’s network to make the store, as create_collection does. In Dart: app.setting(key:, here:), setSetting, removeSetting, settings(here:) and watchSettings().
Conditions and metered connections Built
app.set_conditions(Conditions { metered: Some(true), on_power: Some(false), ..Default::default() });
if app.metered_allowed().is_none() { /* ask the user */ }
app.set_metered_allowed(true)?;
On Android the app passes iceblock_platform’s conditions on as they change. On a metered connection (mobile data, a metered Wi-Fi), the device doesn’t sync until the user allows it, so the app asks them.
Syncing Built
For one change that should go promptly, notes.sync() syncs that Collection alone, soon. app.sync_now() syncs with the network’s other devices and every group’s home, and asks for news of everything held together; app.sync_and_wait(timeout) does the same and waits for it, for work in the background. There’s no push: a phone in the background hears at its next background run.
The user’s other devices Built
app.set_events(Arc::new(MyEvents)); // join_asked(&JoinAsk) -> bool
let invite = app.invite_device()?; // a link and token, for a few minutes
other_device.join(&invite.token)?; // on the other device
The app on the user’s phone and laptop as one network, each a device of it with the app’s Collection on both. The device asked calls the app’s Events::join_asked, with the other device’s name and fingerprint for the user to compare; unanswered, it’s declined.
Which device is which Built
let me = app.status().device; // this device's fingerprint
let id = app.status().device_id; // its id, as a file's writer gives it
The SDK names a device by its fingerprint everywhere: status().device for this one, devices(), each install in presence(), pairing (JoinAsk) and the user’s Iceblock. To keep records of the devices it’s on (where the user was last, which one takes over from another), an app keys them by fingerprint, each install noting its own from status().device, and matches presence() and devices() on it directly.
A device’s id (status().device_id, and a file’s FileInfo::writer) is its public signing key in hex, which is long: its key is hybrid. Its fingerprint is the first 16 hex characters of the BLAKE3 of that key’s bytes, so one follows from the other: hex(BLAKE3(hex_decode(device_id)))[..16]; the BLAKE3 of the hex text gives something else. Both last as long as the device’s key, which stays the same through a re-key, and when the app joins the user’s Iceblock or is adopted by it.
Sharing with other people Built
let family = app.create_group("Family")?; // this network its home
let fridge = family.new_collection("Fridge")?; // or add_collection(¬es)
let link = family.invite(Role::Contributor)?;
match other.accept_invitation(&link)? { Joining::Joined(g) => …, Joining::Asked { .. } => … }
family.members(); family.role(); family.collections();
family.home(); // its home's fingerprint
family.synced_secs(); // when this network last heard from it
family.current(&network); // who a succeeded network is now
family.set_role(&network, Role::Member)?; family.remove(&network)?;
group.leave()?;
can_group() says whether this device can (Full trust: not an app device). synced_secs() is when this network last heard from the group’s home, any of its devices (now, at the home), after sync() to be sure; Built, in r79. home() is the fingerprint of the group’s home, which wrote whatever in its Collections has no network of its own. successions() lists the group’s networks succeeded by others, as an app’s own network is when the user’s Iceblock adopts it, each checked against the old network’s signature, and current(network) follows them to the network now: what a succeeded network wrote is its successor’s. Built, in r78 (library 0.13.1). Planned: invitations that need approval, and answering them.
The app in a group Built, in r76: a group the app makes has the app in it; an Owner adds it to another group with group.add_app(), for everyone there. The app’s Collections in a group (those it makes there with new_collection) are only for the members that use it, each with at least the role the manifest asks for (Manifest::needing_group_role(Role::Contributor)); a member that doesn’t use it keeps the group’s other Collections. Each member chooses in Iceblock, or in the app with group.use_app(true, contact); group.uses_app() and group.has_app() say where things stand. group.propose_app_out(None) proposes taking the app out, its Collections staying the group’s; Some(&other) moving it there with them, to a group whose home is this group’s, or one this group’s home is a member of. Either goes once every Owner agrees, or in 14 days with none declining.
The app’s own invitation URLs Built: the core makes them from a link address the app gives it when it starts, so people without Iceblock join through the app:
let app = Iceblock::start(Config { link_base: Some("https://app.example".into()), … })?;
let url = family.invite(Role::Contributor)?; // "https://app.example/#group2:…"
let pairing = app.invite_device()?; // pairing.link: "https://app.example/#…"
other.accept_invitation(&url)?; // the URL, or the bare invitation
The link address is an origin only (a scheme and a host): the core puts everything else after the #, which browsers never send to a server, so nothing about the invitation, not even that it is one, reaches the app’s website or its logs. A link address with a path or query is refused when the app starts. The app shows the URL, or draws its QR code: the core makes the link, the app only presents it. Without link_base, invitations are Iceblock’s website’s URLs.
Joining the user’s Iceblock
app.join_on_this_device() joins the user’s Iceblock running on the same computer, with no invitation to pass: Iceblock shows the user the app and what its manifest may ask for, and once they approve, invites this device alone. On Linux, through Iceblock’s control socket, which only the same user can reach (the app’s id can’t be checked there, as Android checks it by its signing certificate); on Android, the platform package’s joinIceblock gives the invitation to pass to app.join. Built, in r85 (Android in r84).
app.join(invitation) with an invitation from the user’s Iceblock: alone and empty, the device joins; with files, it brings them. It says which app it is as it joins, and the user’s Iceblock pairs it as an app or not at all. Adopted, the app’s network is succeeded by the user’s, and its groups and other installs follow (Built, in r76; An app’s life). collection.hide_from_files() asks the user’s Iceblock to keep the app’s Collection out of their Files and Collections: it shows under the app in My network instead, where the user can put it back. Built, in r76.
Permissions
let granted = app.permissions(); // what this app holds
app.request(Permission::Presence)?; // asks; the user approves or denies
app.request_collection(&id)?; // a Collection's files
app.request(Permission::Group(group_id))?; // then group changes go as requests
// The manifest, in Config, declared at pairing: what it may ask for, and what it requires.
let manifest = Manifest::new([Permission::Devices, Permission::Presence, Permission::Wake])
.requiring([Permission::GroupContact(group_id)]);
Built, in r76: the manifest, asking, and permissions(). The user answers in Iceblock, as a notice on each of their Full-trust devices (approve, deny once, deny always), or offers a permission unasked, and can take any back. A Collection’s grant brings its files and keys; the others are recorded, and shown in the Applications list. A manifest entry is a permission, or a kind of group permission for any group (group, group-contact…), written in the manifest’s asks and requires. A request for anything the manifest doesn’t list is refused on the app, and dropped by the user’s devices if written anyway.
Answers come as events: Events::granted, Events::taken_back and Events::denied, each with the permission, once the app has set its events (set_events). Built, in r76.
app.open_requests() opens the user’s Iceblock on the same computer at what the app has asked for, for the user to answer there and then, rather than when they next look: it syncs first, so what’s asked has reached Iceblock. On Linux, through Iceblock’s control socket; on Android, the platform package’s openIceblockRequests. Built, in r85.
Withholding what isn’t granted is Built, in r76: an app holds a view of the network, not its document, so without devices it doesn’t see the devices’ names, and without collections nothing of other Collections (see the first app’s needs). group.out_of_touch(), the members the home hasn’t heard from in two days or more (of those that agreed to group-contact for this app), group.contact_required() and group.contact_shared(), agreed or not with group.use_app(true, contact): Built, in r76, for an app’s own network; an app device of the user’s Iceblock reads its groups, the same calls, through the user’s network (Built, in r81). app.port() is the port the app serves on, the same after it joins another network, so the old network’s members still find it there. app.devices(): the network’s devices, each by fingerprint and name, which one is this, and which are apps; in the user’s Iceblock only with devices, refused without it, and in the app’s own network, its own installs. Built, in r82.
Waking
await wakeIceblock(); // iceblock_platform, on Android: take what's new and send it on
// Woken by Iceblock (with `wake`): the platform package runs the app's background
// handler, which syncs and posts any notification.
Built, in r76, in iceblock_platform (wakeIceblock, and the background handler Iceblock wakes): Iceblock wakes only the apps granted wake, when their Collections change. The Rust facade needs no call of its own: the app and Iceblock on one device are two devices of the user’s network, so app.sync_now() hands the app’s changes straight to Iceblock, which sends them on in a round a few seconds later, as waking it would.
Presence, stage 3
app.used(); // in front and in use: say so, every few minutes
let p = app.presence()?; // the network: Around | Away | Unknown, with when
for i in &p.installs { /* this app's installs: last_used, last_contact */ }
group.share_presence(true)?; // this network's switch, for this group and app
let them = group.presence()?; // each member network: state, last use, last contact
Within the user’s network Built, in r83: app.used() says the person is using the app, which the app calls while it’s in front and being used (the facade can’t tell that itself; the platform package’s UseReporter does it for a Flutter app, in r88, #762). It counts towards whether they’re around, never saying which app, as does using Iceblock. app.presence() gives the network’s state, from the most recent use on any of its devices (around within 15 minutes, then away, unknown after two hours), and, for each of the app’s own installs, when it was last used and last heard from, coarsened to 5 minutes. In the user’s Iceblock it needs presence; in the app’s own network, it’s the app’s own. Nothing is noted or passed on unless some app of the network holds presence, and it’s kept in memory alone, never in a document, passed between the network’s devices with their addresses at each sync.
Which of the user’s devices they’re using Built, in r90 (#769): each app on a machine is a device of its own, so using Iceblock, or another app, on the laptop says nothing about the laptop to this app’s install there. With device-presence (“which of your devices you’re using”, a permission of its own, apart from presence), each install in app.presence() also gives when any app built on Iceblock, Iceblock included, was last used on its machine (machine_last_used; asked for with presence, which presence() needs): coarsened the same, never which app, within the user’s network alone, never in a group. Iceblock knows two devices share a machine only where it can tell: an app that joined it on the same phone or computer (joinIceblock, join_on_this_device), not one paired from elsewhere. When a machine goes from unused to used, Iceblock syncs at once with the apps on it holding device-presence, waking those that also hold wake on Android, so they hear within seconds: enough for an app to follow its user to the device they’ve picked up. Using the device for anything else (a browser, say) still says nothing: that stays the app’s own to know, on that device.
When the user’s at their computer Built, in r95 (library 0.15.0, #772): with device-activity (“when you’re at your computer”), the app’s own permission, each install in app.presence() also gives machine_last_active: when the person was last at its computer, as the computer itself says (input in the last two minutes, the session unlocked and the one at the screen), coarsened and expiring as the rest of presence, never what’s done there. It isn’t use: presence alone says nothing of it, and machine_last_used stays use of apps built on Iceblock. Computers only, Linux for now; on a phone, use is still what counts. Who checks: Iceblock, wherever it’s on the computer (its desktop app, or iceblock serve in the user’s session), every half a minute; on a computer with the app but not Iceblock, the app’s own SDK, under the same permission. On its own network, with no Iceblock to grant it, the app turns it on itself, once it has asked the user in its own words, when it likes (after a couple of visits, say): app.report_activity(true), read back with app.reports_activity(); it then checks while it runs, for its own installs alone. Nothing carries over silently: when the user’s Iceblock adopts the app, its approval asks “On its own network, the app was allowed to know when you’re at your computer. Keep that here?”, ticked when it was on there, so approving grants device-activity unless the user unticks it; otherwise the app asks for it later like any other permission. Picking a computer up this way syncs at once, and wakes the apps there holding it and wake. On the same machine too, where Iceblock’s “Add it” screen asks it (#790): join_on_this_device says so itself; on Android, pass reportsActivity: app.reportsActivity() to the platform package’s joinIceblock. Breaking: presence entries carry it, so every device needs r95.
In groups Built, in r83: each member network chooses, per group and app, whether to let the app know when it’s around (group.share_presence(bool), read back with group.presence_shared(); in Iceblock, the switch in the group’s app sheet). group.presence() gives, for each member that does, its state, its last use and when the group’s home last heard from it, with the same coarsening and expiry; a member that doesn’t isn’t listed, and in the user’s Iceblock it needs group-presence:<group>. Each member says when it was last used as it syncs the group with its home, which passes everyone’s on with the roster, and the network’s devices pass it to each other with their addresses, to an app device only with that permission.
Its log Built, in r100
Iceblock::set_log(|level, line| my_log(level, line)); // once, before start
let lines = app.recent_log(0); // the latest lines, for a debug report
What the library says as it works: syncs, devices it couldn’t reach, its relay and port forward, shares served (#800). Iceblock::set_log says where it goes, each line with its level (LogLevel::Info, Warning, Error): once, before the first start, as it’s the process’s. Without it, warnings and errors only, marked iceblock:, go to standard error, and the rest nowhere. app.recent_log(after) gives the latest lines kept (up to 2,000), numbered, for the app’s own debug report. In Dart, app.log() is a stream of them (cancelling it ends it at once), and app.recentLog(after:); while nothing listens, warnings and errors go to logcat on Android, tagged Iceblock, and to standard error elsewhere.
An example: Tintbench
Tintbench is a sample app on the facade alone (#659): a stand-in photo editor whose edits are small recipes written beside each photo, never changing the photo. It shows an app’s whole life in a few hundred lines: alone with its own Collection; adopted by the user’s Iceblock with its edits; with collections, seeing the user’s other Collections by name and asking for one (“Photos”, which brings what’s linked inside it); denied one for good, and one once, asking again a day on; and removed, keeping what it had (app.removed(), Events::removed). It’s the iceblock-sample crate in the Iceblock repository, with a command line, tintbench (add, edit, photos, invite, join, join-here, ask, open-requests, run), and a test of scenario 47 end to end against a running Iceblock. Built, in r86.
What it leaves out
Rosters and trust, Links and splitting, levels, Sources, backups, mounting, plain shares, storage loans, relays, diagnostics. Each can come in later if an app needs it, and the core underneath stays reachable for a Rust app that wants it, without the facade’s promise of steadiness.
Packaging
- Published to the iceblock organisation’s Cargo registry on git.tricauda.com with each release whose library changed, from the next one on. Built An app adds the registry to its Cargo config, and depends on
iceblock-sdkfrom it:
# .cargo/config.toml
[registries.iceblock]
index = "sparse+https://git.tricauda.com/api/packages/iceblock/cargo/"
# Cargo.toml
[dependencies]
iceblock-sdk = { version = "0.10", registry = "iceblock" }
- What an app doesn’t use is left out, with nothing to turn off: no image decoders (thumbnails are made by Iceblock’s own worker, which an app doesn’t run), no FUSE mounting, and it never relays for its network. Built
- Licence: the Mozilla Public License 2.0, as all of Iceblock: an app can be under any licence, but changes to Iceblock’s own files stay open.