src/profiles/README.md in the repository — edit it there.Profiles — The User's Own Printers, Filaments and Processes
This module owns the profile library: the printers, filaments, process profiles and label vocabulary a user has created. It exists because of one failure it must prevent:
A cloud user who clears their browser must not lose every printer they own. Profile instances live where the engine runs, not only in
localStorage.
ProfileStore is the engine-side home. profiles.toml sits beside slicer.toml in config_dir().
TOML at rest, JSON on the wire
The library is TOML on disk and JSON in transit. The profile structs are JSON-native — #[serde(flatten)] metadata plus a dynamic serde_json::Valueparams bag — which the toml serializer cannot encode directly, so the conversion goes through serde_json::Value with nulls dropped.
Do not try to toml::to_string a profile struct. Call toml_bridge::render_library_toml — the one renderer behind both ProfileStore::save and the exporter.
The contract
- The library shape is target-independent.
ProfileLibrary,LabelandProfileKindlive in library.rs so wasm — which has no filesystem and therefore nostore— can still use them. OnlyProfileStoreiscfg(not(target_arch = "wasm32")). - Profiles never touch SQLite. The database is history and the G-code cache, nothing else.
- Sync is whole-category, last-writer-wins. The unit is a
ProfileKind(Printers·Filaments·Processes·Labels); the UI sends the full array on any add, edit or delete, mirroring the whole-bloblocalStoragewrite it replaced. Single-tenant: one library per engine instance, no auth. Labelis snake-case aligned ({id, name, color, tone}) across store.rs and the UI'slabel.model.ts. The UI profile models are the engine's generated types, with no mapping layer, so stored items serialize back byte-compatibly.- The UI's "print profiles" are the engine's "processes". The store key is
profiles.printProfiles; the wire token isprocesses.
Three transports, one store
| Runtime | Transport |
|---|---|
| Cloud | REST — server/handlers.rs |
| Native | Tauri profiles_load / profiles_save_category |
| Web | localStorage only — the browser is the engine |
- Change fan-out over WS, cloud only. A successful
PUT /api/profiles/:kindbroadcastsServerMessage::ProfilesChanged { kind }to every open WebSocket session — via atokio::broadcastchannel onAppState— so a second tab refetches instead of showing stale profiles.ProfileSyncmaps the token to its store and callsreload(). GET and PUT stay REST; only the nudge is WS. It is inert in web and native, which have no second client. loadLibrary()is memoised in the UI. The four stores hydrate in their constructors, so one in-flight request is shared rather than fetching the whole library once per category. Invalidated onsaveCategory; force-refreshed viareloadLibrary().- On first run against an empty engine store the local library is pushed up. A migration, never a clobber.
localStoragestays a fast cache in every mode. Engine-backed runtimes also hydrate from and write through to the store.
The settings-sidebar notice mirrors exactly this: native = "saved on this device", cloud = "saved on the slicer", web = "kept in this browser only".
Export — written once, never revisited
export.rs renders the library for download in two shapes: a bundle (slicer-profiles.zip — one TOML per profile, plus labels.toml, manifest.toml and a README.md) and a single profiles.toml identical to what the store writes. Both come from the same serialization.
- Never name a field — or a category. The exporter walks the serialized value and treats every top-level array as a category. A new profile setting, or a whole new category on
ProfileLibrary, exports with zero changes here. That is the point of the module; do not add per-feature branches. The only category-specific rule is one line (splits_per_item): labels are a flat vocabulary and stay in one file. - Every file is an array of tables (
[[printers]]), and per-item files are ordinal-prefixed (printers/01-voron-24.toml). Concatenating a bundle in name order therefore reconstructs a validprofiles.tomlwith the original order intact — the contract a future importer relies on, pinned byconcatenating_a_bundle_reconstructs_the_library. - Deterministic. Fixed zip timestamps, so the same library exports byte-identically and the artifact can be diffed or version-controlled.
- Credentials are stripped. An export is built to be handed over — a git repo, AirDrop, mail — so
redact_secretsremoves any field named inSECRET_FIELDS(api_key,token, …) anywhere in the tree, in both shapes. Matched by name at the value level, so a credential added later is covered for free. The user re-enters keys after restoring. - It is faithful to the library as the engine understands it — what
ProfileStore::loadproduced and what the next save would write — not a verbatim copy of the bytes on disk. A typed field written by a different build is dropped by serde at load, before the exporter sees it (exactly as it already is forGET /api/profiles). Free-formparamsentries, where new slicing settings actually land, always survive. Do not overstate this in user-facing copy.
Export has the same three transports: GET /api/profiles/export?format=, Tauri profiles_export — both exporting what is persisted, i.e. what the CLI on that machine would read — and wasm exportProfileLibrary(library, format) for the web runtime. The wasm binding exists only in the web-slicer build, so the UI looks it up dynamically rather than importing a symbol the cloud and native bindings do not declare.
The UI half
ProfilePersistencehas three adapters — browser, remote-REST, native-invoke — picked byresolveRuntimeMode(), notenvironment.runtimeModealone, because the desktop build ships thecloudenvironment and only becomesnativeby detecting Tauri at runtime.ProfileExportpicks the export transport and hands the bytes toFileExport— the one place that knows the three "save a file" idioms (iOS share sheet, desktop Save-As, browser anchor). G-code downloads go through it too; do not re-implement a download in a feature.BrowserProfilePersistencelooks the wasm export up dynamically, sinceexportProfileLibraryexists only in theweb-slicerbindings.
The built-in G-code presets
The ready-made start / end / layer blocks for Marlin, mainline Klipper and Klippain live in gcode_templates.rs, and the UI's copy is generated from them (pnpm run gen-gcode-templates). They used to be written out twice — once here as the blocks a blank profile starts with, once in the UI as the selectable presets — and the two drifted: the Klipper blocks disagreed, and Klippain existed only UI-side with the wrong argument names, so a Klippain user's bed temperature was dropped by the macro.
That failure is quiet by construction, which is why the presets are worth centralising: the text left of each = is the receiving macro's parameter name, and Klipper discards an argument its macro never declared without raising an error. BED= where the macro reads BED_TEMP= prints the plate at the macro's default temperature instead of failing.
This is a closed set, and not the profile catalog. An entry is a firmware macro convention several machines share, so the list only changes when Marlin, Klipper or Klippain do. A vendor shipping its own start G-code does not earn one: that is custom start G-code, which the custom sentinel already covers. The choice is a preset or custom — nothing per-vendor in between.
What this module deliberately does not do
- No networking.
reqwestwould break the wasm build; outbound printer traffic isprinter's job. - No per-user identity. Single-tenant by design.
- No import. The bundle is built so a future importer can reconstruct the library, but nothing reads one back yet.
- No merge resolution. Whole-category, last writer wins.
See also
- store.rs · library.rs · export.rs · toml_bridge.rs · resolve.rs · gcode_templates.rs
- ../config/README.md — where
config_dir()points - ../server/README.md — the REST endpoints and the WS nudge