Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Omikuji

User guide built on 0.4.4, dev guide built on 0.7.0.

- If you’re a wandering user, start here: User Guide.

- If you want to contribute or learn how it works: Developer.

- If you’re sane: github.com/lutris/lutris.

Installation

This is how you install omikuji. Pick whatever fits your setup, cutie pie~

Arch (AUR)

yay -S omikuji-bin   # prebuilt
yay -S omikuji-git   # builds from latest source

Fedora (COPR)

sudo dnf copr enable reakjra/omikuji
sudo dnf install omikuji

Fedora 43 and 44. Or grab the .rpm from the releases page.

Flatpak

Not on Flathub yet. Grab the .flatpak from the releases page:

flatpak install omikuji.flatpak

Nix

For flake issues, mention @claymorwan when opening a issue.

On NixOS with flakes, add the input:

# flake.nix
inputs.omikuji = {
    url = "github:reakjra/omikuji";
    inputs.nixpkgs.follows = "nixpkgs";
};

Then install via the Home Manager module (recommended) or as a package:

# home-manager module
programs.omikuji.enable = true;

# or as a package (NixOS or home-manager)
environment.systemPackages = [
    inputs.omikuji.packages.${pkgs.stdenv.hostPlatform.system}.default
];

To skip compiling, add the Cachix cache:

nix.settings = {
    substituters = [ "https://omikuji.cachix.org" ];
    trusted-substituters = [ "https://omikuji.cachix.org" ];
    trusted-public-keys = [ "omikuji.cachix.org-1:dS6sbpMxarHWIIk3y0R7KXz3eVHUg1lo/y3gMbv4JhM=" ];
};

Or run it without installing:

nix run github:reakjra/omikuji

From source

Needs Rust (2024 edition), Qt 6.7+, pkgconf, and cmake.

git clone https://github.com/reakjra/omikuji.git
cd omikuji
cargo build --release
./target/release/omikuji

Little Big Launcher

Oh, hello there. Are you lost? Don’t you fret.

Come take a little tour through this page, silly and unimportant to the cold, indifferent universe as it may be, and I shall guide you through your very first steps. Together we’ll coax one humble game into running. Today’s brave volunteer is a noble title named WizardGoose.exe, though whatever game you happen to have will do just as nicely.

A home for your game

Up in the top right of your little, adorable application window, there’s a very calm + button. Give it a gentle press, and a window titled New Game drifts open.

This is where your game introduces itself:

- A Name, so it has something to be called. Ours shall be “Wizard Goose”.

- A Runner, which is how it should run. Our goose is a Windows creature, so we leave it on Wine. A native Linux creature would prefer Native, a Steam import would prefer Steam, but those are tales for another day.

A name is all it truly needs to exist. The rest is just kindness.

Dressing it up

Wander over to the Runner tab. Here live the knobs that teach a Windows game how to behave on Linux.

You needn’t touch most of them. Three deserve a glance:

- The Path to its .exe, the little file that actually starts the thing. Browse to wherever it lives on your disk.

- Version: the wine or proton build it runs on. Choose one from the list. If the list is bare, slip into Settings and install a runner first, then come back.

- Prefix: the little fake C: drive your game will call home. Leave it empty and omikuji quietly conjures a fresh one just for this game, which is exactly what you want.

Should your goose demand finer graphics, the Translation Layers section holds toggles like DXVK and VKD3D. Flick them on if it asks. Every last knob in here is catalogued, calmly and exhaustively, over in Game settings. For now, we keep things simple.

The first flight

At the top wait two buttons. Create tucks the game safely into your library. Create & Play does the same, then sets it flying at once.

Press Create & Play.

And off it goes. Your goose takes wing. You did it.

Now go, back into the cold and indifferent universe, a little less lost than you were. Your library is right over there whenever you want it, and every game you bring home gets its own quiet little corner.

But… but what if a third-world eldritch being offers me games…?

Oh… little summer dear. Don’t you just worry. Look at your left, what do you see? Exactly. Four, cosmological creatures waiting for your interest. Let’s try the first one. Click.

- Steam: A religion, someone would say. It just shows you, doesn’t it? A tiny, colored + awaits on each contemporary art. Click it. And it’ll come home.

- Epic Games: Loved by its generosity. Same as before, however, now you’ll have to make your first choice. The installation path. Where do you want to keep the bloody membrane of this one? Choose carefully. Then, a prefix, just leave it empty. A runner. Of course. Once you made your life-bearing decisions, don’t worry yet, it’s gonna take just a moment. Click gently on the button at the bottom right, ‘Install’. You won’t believe what will happen.

- GOG: I’m not gonna explain this one, you’re grown enough. You already know how to do it.

- Gachas: Oh dear. Now some more paths belie in front of us. But stay calm. Let your inner self tell you where do you feel the most and what voices you’re hearing.

What is this…?

My, my. This is, in plain terms, a context menu. See? It’s very simple to understand. But let’s talk abotu some distinctive choices.

- Check for updates: This works for GOG and Epic Games. It’s self explanatory, isn’t it?

- Uninstall (Epic Games): Oh, this is unique. This, will help you delete a silly, naughty creature from your library and your disk. It’s important to do this for this type of creatures, because they need extra implicit steps that sometimes, our life, won’t ever tell us.

- Repair: This… this one is healing a wound that sometimes life scars us with. This one, is unique too. It only applies on creatures from a very specific company.

Is… is it infecting my digital life?

Of course not, adorable creature. This one, helps you out when sometimes reaching for something important feels to heavy so you need something lighter to assist you.

See?

Very minimal.

Game settings

Per-game settings open from the library: right-click a game and pick Settings, or use the edit button or just add a new game. Changes are staged until you hit Save or Save & Play.

Runner type

On the Game Info tab, the runner type decides how the game launches:

- Wine: run a Windows game through wine or proton. The default for most things.

- Steam: hand off to Steam, for imported Steam games.

- Native: run a Linux binary directly, no wine.

- Flatpak: launch an installed flatpak app.

The rest of Game Info (name, artwork, color) is optional and self-explanatory.

Runner

Wine/proton config. Only shown for Wine games.

Executable

- Path: the game’s .exe.

- Working Directory: where the process starts. Empty uses the exe’s folder.

- Arguments: passed to the game, e.g. --skip-intro, -use-d3d12.

- Command Prefix: prepended to the whole launch command, for a custom wrapper.

Wine

- Version: which wine/proton build to use.

- Prefix: the wine prefix. Empty auto-creates a fresh one per game, the usual setup.

- Architecture: 64- or 32-bit prefix.

Translation Layers

- DXVK: Direct3D 9/10/11 to Vulkan.

- VKD3D: Direct3D 12 to Vulkan.

- DXVK-NVAPI: exposes NVIDIA features (DLSS, Reflex).

Toggling a layer here enables it for this game. The actual versions come from the DLL packs in Settings => Components, and the version picker lives in that panel.

Display: DPI Scaling plus a DPI slider.

Drivers: the wine Audio Driver (Default, PulseAudio, or ALSA).

Graphics Driver: X11/Wayland picker.

System

Performance: GameMode (Feral’s gamemoderun) and a CPU core limit.

Display: MangoHUD overlay and the GPU to run on. The GPU list comes from your installed Vulkan drivers, handy on multi-GPU setups to pin a game to the dGPU or iGPU.

Gamescope: runs the game inside Valve’s gamescope compositor. Enabling it exposes output and game resolution, an FPS cap, fullscreen or borderless, integer scaling, and an upscaling filter (FSR, NIS, nearest, linear).

Audio: Reduce Pulse Latency.

Power: Prevent Sleep keeps the screen awake while the game runs.

Environment: env vars to use when launching a game.

Sets: Stored env vars that you can:

- Sync (they will be used along the current game’s ones. Synced ones are not added in the game settings, so, changing a synced set will apply to all games)

- Add (adding an env set will apply the contained vars to the game settings).

Epic

Only appears for Epic games.

- EOS Overlay: installs and enables the Epic Online Services overlay in the prefix.

- Cloud Saves: Auto-sync pulls saves before launch and pushes them after exit. Save Path Override covers the case where the path isn’t detected automatically. Note that it won’t find the path if the game was never ran first.

Configuration

Main config lives at ~/.local/share/omikuji/settings.toml, auto-generated on first run. Edit it and restart to apply (only ui.toml is live-watched).

Most sections rarely need touching. The two worth knowing are [[runners]] and [[dll_packs]], which let you add your own wine/proton/DXVK sources without touching code.

[paths]

Where omikuji keeps its data. leave them unless you have a reason.

[paths]
data_dir = "~/.local/share/omikuji"
library_dir = "~/.local/share/omikuji/library"
gachas_dir = "~/.local/share/omikuji/gachas"
runners_dir = "~/.local/share/omikuji/runners"
dll_packs_dir = "~/.local/share/omikuji/components"
prefixes_dir = "~/.local/share/omikuji/prefixes"
cache_dir = "~/.local/share/omikuji/cache"
logs_dir = "~/.local/share/omikuji/logs"
runtime_dir = "~/.local/share/omikuji/runtime"

A leading ~ is expanded to $HOME on read (crazy right?).

[assets]

Where gacha manifests and artwork are fetched from.

[assets]
fetch_url = "https://raw.githubusercontent.com/reakjra/omikuji-assets/main"

Point it at a fork to add your own gachas. hmph.

[components]

Download URLs for the runtime tools (umu, hpatchz, legendary, gogdl, jadeite, EGL dummy). umu-run is fetched on first launch; the rest are fetched when first needed, like a store login or a game install.

[components]
umu_run = "https://api.github.com/repos/Open-Wine-Components/umu-launcher/releases/latest"
hpatchz = "https://api.github.com/repos/sisong/HDiffPatch/releases/latest"
legendary = "https://api.github.com/repos/derrod/legendary/releases/latest"
gogdl = "https://api.github.com/repos/Heroic-Games-Launcher/heroic-gogdl/releases/latest"
jadeite = "https://codeberg.org/api/v1/repos/mkrsym1/jadeite/releases/latest"
egl_dummy = "https://raw.githubusercontent.com/reakjra/omikuji-assets/main/runtime/epic/EpicGamesLauncher.exe"

Leave them unless an upstream tool moves repos.

[steam]

[steam]
api_key = ""

Optional Steam Web API key (get one here). Without it, Steam library listing still works (read locally from ACF files), only remote playtime sync is off. Playtime for games launched through omikuji is tracked either way.

[[runners]]

The sources the runner manager pulls wine/proton from. Each entry points at a releases API plus a pattern to match the right asset.

fieldmeaning
namedisplay name in the runner manager. arbitrary.
kind"wine" or "proton". drives variant detection at launch.
api_urla GitHub releases API (https://api.github.com/repos/{owner}/{repo}/releases). Gitea/Codeberg instances work too if they expose the same JSON.
asset_patternsubstring matched against each asset filename, first match wins. specific enough to skip .sha256sum and the like.
extracttar_gz / tar_xz / tar_zst / zip.

Ships with Proton-Spritz, Proton-GE, Dawn Winery Proton, and Proton-Cachyos. To add one, e.g. wine-tkg:

[[runners]]
name = "Wine-TkG"
kind = "wine"
api_url = "https://api.github.com/repos/Frogging-Family/wine-tkg-git/releases"
asset_pattern = "-x86_64.tar.zst"
extract = "tar_zst"

Restart, then Settings => Components lists the new source with its installable versions. A pattern that matches nothing is skipped.

[[dll_packs]]

Same shape as [[runners]], for DXVK / VKD3D-Proton / DXVK-NVAPI. Installed under components/{name}/{tag}/ so colliding tags don’t clobber each other. Ships with DXVK, VKD3D-Proton, and DXVK-NVAPI. kind is a free string, used only for grouping in the UI.

Which version of each pack gets auto-injected lives in components_state.toml, next to settings.toml:

[dll_packs]
DXVK = "v2.4"
VKD3D-Proton = "v2.13"
DXVK-NVAPI = ""  # empty = disabled

The UI manages this, no need to hand-edit unless something’s off.

Interface (ui.toml)

UI preferences live in ~/.local/share/omikuji/ui.toml: categories, nav rail, tab visibility, zoom, theme. Almost all of it is set through the app, and the file is live-watched, so edits apply without a restart.

A couple of theme knobs have no control in the UI and can only be set by editing ui.toml:

[theme]
fill_fields = true
radius_scale = 1.0
  • fill_fields (default true): text fields, dropdowns, and file pickers render as filled containers. Set it false for the older outline-only look.
  • radius_scale (default 1.0): a multiplier on the corner radii across the app. Above 1.0 rounds everything more, below 1.0 sharpens it.

Everything else in ui.toml mirrors a control in the app, so use those instead of hand-editing.

Overview

Omikuji is two crates and a QML frontend.

  • crates/omikuji-core is the backend. Pure Rust, no Qt. Game library, runners, prefixes, downloaders, per-publisher install logic. If it doesn’t touch the UI, it lives here.
  • crates/omikuji is the app: main.rs, the cxx-qt bridges in src/bridge/, and the QML in qml/.

The split is enforced. omikuji-core never imports Qt, and QML never calls core directly. Everything crosses through a bridge object.

Building

cargo build -p omikuji

cxx-qt regenerates C++ glue on every build, so it isn’t fast. The QML is compiled into the binary as a Qt resource, so editing a .qml (or adding one) needs a rebuild to show up.

Syntax-check QML without a full build:

qmllint qml/components/SomeFile.qml

qmllint only sees syntax. It doesn’t know the types a bridge exposes, so it flags every bridge call as unknown.

Shape of a change

A typical feature touches three layers: something in omikuji-core, a bridge in src/bridge/ to expose it, and the QML that calls it. Adding things covers the common cases.

The cxx-qt bridge

A bridge is a QObject whose state and methods live in Rust. QML instantiates it, reads its properties, calls its methods, and reacts to its signals. Each bridge is one file in crates/omikuji/src/bridge/.

Anatomy

A bridge has three parts in the same file: the bridge module, the Rust state struct, and the impl.

#![allow(unused)]
fn main() {
#[cxx_qt::bridge]
pub mod qobject {
    extern "RustQt" {
        #[qobject]
        #[qml_element]
        #[qproperty(i32, count)]
        type Counter = super::CounterRust;
    }

    unsafe extern "RustQt" {
        #[qinvokable]
        fn increment(self: Pin<&mut Counter>);
    }
}

pub struct CounterRust {
    pub count: i32,
}

impl Default for CounterRust {
    fn default() -> Self {
        Self { count: 0 }
    }
}

impl qobject::Counter {
    fn increment(mut self: Pin<&mut Self>) {
        let next = self.count + 1;
        self.as_mut().set_count(next);
    }
}
}
  • #[qobject] + #[qml_element] declare the QObject and make it instantiable from QML.
  • type Counter = super::CounterRust; ties the QObject to the plain Rust struct that holds its state.
  • The first extern "RustQt" block declares properties. The unsafe extern "RustQt" block declares signals and invokables.
  • impl qobject::Counter holds the method bodies.

Wiring it up

build.rs has two lists. A new handwritten bridge .rs goes in the kushi::stage_files([...]) list, a new .qml goes in qml_files([...]).

#![allow(unused)]
fn main() {
let staged = kushi::stage_files(
    [
        "src/bridge/counter.rs",
        // ...
    ],
    &out_dir,
);
}

The staging copies each file into OUT_DIR before handing it to cxx-qt: cxx-qt-build accepts only one directory per QML module (QTBUG-93443), and the generated bridges already live there. rustc compiles the originals in src/bridge/ as normal modules; the copies only feed cxx-qt’s parser.

Forgetting to register a new .qml is the usual trip: use MyWidget {} somewhere and you get “MyWidget is not a type” at runtime, with no compile error and nothing from qmllint. Add the file to qml_files and rebuild.

Once registered, QML imports the module and instantiates the type:

import omikuji 1.0

Counter { id: counter }

The module name is set in build.rs with QmlModule::new("omikuji").

Properties, signals, invokables

Properties. #[qproperty(T, name)] generates a getter, a setter (set_name), and a name_changed signal. QML binds to the property and re-evaluates when the signal fires. cxx_name sets the name QML sees:

#![allow(unused)]
fn main() {
#[qproperty(bool, is_logged_in, cxx_name = "isLoggedIn")]
}

Rust calls it is_logged_in, QML sees isLoggedIn. Without cxx_name, the Rust name is used as-is.

Signals. Declared with #[qsignal] in an unsafe extern "RustQt" block. You emit one by calling it on self. QML listens with onNameChanged handlers or a Connections block.

Invokables. #[qinvokable] exposes a method to QML. Invokables are not auto-camelCased the way properties are: without cxx_name, QML calls the snake_case Rust name.

#![allow(unused)]
fn main() {
#[qinvokable]
fn get_login_url(self: &Counter) -> QString;   // QML: counter.get_login_url()

#[qinvokable]
#[cxx_name = "installAll"]
fn install_all(self: Pin<&mut Counter>);        // QML: counter.installAll()
}

A property is isLoggedIn but a plain invokable next to it stays get_login_url. If you want camelCase on an invokable, give it a cxx_name.

Reading and writing state

Methods that read take &self, methods that mutate take Pin<&mut Self>. The struct fields are reachable directly, and there are helpers for the rest:

#![allow(unused)]
fn main() {
fn example(mut self: Pin<&mut Self>) {
    let current = self.count;                       // read a field directly
    self.as_mut().set_count(current + 1);           // property setter, fires count_changed
    self.as_mut().rust_mut().get_mut().count = 0;   // write the field directly, no signal
}
}

Reach for set_* when QML needs to react to the change. Use rust_mut().get_mut() for fields that aren’t properties, or for batch edits that fire one signal at the end. self.rust() returns the whole struct. self.as_mut() reborrows the pin for chaining further calls.

The paperwork

Adding one user-visible value is more steps than it looks. A property that persists needs a field on the Rust struct, the #[qproperty] line, an entry wherever the struct is built (Default, or a from_settings), and usually an invokable that sets the value and writes it to disk. Settings also mirror into a core struct, so the same field name shows up in the core type, the bridge struct, and two or three conversion functions.

That repetition is handled two ways. For settings and the download model, the whole mirror is generated from a declaration; see Generated bridges. In the handwritten bridges, macro_rules! does the same job in-file: defaults_fields! and game_fields! build whole families of getters and setters from a single field table. The rule of thumb: when the only thing that changes between copies is a name (a field, a type, a signal), that is generation’s job, not copy-paste. Adding things has the full add-a-setting walkthrough.

Threading

A bridge object lives on the Qt thread and can only be touched there. To update it from other work, grab a handle first, move it into the work, then queue a closure back onto the Qt thread:

#![allow(unused)]
fn main() {
fn refresh(mut self: Pin<&mut Self>) {
    let handle = self.as_mut().qt_thread();
    tokio::spawn(async move {
        let value = fetch_something().await;
        let _ = handle.queue(move |mut obj: Pin<&mut qobject::Counter>| {
            obj.as_mut().set_count(value);
        });
    });
}
}

qt_thread() needs impl cxx_qt::Threading for Counter {} in the bridge module. The closure given to queue runs back on the Qt thread with a fresh Pin<&mut> to the object, which is the only place set_* is safe to call.

Blocking on async

main is #[tokio::main], so invokable bodies run inside the runtime, and calling block_on there panics with “cannot start a runtime from within a runtime”. When you need a result synchronously, run the work on a separate OS thread with its own runtime:

#![allow(unused)]
fn main() {
std::thread::spawn(move || {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    rt.block_on(async {
        // ...
    });
});
}

Binding bridges into delegates

Inside a QML Component (a Repeater/ListView delegate, a Loader source), a binding like model: gameModel resolves the right-hand gameModel to a property on the delegate itself, which is null, not the outer id. The fix used throughout Main.qml is a *Ref alias on the root:

readonly property var gameModelRef: gameModel

// inside a delegate:
SomeItem {
    model: root.gameModelRef
}

Bind bridges into delegates through these aliases, never by the bare same name.

List models

A bridge that backs a QML list (the library grid, the stores, downloads) is a QAbstractListModel. It adds #[base = QAbstractListModel] and overrides three methods:

#![allow(unused)]
fn main() {
extern "RustQt" {
    #[qobject]
    #[qml_element]
    #[base = QAbstractListModel]
    type GameList = super::GameListRust;
}

unsafe extern "RustQt" {
    #[cxx_name = "rowCount"]
    #[cxx_override]
    fn row_count(self: &GameList, parent: &QModelIndex) -> i32;

    #[cxx_override]
    fn data(self: &GameList, index: &QModelIndex, role: i32) -> QVariant;

    #[cxx_name = "roleNames"]
    #[cxx_override]
    fn role_names(self: &GameList) -> QHash_i32_QByteArray;
}
}

Roles are an enum, and role_names maps each to the name QML reads (model.title, model.banner, …):

#![allow(unused)]
fn main() {
enum Role {
    Title = 0,
    Banner = 1,
}

// role_names:
roles.insert_clone(&(Role::Title as i32), &QByteArray::from("title"));

// data:
match role {
    r if r == Role::Title as i32 => QVariant::from(&QString::from(&item.title)),
    _ => QVariant::default(),
}
}

Mutating the backing data has to be wrapped in the inherited model signals or QML won’t update. A full swap uses reset:

#![allow(unused)]
fn main() {
self.as_mut().begin_reset_model();
self.as_mut().rust_mut().get_mut().items = new_items;
self.as_mut().end_reset_model();
}

For finer changes there’s begin_insert_rows(&QModelIndex::default(), row, row) / end_insert_rows, and data_changed(&index, &index, &roles) to repaint one row. These inherited methods (begin_reset_model, begin_insert_rows, data_changed, model_index, …) are declared with #[inherit] in their own unsafe extern "RustQt" block before use. When the data comes from async work, run the fetch off-thread and do the reset inside qt_thread().queue(...).

Generated bridges

Two bridges are not written by hand: UiSettingsBridge and DownloadModel. Their bridge modules are generated by kushi from declarations in crates/omikuji/build.rs and written into OUT_DIR at build time. The declarations list every property, role, signal, and invokable; changing the bridge means changing the declaration.

The two halves

Each generated bridge has a handwritten half in src/bridge/ that pulls the generated module in and implements everything with logic in it:

#![allow(unused)]
fn main() {
include!(concat!(env!("OUT_DIR"), "/ui_settings_bridge.rs"));
}
  • src/bridge/ui_settings.rs: the persist implementation (the watcher suppress window), the custom apply bodies (apply_ui_scale, apply_card_flow, …), the reload hook, the file watcher, and the misc invokables (fonts, icons, color overrides).
  • src/bridge/download_model.rs: the custom Default (counts via recompute), the enqueue/pause/resume invokable bodies, drain_events, and the role_* helpers behind computed roles (status label, byte casts).

The generated half calls into the handwritten one where the declaration says so: .prop_custom_apply declares an invokable without generating a body, .role_fn points a role at a function, .custom_invokable declares any signature. A missing implementation is a compile error.

Rules

  • Generated files live in OUT_DIR and are rewritten every build. Never edit them; edit the declaration or the handwritten half. The current output is inspectable at target/debug/build/omikuji-*/out/.
  • The strings passed to the builders are QML API. Property and role names come out camelCased (show_hidden becomes showHidden). Signatures passed to qsignal_raw and custom_invokable_raw are exposed under their literal snake_case names, which is what the existing QML calls.

Adding things

Three common changes, start to finish. Each builds on The cxx-qt bridge.

A setting

A setting is a value in ui.toml that QML reads and writes live. The bridge for it is generated by kushi from a declaration in crates/omikuji/build.rs. For a new bool show_clock:

  1. Add the field to the core struct in omikuji-core/src/ui_settings.rs (the right section, e.g. DisplaySettings) and to that section’s Default.

  2. Declare it in the ObjectBridge builder in crates/omikuji/build.rs, next to the other props:

#![allow(unused)]
fn main() {
.prop_at("show_clock", kushi::Kind::Bool, "display.show_clock")
}

The last argument is the field’s path inside UiSettings. The generated names derive from the first: property showClock, invokable applyShowClock.

  1. In QML, read uiSettings.showClock and write through uiSettings.applyShowClock(value).

The property, the apply invokable, persistence, and hot reload are all generated. When the setter needs logic (clamping, a side effect, a signal), declare it with .prop_custom_apply(...) instead and write apply_show_clock by hand in src/bridge/ui_settings.rs; apply_ui_scale and apply_discord_rpc are the existing examples. .prop_readonly(...) declares a property with no setter (fill_fields, radius_scale). List-shaped settings that cross as JSON use .json_accessor(...); categories is the model.

A per-game setting

Per-game config (the fields in GameSettingsPage: wine version, env, launch options) lives on the Game struct in library.toml, not ui.toml, and it doesn’t use properties: the whole game config crosses to QML as one string-keyed map, so a new field is one row in a table.

  1. Add the field to the right struct on Game in omikuji-core/src/library/mod.rs (WineConfig, LaunchConfig, …), with a serde default.

  2. Register it in the game_fields! table in crates/omikuji/src/bridge/game_model.rs:

#![allow(unused)]
fn main() {
"wine.my_flag" => bool, wine.my_flag,
}

That row wires both directions: reading the field into the config map and writing it back from QML. The kind tag picks the conversion (str, path, bool, int, json for maps and vecs, args for launch args). Add readonly after the kind for a read-only field.

  1. In the settings tab QML (TabRunnerOptions, TabSystem, …), add the control. Read the value from config["wine.my_flag"] and write changes through the page’s updateField("wine.my_flag", value), which updates the draft and refreshes config.

A runtime tool

Runtime tools (umu, legendary, jadeite, …) are fetched by the component system. To add one:

  1. Add a variant to SettingsKey in omikuji-core/src/components/spec.rs.

  2. Add the download URL to ComponentsSettings in omikuji-core/src/settings.rs and its Default. These are release-latest API URLs (GitHub, Codeberg) or a direct URL.

  3. Map the key to the URL in url_for in omikuji-core/src/components/mod.rs.

  4. Add the ComponentSpec in omikuji-core/src/components/specs.rs:

#![allow(unused)]
fn main() {
ComponentSpec {
    name: "mytool",
    source: Source::GithubRelease { asset_matcher: |n| n.ends_with(".tar.gz") },
    extract: ExtractStrategy::TarGz { inner_path: "mytool" },
    dest: "mytool",
    settings_key: SettingsKey::MyTool,
    trigger: Trigger::OnDemand,
},
}
  1. Pick the trigger. Eager fetches at boot (only umu uses this). OnDemand fetches when something calls components::ensure(...). For OnDemand, add that ensure call at the point the tool becomes necessary (a store login, a game install). epic_tools and gacha_tools in components/mod.rs are the existing examples.

A dialog

Dialogs are built on DialogCard, which owns the backdrop, dim, click-outside, Esc, shadow, and scrolling. A dialog is a config of its slots.

  1. Create qml/components/dialogs/MyDialog.qml:
import QtQuick
import "../widgets"

DialogCard {
    id: root
    title: "Do the thing?"
    maxWidth: 480

    body: Column {
        spacing: theme.space.md
        Text { text: "..."; color: theme.text }
    }

    actions: Row {
        spacing: theme.space.sm
        M3Button { text: "Cancel"; variant: "text"; onClicked: root.close() }
        M3Button { text: "Confirm"; variant: "tonal"; onClicked: { root.close() } }
    }

    onCloseRequested: close()
}
  1. Register it in qml_files in build.rs.

  2. Instantiate it once where it’s used (a page, or Main.qml), give it an id, and call .open() to show it.

The slots take a Component, but a plain element works directly. Don’t add your own backdrop or scrollbar, DialogCard owns those. For a fixed-height list dialog, set fillHeight: true and scrollable: false.

Translations

omikuji’s UI strings are wrapped in Qt’s qsTr(). A QTranslator loads a compiled .qm at startup and resolves them; an unwrapped or untranslated string falls back to its English source. Translations live in crates/omikuji/i18n/, one editable .ts and one compiled .qm per language. The scope is the QML UI: the CLI and the Rust backend stay in English (aint doing allat).

Prerequisites

The Qt Linguist tools, lupdate and lrelease. On Arch they ship in qt6-tools (as lupdate6 / lrelease6), idk other distros. scripts/update-translations.sh accepts either the suffixed or the plain name.

Adding a language

As an example, for Italian (it):

  1. ./scripts/update-translations.sh it harvests every qsTr/tr string into crates/omikuji/i18n/omikuji_it.ts and compiles a first omikuji_it.qm.

  2. Then, you translate omikuji_it.ts, in either any text editor, by filling the <translation> elements, or QtLinguistic if you’re sane. The file name carries the locale code Qt expects (omikuji_it.ts, omikuji_pt_BR.ts, omikuji_ja.ts).

  3. Once done translating, run ./scripts/update-translations.sh it again to recompile omikuji_it.qm from the finished strings.

  4. Build. build.rs embeds the .qm into the binary, and the language appears in the picker at Settings > Interface under its own native name.

  5. Commit both omikuji_it.ts and omikuji_it.qm.

Updating a language

After UI strings change, refresh the catalogs:

./scripts/update-translations.sh with no arguments re-harvests and recompiles every language already in i18n/. New strings land in the .ts marked unfinished; translate them and run it again. Commit the updated .ts and .qm.

The build embeds the committed .qm and never runs lrelease, so both files are committed. The script produces both.

Wrapping a new UI string

Any user-visible literal in a .qml file goes through qsTr:

text: qsTr("Play")

Text combined with data uses placeholders, not concatenation, because word order differs between languages:

text: qsTr("%1 left").arg(formatEta(secs))
text: qsTr("%n game(s)", "", count)

%n picks the plural form for the count.

Left unwrapped: icon names (name:, icon:), color tokens, runner and kind values ("wine", "native", "steam"), any literal used in a comparison, config keys, paths, and bare brand names (Steam, Epic Games, GOG, Proton, DXVK). A brand inside a sentence stays literal while the sentence is wrapped, as in qsTr("Run with Omikuji").