DistroShelf is a Rust-based GTK4/Libadwaita GUI for managing Distrobox containers. Built with Meson, it provides container lifecycle management, package installation, and application export functionality.
The app state is split across two GObjects, each with a different lifecycle:
RootStore (src/models/root_store.rs) — top-level, app-wide state. Created once per window in DistroShelfApplication::recreate_window, lives for the lifetime of that window. Holds:
- Host-touching abstractions (cloned handles):
command_runner,settings,file_system,terminal_repository - Cross-cutting async state:
container_runtime(Query<DetectedRuntime>),distrobox_version/host_distrobox_version/bundled_distrobox_version(Query<Option<DistroboxExecutable>>/Query<Option<VersionedExecutable>>) - Task management:
tasks: TypedListStore<DistroboxTask> - Current view + dialog (
ViewType,DialogType,DialogParams) - The active
MainStore(below) when inViewType::Main; set toNoneinViewType::Welcome.
MainStore (src/models/main_store.rs) — per-view state for ViewType::Main. Lazily created by RootStore::set_current_view(ViewType::Main) and dropped when switching back to Welcome. Holds:
- The
Distroboxbackend handle (with aCmdFactorythat reads the resolveddistrobox_version) - Container/images queries:
containers_query,images_query,downloaded_images_query TypedListStore<Container>+ GTK selection/sort models (SingleSelection,SortListModel)stale_containers(distrobox-init migration state)
RootStore exposes thin delegating methods (containers(), selected_container(), load_containers(), …) so widgets can ignore the split and call root_store directly. New app-wide state goes in RootStore; new Main-view-only state goes in MainStore. UI binds to GObject properties on either via data binding — never duplicate state in widgets.
Every operation that touches the host — running a process, reading/writing a file, reading/writing GSettings — MUST go through the corresponding faker in src/fakers/. Each faker is a clonable enum { Real(...), Null(...) }:
| Faker | Real variant | Rule |
|---|---|---|
CommandRunner (src/fakers/command_runner.rs) |
Spawns via async_process |
No std::process::Command, async_process::Command, or tokio::process::Command outside this module |
FileSystem (src/fakers/file_system.rs) |
Forwards to std::fs |
No std::fs::* outside this module (and its tests) |
Settings (src/fakers/settings.rs) |
Wraps gio::Settings (com.ranfdev.DistroShelf) |
No direct gio::Settings outside this module |
Why: DistroShelf runs both natively and inside a Flatpak sandbox. The faker indirection lets (a) Real variants be wrapped for Flatpak (CommandRunner::map_cmd(flatpak::map_flatpak_spawn_host)), and (b) every host-touching code path be exercised by tests and UI previews against the Null variants. The DistroboxStoreTy enum in src/application.rs selects the faker variants per window — Real for production, Null* for previews/e2e tests.
Construction: Fakers are constructed in DistroShelfApplication::recreate_window based on DistroboxStoreTy, then passed into RootStore::new(command_runner, settings, file_system). From there they propagate by cloning: RootStore hands them to MainStore, TerminalRepository, etc. Never call gio::Settings::new, FileSystem::new_real, etc. in widget/dialog code — get the already-constructed instance from the RootStore.
Builders for tests/previews: NullCommandRunnerBuilder, NullFileSystemBuilder, NullSettingsBuilder configure the Null variants with predetermined responses/files/values. NullSettingsBuilder::new() starts pre-filled with the schema defaults (data/com.ranfdev.DistroShelf.gschema.xml) — keep the two in sync when adding keys.
// CORRECT
let fs = root_store.file_system();
let contents = fs.read_to_string(&path)?;
let settings = root_store.settings();
let sort = settings.string("sort-key");
// WRONG - bypasses the faker, breaks in tests/Flatpak previews
let contents = std::fs::read_to_string(&path)?;
let settings = gio::Settings::new("com.ranfdev.DistroShelf");Beyond the CommandRunner itself, src/fakers/ also provides:
Command(src/fakers/command.rs): the project's own clonable, owned, transformable command value.std::process::Commandisn'tCloneand hides its stdio config; this one exposesprogram,args,stdin/stdout/stderr(FdMode) as public fields so commands can be passed around, cloned, and rewritten (extend,map_cmd,remove_flag_arg, …). Always build commands withCommand::new/Command::new_with_args.OutputTracker<CommandRunnerEvent>(src/fakers/output_tracker.rs): everyrunner.spawn(...)/runner.output(...)pushes aSpawned/Started/Outputevent onto the runner's tracker (opt-in viarunner.output_tracker().enable()). Tests and previews assert on the command stream rather than mocking globals.
let runner = NullCommandRunnerBuilder::new()
.cmd(&["distrobox", "ls", "--no-color"], "ID | NAME | ...")
.build();
let mapped = runner.map_cmd(|cmd| { /* rewrite before exec, e.g. flatpak-spawn wrap */ cmd });
let tracker = runner.output_tracker(); // .enable() called implicitly
let _ = block_on(runner.output(Command::new_with_args("distrobox", ["ls"])));
assert!(tracker.items().iter().any(|ev| matches!(ev, CommandRunnerEvent::Started(_, _))));Standard gtk-rs pattern used throughout (src/container.rs, src/window.rs, etc.):
mod imp {
#[derive(Properties)]
#[properties(wrapper_type = super::MyWidget)]
pub struct MyWidget {
#[property(get, set)]
name: RefCell<String>,
}
}
glib::wrapper! {
pub struct MyWidget(ObjectSubclass<imp::MyWidget>);
}UI widgets use GTK composite templates:
#[derive(gtk::CompositeTemplate)]
#[template(file = "window.ui")]
pub struct DistroShelfWindow {
#[template_child]
pub sidebar_list_view: TemplateChild<gtk::ListView>,
}
// Connect callbacks in imp module:
#[gtk::template_callbacks]
impl WelcomeView {
#[template_callback]
fn continue_to_terminal_page(&self, _: >k::Button) { /* ... */ }
}Widget .ui files live alongside their Rust implementations in src/widgets/. Global UI resources (help overlay, etc.) remain in data/gtk/.
Wraps async operations with reactive state (src/query/mod.rs). State is split across three orthogonal axes: loading (is-loading), data presence (data()), and last-fetch outcome (last_fetch() → Pending / Success / Error, kept independent so a failed refresh still shows stale data).
let query = Query::new("containers", || async { fetch_containers().await })
.with_timeout(Duration::from_secs(5))
.with_retry_strategy(|n| if n < 3 { Some(Duration::from_secs(n as u64)) } else { None });
query.refetch(); // Triggers fetch, updates is-loading/data/error properties
query.connect_success(|data| { /* UI update */ });
query.connect_error(|error| { /* error UI */ });Properties: is-loading, is-success, is-error, error-message. Signals: success, error. Methods also expose data(), age(), is_stale(max_age), last_success_at().
Beyond direct fetchers, prefer the combinators over hand-rolling reactive wiring:
Query::pure(value)/Query::pending()— synchronous / never-loading queries. Used as terminals for derived chains.set_fetcher— install the fetcher after construction (used when the fetcher needs handles that aren't available atQuery::newtime, e.g. insideRootStore::new).switch_map(f)— switch-style derived query: each source success replaces the inner query, aborting any in-flight fetch. Used to derivedistrobox_versionfrom the host/bundled version queries and the selected source.refetch_if_stale(max_age)/is_stale(max_age)— staleness is measured fromlast_success_at, not from the last attempt or last failure. A failure does not reset the staleness clock.set_refetch_strategy(...)withQuery::immediate/Query::debounce(d)/Query::throttle(d, trailing)— controls how repeatedrefetch()calls collapse.containers_queryis throttled to 1s so podman-event bursts don't flood distrobox.refetch_on(max_age, connect)/refetch_on_focus(window, max_age)— register external event sources (window focus, signal handlers, etc.) that trigger a staleness-gated refetch; teardown is owned by the query viaRefetchTriggerGuard.
Tracks command execution with output streaming (src/distrobox_task.rs):
let task = DistroboxTask::new("my-container", "Upgrade", |task| async move {
let child = runner.spawn(Command::new("distrobox-upgrade"))?;
task.handle_child_output(child).await?; // Streams output to task.vte_terminal()
Ok(())
});
// Status: "pending" -> "executing" -> "successful"/"failed"
// Displayed in TaskManagerDialog with live outputTasks are created via RootStore::create_task(name, action, operation), which appends them to RootStore::tasks and triggers main.load_containers() after they finish. Use view_task(&task) to switch to the TaskManager dialog; use create_task rather than constructing DistroboxTask directly so the post-task container refresh is wired up.
Type-safe wrapper over gio::ListStore (src/gtk_utils/typed_list_store.rs):
let store = TypedListStore::<Container>::new();
for container in store.iter() { /* No downcasting needed */ }Use reconcile_list_by_key to diff-update lists without full rebuild:
reconcile_list_by_key(&store, &new_containers, |c| c.name(), &["status", "image"]);
// Updates existing items, adds new, removes old - preserves object identityAlways use attribute syntax for weak/strong references:
btn.connect_clicked(clone!(
#[weak(rename_to=this)]
self,
#[strong]
data,
move |_| { this.do_something(&data); }
));App automatically detects the Flatpak environment and configures CommandRunner:
- Native: Uses
CommandRunner::new_real() - Flatpak: Wraps the real runner with
command_runner.map_cmd(backends::flatpak::map_flatpak_spawn_host)so every command is rewritten to run viaflatpak-spawn --host - See
src/backends/flatpak.rsandDistroShelfApplication::recreate_windowinsrc/application.rs
ContainerRuntime trait (src/backends/container_runtime.rs) abstracts Podman/Docker:
- Auto-detects available runtime at startup
- Provides unified interface for images, events, container status
RootStore::container_runtimeis aQuery<DetectedRuntime>(the detected runtime plus the version string obtained during detection)
Shell script in src/backends/distrobox/POSIX_FIND_AND_CONCAT_DESKTOP_FILES.sh finds and encodes desktop files from containers for app export. Uses hex-encoding to avoid shell escaping issues.
Use the gnome-sdk-docs skill for guidance on browsing GObject Introspection (.gir) files, D-Bus interfaces, and icon discovery for GNOME library development.
Use the subagent tool to delegate heavy tasks to specialized agents with isolated context. Available agents: scout (fast codebase recon), planner (implementation plans), reviewer (code review), worker (general-purpose). Supports single, parallel, and chained modes.
jujutsu is the VCS used to manage the project. Run jj -h to see the full list of commands supported by the version installed.
We follow the Scoped Commits standard. Scoped Commits is a loose standard for formatting commit messages that focuses on making the commit log quickly understandable to contributors.
Normal commit messages should be formatted as follows:
<scope>: <description>
[optional body]
[optional trailer(s)]
where:
- — the subsystem, area, or module that the commit touches
- — a short description of the changes made
- [optional body] — detailed information about the changes
- [optional trailer(s)] — additional metadata about the commit
Issues are tracked as local markdown files under .scratch/. See docs/agents/issue-tracker.md.
The five canonical triage roles use their default names. See docs/agents/triage-labels.md.