WolOS
Reference contents

Reference · Apps and the UI kit

Cryswol: every system verb in Crystal

The Crystal shard that gives an app windows, notifications, files, power, network, Search and the other system verbs, as typed objects.

On this page

What it is

Cryswol is the Crystal library behind every WolOS app written in Crystal. An app writes require "cryswol" and calls the system through Crystal objects, blocks and exceptions: windows, notifications, the clipboard, preferences, file panels, power, network, sound, Search, messages between apps, devices, sharing and background services.

Why

The system interface is a C library, and C asks you to manage memory and error codes by hand. Cryswol does that once, in one place: handles are released for you, a callback can never crash into the system, and a failed call raises an error that carries the system's own sentence. It adds no privilege: the system still checks every permission.

How

  1. Install the Developer module: wol modules install dev (or tick Developer Tools in the installer). See Set up for development. You see crystal --version answer in a terminal.
  2. Write one file that begins with require "cryswol". Your first app in Crystal walks through a seven-line program that reads the system and posts a notification. (wol app new hello --template cli, which writes a project for you, comes with the next update.)
  3. Build it with crystal build, with the system shards on CRYSTAL_PATH as shown below.
  4. Blocking calls (file panels, sharing, messages) go through Wol.background, so the window never freezes.

If something goes wrong

What you seeWhat to do
crystal: command not foundThe base image has no compiler. Install the Developer module: wol modules install dev.
Wol::PermissionDenied with a sentenceThe person has not allowed it. Show the sentence; it says where in System Settings to allow it.
Wol::UsageErrorThe program used something after it was released or outside its callback. It is a bug in the app, never shown to a person.
The window freezes on a file panelCall it through Wol.background, which runs it on its own thread with its own context.
An app refuses to start on another computerIt was built against a newer system library. Its package must say depends=(libwol>=1.N) for the newest verb it calls.

Your first app in Crystal · Set up for development · Your first Wolkit app · libwol: the app API · Crystal shards · The Cryswol page

Reference

Cryswol is the Crystal shard that gives an app every WolOS verb: windows and Kit parts, notifications, menus, the clipboard, preferences, file panels, power, network, audio, Search, accounts, messages between apps, devices, sharing and background services. It lives in wolos-crystal/shards/cryswol/ (BSD-2-Clause), its namespace is Wol, and it is a thin, typed layer over libwol.so.1 and crates/wol-abi/include/wol.h (ADR 0020). An app writes require "cryswol" and gets Crystal objects, exceptions and blocks, never pointers.

Why

ADR 0036 makes Crystal the userland language, and it says every WolOS verb must be reachable from Crystal through generated declarations, never hand-written ones. The C ABI stays the contract: Cryswol adds no wire and holds no privilege, so wold still checks every permission (System Settings > Privacy & Security), and the same wol.h serves C, Swift and .NET. The high layer exists for three reasons. Memory: libwol's handles have a retain and release protocol that a garbage collector does not see. Callbacks: a Crystal block cannot be handed to C directly, and an exception must not unwind into C. Errors: a status code plus an out-parameter becomes one exception that carries libwol's sentence.

Layers

LayerFilesWritten byLicense
Low: lib LibWolsrc/wol/lib_wol.crgenerated from wol.h (crates/wol-abi/tests/common/bindgen.rs)BSD-2-Clause
Low, opt-in: lib LibWopkgsrc/wol/pkg/lib_wopkg.crgenerated from crates/wopkg/include/wopkg.hGPL-2.0-or-later
Low, opt-in: lib LibRealtimesrc/wol/realtime/lib_realtime.crgenerated from crates/realtime/include/wol_realtime.h, no @[Link]GPL-2.0-or-later
Coresrc/wol/{version,error,handle,callbacks,string,context,loop,background,info,event}.crby handBSD-2-Clause
Kit verbssrc/wol/{window,widget,notify,menu,toolbar}.crby handBSD-2-Clause
Service verbssrc/wol/{system,clipboard,prefs,file,power,audio,network,search,account,message,device,share,service}.crby handBSD-2-Clause
Packages, opt-insrc/wol/pkg/pkg.cr over LibWopkgby handGPL-2.0-or-later

require "cryswol" (src/cryswol.cr) loads lib_wol.cr and the BSD layer, every file directly in src/wol/. The GPL parts sit in subdirectories, so that glob never reaches them, and are opt-in: require "cryswol/pkg" and require "cryswol/realtime". They are GPL, and an app of any license must be able to link the default shard. The generator writes each lib_*.cr both to crates/wol-abi/bindings/crystal/ (where the C-ABI conformance programs hello.cr, parts.cr and services.cr use it) and to the shard; cargo test -p wol-abi --test bindings fails when either copy differs from the header.

Ownership rules

  1. Handles. Each opaque type has one wrapper class built on Wol::Handle. It holds the pointer, calls the matching *_release from finalize, and #close drops the reference at once. A call on a released handle raises Wol::UsageError; libwol also refuses a stale handle, so this is a second guard.
  2. Borrowed results. A *_get_* result lives as long as its argument. Strings are copied out at once; a Wol::Info child keeps its parent alive, so it can never outlive the answer it came from.
  3. Callbacks. A block is boxed, and the box is held by the Crystal wrapper object, which the context keeps registered until it is cancelled or released. The reason: libwol's heap is invisible to the collector, so a box held only as user_data would be freed. The function pointer handed to C is a class-level trampoline, not a closure (Crystal refuses to send a closure to C). It unboxes user_data, runs the block inside begin/rescue, and routes an exception to Wol::Loop.on_error. Nothing unwinds into C.
  4. Callback-scoped objects. A Search reply, a message and an event are valid only while their callback runs (wol.h says so per call). The wrapper marks them invalid when the block returns; later use raises Wol::UsageError. Wol::Message#keep retains a message to reply later.
  5. Strings in. Every string goes as pointer plus byte length and libwol copies it; Crystal strings may hold a NUL byte, and libwol refuses one where wol.h forbids it. An optional string (detail, icon) is nil.
  6. Strings out. String.new(ptr, len) at once, then release the owned reference. A pointer from libwol is never kept.
  7. None is nil. An out-parameter that is NULL on success means "nothing": an unset preference, a cancelled panel, a missing key. Those return nil (String?, Wol::OpenFile?), not an exception.
  8. File descriptors. Wol::OpenFile#to_io returns a duplicate (close-on-exec), so it survives the release of the file. The loop reads a duplicate of wol_context_get_fd too, and closes it before the context is released: wrapping libwol's own descriptor leaves a stale event-loop registration that hangs the next context given the same number.

The service verbs

Each group is one module or class in its own file under src/wol/. The context comes first, and the answer keys are those wol.h lists for the call. An Info answer reads like JSON: info["battery"]["percent"].as_i?, info["interfaces"].map(&.["kind"].as_s), and a missing key is nil.

GroupCrystalPermissionSpec
SystemWol::System.info(ctx) : SystemInfo (os_name, kernel, appearance, accent_hex)noneanswers_spec.cr
ClipboardWol::Clipboard.text(ctx), .set_text(ctx, text)clipboard-read, clipboard-writeprefs_clipboard_spec.cr
Preferencesprefs = Wol::Prefs.new(ctx), then prefs["k"]?, prefs["k"] = v, prefs.delete("k"), prefs.keysapp-preferencesprefs_clipboard_spec.cr
FilesWol::Panel.open(ctx, title) : OpenFile?, .save(ctx, name, title); OpenFile#name, #to_io, #readfile-panelsfiles_spec.cr
PowerWol::Power.info(ctx), .keep_awake(ctx, reason) { ... }power-info, keep-awakeanswers_spec.cr
NetworkWol::Network.info(ctx), .online?(ctx)network-infoanswers_spec.cr
AudioWol::Audio.info(ctx), .set_volume(ctx, 0..100), .set_muted(ctx, bool)audio-info, audio-controlanswers_spec.cr
SearchWol::Search.query(ctx, text); Wol::Search.provide(ctx, on_open: ->(key : String) { }) { |reply, text| reply.add(key, title, detail) }search, search-provideranswers_spec.cr, search_spec.cr
AccountWol::Account.info(ctx)account-infoanswers_spec.cr
MessagesWol::Endpoint.new(ctx, name) { |m| m.reply(text) }, Message#keep, Wol::Message.send(ctx, peer, name, body), .publish(ctx, topic, body)ipc, ipc:<peer>messages_spec.cr
DevicesWol::Device.list(ctx), .open(ctx, vendor, product) : OpenFiledevices, device:<vendor>:<product>answers_spec.cr, files_spec.cr
SharingWol::Share.text(ctx, title, text): "copied", "saved" or "cancelled"shareprefs_clipboard_spec.cr
ServicesWol::Service.start(ctx, name), .stop, .status; Wol::Service.current (the name this process runs as, or nil)backgroundservices_spec.cr
Packages (opt-in, GPL)Wol::Pkg::Context.new(config), then #search, #info, #index_update(req) { |event| }, #verify, #history_record, #rollback, ...; results are JSON textwold's consent, for system changespkg_spec.cr

Errors

Every verb goes through one private check: on a status other than WOL_OK it reads the code and the message of the wol_error, releases it, and raises. The message is libwol's sentence, word for word; it is written for a person and passes the copy rules.

wol_statusValueCrystal
WOL_ERR_INVALID_ARGUMENT1Wol::InvalidArgument
WOL_ERR_PERMISSION_DENIED2Wol::PermissionDenied
WOL_ERR_UNAVAILABLE3Wol::Unavailable
WOL_ERR_INTERNAL4Wol::Internal
WOPKG_ERR_UNTRUSTED (opt-in)5Wol::Pkg::Untrusted
WOPKG_ERR_NOT_FOUND (opt-in)6Wol::Pkg::NotFound

All of them are Wol::Error, which carries code : Int32. Wol::UsageError is separate: it means the program is wrong (a released handle, a callback-scoped object used too late, a second dispatch from inside a callback). Its text is for the developer and is never shown to a person. An exception raised inside a callback never reaches C: it goes to Wol::Loop.on_error, which by default writes it to standard error and keeps the loop running.

Threads and blocking calls

wol.h asks for one thread at a time per context. The default Crystal execution context is single-threaded, so an app that stays on it meets the rule without effort. Only the Wol::Loop fiber calls wol_context_dispatch, and callbacks run inside it, so a callback should be short and spawn its work.

Some verbs wait for the person or a peer: Wol::Panel.open and .save (up to ten minutes), Wol::Share.text (until the person chooses) and Wol::Message.send (up to 3 seconds). On the loop's fiber they freeze the window. Run them through Wol.background(ctx.app_id) { |bg| Wol::Panel.open(bg) } (src/wol/background.cr). It runs the block on its own thread with a separate context for the same app (wol.h allows one context per driving thread), and the calling fiber waits for the result without blocking the other fibers. An exception in the block is raised in the caller. Crystal 1.22 has execution contexts with no flag; never build with -Dpreview_mt alone. Every such method says Blocks: in its doc comment.

Needs: toolchain and advisories

A Cryswol program needs the pinned toolchain, crystal >= 1.22.0, and nothing outside the standard library unless the shard is counted. The pin lives in one file, tools/crystal/toolchain.json; the shard's shard.yml, the recipes, CI (tools/crystal/lint.sh) and this page are checked against it (RCR-046). Security advisories for crystal, gc, llvm-libs and for shards go into tools/crystal/advisories.json; a listed version in the snapshot record or in a shard.lock fails the rules until the snapshot moves and the recipes that compile Crystal bump pkgrel (RCR-045). What each standard-library module may be used for (typed decode, timeouts, Random::Secure, argv commands) is the table in docs/RULES-crystal.md, "Standard library and security policy"; the procedures are in tools/crystal/README.md.

Build

The toolchain is pinned: crystal 1.22 from the base distribution's repositories (shard.yml asks for >= 1.22.0, the only version tested). It is a build dependency only (ADR 0036): an app at run time needs gc pcre2 glibc and libwol.

On an installed WolOS, Cryswol needs the Developer module. The base image ships neither crystal, nor libwol.so.1, nor the shard. wol modules install dev (or ticking Developer Tools in the installer, or Settings > Modules) installs wolos-module-dev, which depends on crystal, pkgconf (crystal build asks pkg-config for the libraries it links), libwol and cryswol (wolos-pkgbuilds/wolos-module-dev/PKGBUILD, record files/usr/share/wolos/modules/dev.json; wolos-pkgbuilds/checks/tests/modules.rs keeps them there). After that crystal --version answers, /usr/lib/libwol.so.1 is there and the shard sits under /usr/lib/wolos/crystal/cryswol, so a program builds with CRYSTAL_PATH="/usr/lib/wolos/crystal:$(crystal env CRYSTAL_PATH)" crystal build app.cr. A packaged app built elsewhere needs only libwol (its own depends=), not the module. The VM journey wolos-iso/tests/wolvm/journeys/cryswol-hello.yaml installs the module, builds and runs examples/hello.cr against the live wold as the person, and requires both the notification and the theme event that wol theme light / dark sends (every theme change publishes it on wold, crates/controller/src/run.rs announce_theme).

  • The library. wolos-pkgbuilds/libwol/PKGBUILD builds crates/wol-abi (cargo build -p wol-abi --release --locked), links libwol.so.1 with crates/wol-abi/link.sh (only abi/wol.map decides the exports), and installs /usr/lib/libwol.so.1, the /usr/lib/libwol.so link for -lwol and /usr/include/wol.h. Its pkgver is the ABI version (WOL_VERSION_MAJOR.MINOR.PATCH, kept in step by tools/bump-wolos.sh), so an app's package says depends=(libwol>=1.14) for the newest verb it calls. An app built against a newer libwol refuses to start on an older one (symbol versions), which is why that dependency matters. The contract test is wolos-pkgbuilds/checks/tests/recipe_libwol.rs.
  • The shard package. wolos-pkgbuilds/cryswol/PKGBUILD installs the shard with its own make install under /usr/lib/wolos/crystal/cryswol (so require "cryswol" works on that CRYSTAL_PATH entry), depends on crystal and libwol, and runs make fmt-check (the specs need cargo and run in the wolos CI). Its pkgver is shard.yml's version (kept in step by tools/bump-wolos.sh); the wolkit package depends on it. The contract test is wolos-pkgbuilds/checks/tests/recipe_cryswol_shard.rs.
  • The shard in the tree. make spec in wolos-crystal/shards/cryswol/ builds libwol into build/ and runs crystal spec against the fake wold and Kit host (crates/wol-abi/examples/wol_fake.rs, fed by spec/fixtures/<group>.json), so the specs need no running WolOS. make format runs crystal tool format --check.
  • File layout. src/cryswol.cr (default entry), src/wol.cr, one BSD file per verb group directly in src/wol/; the GPL parts in subdirectories: src/wol/pkg/ (lib_wopkg.cr, ffi.cr, pkg.cr, via src/cryswol/pkg.cr) and src/wol/realtime/ (lib_realtime.cr, via src/cryswol/realtime.cr). Also spec/ with spec/fixtures/, and examples/hello.cr.
  • Acceptance. WOL_BINDINGS_REQUIRED=crystal cargo test -p wol-abi --test bindings --test crystal_shard (every wol.h function has a wrapper or a reasoned EXEMPT row; the shard specs and examples/hello.cr run), plus make spec and make fmt-check in the shard, and cargo test -p controller --test rules_crystal.
  • An app. In a package build: export CRYSTAL_PATH="<shards dir>:$(crystal env CRYSTAL_PATH)", then crystal build --release src/app.cr. No shards binary is needed. Out-of-tree projects use a path: dependency on the shard directory.

How to add a verb

A new call in libwol costs its Crystal wrapper in the same change (ADR 0036).

  1. Add the declaration to crates/wol-abi/include/wol.h and the symbol to a new node of crates/wol-abi/abi/wol.map (docs/modules/abi.md, ABI rules).
  2. Run WOL_BLESS=1 cargo test -p wol-abi --test bindings. It regenerates both copies of lib_wol.cr.
  3. Write the wrapper in the shard file of its group (src/wol/<group>.cr): a doc comment on every public method; strings in through Wol::Strings.ptr/.size; the status through Wol.call, Wol.out or Wol::Info.copy; out-parameters to Crystal values or nil; # Blocks: when it waits. A verb file holds no Pointer, pointerof, LibC or to_unsafe (RCR-010): the raw memory stays in the core files on the FFI allowlist (crates/controller/tests/rules_crystal.rs, FFI_ALLOW).
  4. Add the answer the fake wold gives to spec/fixtures/<group>.json and a spec to spec/<group>_spec.cr. Assert the call wold received (from the fake's log) as well as what the method returned.
  5. Run make spec format in the shard, then cargo test -p wol-abi --test bindings. The coverage check fails while a function of the header has no wrapper.
  6. Add the call to services in each language (crates/wol-abi/tests/common/services.rs checks them all).

From the developer documentation, wonderlandos/wolos docs/modules/cryswol.md.