diff --git a/.keys/app_store_publisher_ed25519.pub b/.keys/app_store_publisher_ed25519.pub
new file mode 100644
index 0000000000..38557b00d3
Binary files /dev/null and b/.keys/app_store_publisher_ed25519.pub differ
diff --git a/.keys/app_store_publisher_mldsa65.pub b/.keys/app_store_publisher_mldsa65.pub
new file mode 100644
index 0000000000..09606e55ff
Binary files /dev/null and b/.keys/app_store_publisher_mldsa65.pub differ
diff --git a/Cargo.toml b/Cargo.toml
index 9bb9378923..01d6b3f53d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -111,6 +111,7 @@ nonos-capsule-image-codec = []
nonos-capsule-image-viewer = []
nonos-capsule-video-player = []
nonos-capsule-about = []
+nonos-capsule-app-store = []
nonos-capsule-audio-player = []
nonos-capsule-hello = []
nonos-capsule-boot-splash = []
@@ -552,6 +553,7 @@ microkernel-desktop-base = [
"nonos-capsule-wallpaper-catalog",
"nonos-capsule-toolkit",
"nonos-capsule-about",
+ "nonos-capsule-app-store",
"nonos-capsule-linux",
"nonos-capsule-audio",
"nonos-capsule-driver-hda",
diff --git a/mk/20-build.mk b/mk/20-build.mk
index ee88ff1172..b75b8bd366 100644
--- a/mk/20-build.mk
+++ b/mk/20-build.mk
@@ -557,6 +557,7 @@ include userland/capsule_clipboard/Capsule.mk
include userland/capsule_login/Capsule.mk
include userland/toolkit/Capsule.mk
include userland/capsule_about/Capsule.mk
+include userland/capsule_app_store/Capsule.mk
include userland/capsule_linux/Capsule.mk
include userland/capsule_hello/Capsule.mk
include userland/capsule_gui_demo/Capsule.mk
diff --git a/src/kernel_core/surface_registry/share/attach_frames.rs b/src/kernel_core/surface_registry/share/attach_frames.rs
new file mode 100644
index 0000000000..aa9d7a640a
--- /dev/null
+++ b/src/kernel_core/surface_registry/share/attach_frames.rs
@@ -0,0 +1,60 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Giving a receiver its own view of a surface's frames.
+
+use crate::kernel_core::surface_registry::table::SLOTS;
+use crate::kernel_core::surface_registry::types::{
+ decode_handle, RegistryError, SurfaceDescriptor, SurfaceHandle,
+};
+use crate::memory::paging::manager::api::{lookup_asid_for_process, map_page_in_asid};
+use crate::memory::paging::types::PagePermissions;
+use crate::process::current_process;
+
+pub(super) fn attach_frames(
+ receiver_pid: u32,
+ handle: SurfaceHandle,
+ out_desc: &mut SurfaceDescriptor,
+) -> Result {
+ let (idx, epoch) = decode_handle(handle);
+ let frames = {
+ let mut slots = SLOTS.lock();
+ let slot =
+ slots.get_mut(idx as usize).and_then(|s| s.as_mut()).ok_or(RegistryError::BadHandle)?;
+ if slot.epoch != epoch {
+ #[cfg(feature = "dbg-ring")]
+ crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64);
+ return Err(RegistryError::BadHandle);
+ }
+ slot.refcount = slot.refcount.checked_add(1).ok_or(RegistryError::InvalidArg)?;
+ slot.frames.clone()
+ };
+ let mut desc = super::descriptor::descriptor(handle)?;
+ let asid = lookup_asid_for_process(receiver_pid).ok_or(RegistryError::MapFailed)?;
+ let proc = current_process().ok_or(RegistryError::NoProc)?;
+ let base = proc
+ .reserve_vma(frames.len().saturating_mul(4096))
+ .map_err(|_| RegistryError::MapFailed)?;
+ let perms = PagePermissions::user_rw();
+ for (i, frame) in frames.iter().enumerate() {
+ let va = crate::memory::addr::VirtAddr::new(base.as_u64() + (i as u64) * 4096);
+ map_page_in_asid(asid, va, *frame, perms).map_err(|_| RegistryError::MapFailed)?;
+ }
+ desc.base_va = base.as_u64();
+ *out_desc = desc;
+ super::super::attach_map::record(receiver_pid, handle, base.as_u64(), out_desc.byte_len);
+ Ok(base.as_u64())
+}
diff --git a/src/kernel_core/surface_registry/share/attach_surface.rs b/src/kernel_core/surface_registry/share/attach_surface.rs
index 960492861a..1025b3c2e7 100644
--- a/src/kernel_core/surface_registry/share/attach_surface.rs
+++ b/src/kernel_core/surface_registry/share/attach_surface.rs
@@ -14,76 +14,32 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-use crate::kernel_core::surface_registry::table::SLOTS;
+//! Handing a surface to another process.
+
use crate::kernel_core::surface_registry::types::{
- decode_handle, RegistryError, SurfaceDescriptor, SurfaceHandle,
+ RegistryError, SurfaceDescriptor, SurfaceHandle,
};
-use crate::memory::paging::manager::api::{lookup_asid_for_process, map_page_in_asid};
-use crate::memory::paging::types::PagePermissions;
-use crate::process::current_process;
+
+use super::attach_frames::attach_frames;
+use super::self_attach::self_attach;
pub fn attach_surface(
receiver_pid: u32,
handle: SurfaceHandle,
out_desc: &mut SurfaceDescriptor,
) -> Result {
+ // Never to a guest.
+ if crate::process::foreign::is_foreign(receiver_pid) {
+ return Err(RegistryError::InvalidArg);
+ }
if let Some((base_va, byte_len)) = super::super::attach_map::lookup(receiver_pid, handle) {
*out_desc = super::descriptor::descriptor(handle)?;
out_desc.base_va = base_va;
out_desc.byte_len = byte_len;
return Ok(base_va);
}
- let (idx, epoch) = decode_handle(handle);
- // A self-attach (the owner attaching its own surface) needs no new
- // mapping: the surface already lives at the VA the owner registered
- // it at. Returning that VA keeps the owner's existing VMA, which the
- // present path resolves against. Remapping would create a second VA
- // with no backing VMA and break MkSurfacePresent.
- {
- let slots = SLOTS.lock();
- let slot =
- slots.get(idx as usize).and_then(|s| s.as_ref()).ok_or(RegistryError::BadHandle)?;
- if slot.epoch != epoch {
- #[cfg(feature = "dbg-ring")]
- crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64);
- return Err(RegistryError::BadHandle);
- }
- if slot.owner_pid == receiver_pid && slot.owner_base_va != 0 {
- let base_va = slot.owner_base_va;
- let byte_len = slot.byte_len;
- drop(slots);
- *out_desc = super::descriptor::descriptor(handle)?;
- out_desc.base_va = base_va;
- out_desc.byte_len = byte_len;
- super::super::attach_map::record(receiver_pid, handle, base_va, byte_len);
- return Ok(base_va);
- }
- }
- let frames = {
- let mut slots = SLOTS.lock();
- let slot =
- slots.get_mut(idx as usize).and_then(|s| s.as_mut()).ok_or(RegistryError::BadHandle)?;
- if slot.epoch != epoch {
- #[cfg(feature = "dbg-ring")]
- crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64);
- return Err(RegistryError::BadHandle);
- }
- slot.refcount = slot.refcount.checked_add(1).ok_or(RegistryError::InvalidArg)?;
- slot.frames.clone()
- };
- let mut desc = super::descriptor::descriptor(handle)?;
- let asid = lookup_asid_for_process(receiver_pid).ok_or(RegistryError::MapFailed)?;
- let proc = current_process().ok_or(RegistryError::NoProc)?;
- let base = proc
- .reserve_vma(frames.len().saturating_mul(4096))
- .map_err(|_| RegistryError::MapFailed)?;
- let perms = PagePermissions::user_rw();
- for (i, frame) in frames.iter().enumerate() {
- let va = crate::memory::addr::VirtAddr::new(base.as_u64() + (i as u64) * 4096);
- map_page_in_asid(asid, va, *frame, perms).map_err(|_| RegistryError::MapFailed)?;
+ if let Some(base_va) = self_attach(receiver_pid, handle, out_desc)? {
+ return Ok(base_va);
}
- desc.base_va = base.as_u64();
- *out_desc = desc;
- super::super::attach_map::record(receiver_pid, handle, base.as_u64(), out_desc.byte_len);
- Ok(base.as_u64())
+ attach_frames(receiver_pid, handle, out_desc)
}
diff --git a/src/kernel_core/surface_registry/share/mod.rs b/src/kernel_core/surface_registry/share/mod.rs
index 72cb5930f3..8406c830f1 100644
--- a/src/kernel_core/surface_registry/share/mod.rs
+++ b/src/kernel_core/surface_registry/share/mod.rs
@@ -14,8 +14,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+mod attach_frames;
mod attach_surface;
mod descriptor;
+mod self_attach;
mod share_surface;
pub use attach_surface::attach_surface;
diff --git a/src/kernel_core/surface_registry/share/self_attach.rs b/src/kernel_core/surface_registry/share/self_attach.rs
new file mode 100644
index 0000000000..b359b0e893
--- /dev/null
+++ b/src/kernel_core/surface_registry/share/self_attach.rs
@@ -0,0 +1,49 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The owner attaching its own surface.
+
+use crate::kernel_core::surface_registry::table::SLOTS;
+use crate::kernel_core::surface_registry::types::{
+ decode_handle, RegistryError, SurfaceDescriptor, SurfaceHandle,
+};
+
+/// The owner's own va when the owner is the receiver, or `None` when the
+/// receiver is somebody else and a real mapping has to be made.
+pub(super) fn self_attach(
+ receiver_pid: u32,
+ handle: SurfaceHandle,
+ out_desc: &mut SurfaceDescriptor,
+) -> Result, RegistryError> {
+ let (idx, epoch) = decode_handle(handle);
+ let slots = SLOTS.lock();
+ let slot = slots.get(idx as usize).and_then(|s| s.as_ref()).ok_or(RegistryError::BadHandle)?;
+ if slot.epoch != epoch {
+ #[cfg(feature = "dbg-ring")]
+ crate::log::dbg_ring::dbg_emit_2u64(0x5546_0001, handle, slot.epoch as u64);
+ return Err(RegistryError::BadHandle);
+ }
+ if slot.owner_pid != receiver_pid || slot.owner_base_va == 0 {
+ return Ok(None);
+ }
+ let (base_va, byte_len) = (slot.owner_base_va, slot.byte_len);
+ drop(slots);
+ *out_desc = super::descriptor::descriptor(handle)?;
+ out_desc.base_va = base_va;
+ out_desc.byte_len = byte_len;
+ super::super::attach_map::record(receiver_pid, handle, base_va, byte_len);
+ Ok(Some(base_va))
+}
diff --git a/src/userspace/capsule_app_store/embed.rs b/src/userspace/capsule_app_store/embed.rs
new file mode 100644
index 0000000000..0e1852db46
--- /dev/null
+++ b/src/userspace/capsule_app_store/embed.rs
@@ -0,0 +1,49 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Build-time embed of the marketplace window.
+
+#[cfg(feature = "nonos-capsule-app-store")]
+pub(crate) const APP_STORE_ELF: &[u8] =
+ include_bytes!(concat!(
+ "../../../userland/capsule_app_store/target/",
+ env!("NONOS_USER_TARGET"),
+ "/release/app_store"
+));
+
+#[cfg(feature = "nonos-capsule-app-store")]
+pub(crate) const APP_STORE_NONOS_ID_CERT_BYTES: &[u8] =
+ include_bytes!("../../../nonos-data/trust/capsules/app_store.nonos_id_cert.bin");
+
+#[cfg(feature = "nonos-capsule-app-store")]
+pub(crate) const APP_STORE_MANIFEST_BYTES: &[u8] =
+ include_bytes!("../../../nonos-data/trust/capsules/app_store.manifest.bin");
+
+#[cfg(feature = "nonos-capsule-app-store")]
+pub(crate) const APP_STORE_ATTESTATION_BYTES: &[u8] =
+ include_bytes!("../../../nonos-data/trust/capsules/app_store.zk_trailer.bin");
+
+#[cfg(not(feature = "nonos-capsule-app-store"))]
+pub(crate) const APP_STORE_ELF: &[u8] = &[];
+
+#[cfg(not(feature = "nonos-capsule-app-store"))]
+pub(crate) const APP_STORE_NONOS_ID_CERT_BYTES: &[u8] = &[];
+
+#[cfg(not(feature = "nonos-capsule-app-store"))]
+pub(crate) const APP_STORE_MANIFEST_BYTES: &[u8] = &[];
+
+#[cfg(not(feature = "nonos-capsule-app-store"))]
+pub(crate) const APP_STORE_ATTESTATION_BYTES: &[u8] = &[];
diff --git a/src/userspace/capsule_app_store/mod.rs b/src/userspace/capsule_app_store/mod.rs
new file mode 100644
index 0000000000..fe406dcc5c
--- /dev/null
+++ b/src/userspace/capsule_app_store/mod.rs
@@ -0,0 +1,24 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The marketplace window, as the kernel spawns it.
+
+mod embed;
+mod spawn;
+mod state;
+
+pub use spawn::spawn_app_store_capsule;
+pub use state::shared_state;
diff --git a/src/userspace/capsule_app_store/spawn.rs b/src/userspace/capsule_app_store/spawn.rs
new file mode 100644
index 0000000000..b50e719c9e
--- /dev/null
+++ b/src/userspace/capsule_app_store/spawn.rs
@@ -0,0 +1,62 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+use super::embed::{
+ APP_STORE_ATTESTATION_BYTES, APP_STORE_ELF, APP_STORE_MANIFEST_BYTES,
+ APP_STORE_NONOS_ID_CERT_BYTES,
+};
+use super::state;
+use crate::capabilities::Capability;
+use crate::kernel_core::process_spawn::capsule_spawn::{
+ self, CapsuleSpecVerified, SpawnError,
+};
+use crate::security::nonos_id_cert::IdCertVerifyError;
+use crate::security::nonos_trust_anchor::{
+ decode as decode_trust_anchor, BAKED_TRUST_ANCHOR_POLICY,
+};
+
+const SERVICE_NAME: &str = "app.store";
+const SERVICE_PORT: u32 = 4940;
+const REPLY_INBOX: &str = "endpoint.app.store.reply";
+const REPLY_PORT: u32 = 4941;
+const TARGET_TRIPLE: &str = env!("NONOS_USER_TARGET");
+
+pub fn spawn_app_store_capsule() -> Result<(), SpawnError> {
+ let trust_anchor = decode_trust_anchor(BAKED_TRUST_ANCHOR_POLICY)
+ .map_err(|_| SpawnError::NonosIdCertRejected(IdCertVerifyError::TrustAnchorPolicy))?;
+ let spec = CapsuleSpecVerified {
+ name: SERVICE_NAME,
+ service_port: SERVICE_PORT,
+ reply_inbox: REPLY_INBOX,
+ reply_port: REPLY_PORT,
+ elf: APP_STORE_ELF,
+ nonos_id_cert_bytes: APP_STORE_NONOS_ID_CERT_BYTES,
+ manifest_bytes: APP_STORE_MANIFEST_BYTES,
+ attestation_trailer: APP_STORE_ATTESTATION_BYTES,
+ target_triple: TARGET_TRIPLE,
+ // It reads one service, paints, and may ask for an install.
+ requested_caps: Capability::CoreExec.bit()
+ | Capability::IPC.bit()
+ | Capability::Memory.bit()
+ | Capability::GraphicsDisplayQuery.bit()
+ | Capability::GraphicsSurfaceCreate.bit()
+ | Capability::AppInstall.bit(),
+ debug_tag: b"",
+ };
+ let pid = capsule_spawn::spawn_verified(&spec, &trust_anchor, None)?;
+ state::set_alive(pid);
+ Ok(())
+}
diff --git a/src/userspace/capsule_app_store/state.rs b/src/userspace/capsule_app_store/state.rs
new file mode 100644
index 0000000000..f18bb639bc
--- /dev/null
+++ b/src/userspace/capsule_app_store/state.rs
@@ -0,0 +1,27 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+use crate::services::lifecycle::CapsuleState;
+
+static STATE: CapsuleState = CapsuleState::new();
+
+pub(super) fn set_alive(pid: u32) {
+ STATE.set_alive(pid);
+}
+
+pub fn shared_state() -> &'static CapsuleState {
+ &STATE
+}
diff --git a/src/userspace/init/install_queue.rs b/src/userspace/init/install_queue.rs
new file mode 100644
index 0000000000..86b7258af4
--- /dev/null
+++ b/src/userspace/init/install_queue.rs
@@ -0,0 +1,61 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Package installs asked for by a capsule, performed by init.
+
+extern crate alloc;
+
+use alloc::string::String;
+use alloc::vec::Vec;
+
+use spin::Mutex;
+
+/// Deep enough for a person clicking faster than a download completes,
+/// shallow enough that a caller in a loop cannot grow it without bound.
+const DEPTH: usize = 8;
+
+static PENDING: Mutex> = Mutex::new(Vec::new());
+
+/// Record a request. False when the queue is full, which the caller
+/// reports as busy rather than silently dropping.
+pub(crate) fn request(package: String) -> bool {
+ let mut q = PENDING.lock();
+ if q.len() >= DEPTH || q.contains(&package) {
+ return false;
+ }
+ q.push(package);
+ super::wake::nudge();
+ true
+}
+
+/// Perform every queued install.
+pub(crate) fn service() {
+ let taken: Vec = core::mem::take(&mut *PENDING.lock());
+ for package in taken {
+ match crate::userspace::capsule_linux::spawn_install(&package) {
+ Ok(pid) => {
+ crate::sys::serial::print(b"[LINUX-INSTALL] started pid=");
+ crate::sys::serial::print_hex(pid as u64);
+ crate::sys::serial::print(b" ");
+ crate::sys::serial::println(package.as_bytes());
+ }
+ Err(_) => {
+ crate::sys::serial::print(b"[LINUX-INSTALL] refused ");
+ crate::sys::serial::println(package.as_bytes());
+ }
+ }
+ }
+}
diff --git a/src/userspace/init/instance_spawn/queue.rs b/src/userspace/init/instance_spawn/queue.rs
index a703e78323..1b0ddeac0c 100644
--- a/src/userspace/init/instance_spawn/queue.rs
+++ b/src/userspace/init/instance_spawn/queue.rs
@@ -40,9 +40,7 @@ pub enum PendingApp {
impl PendingApp {
/// The capsule name behind this request, for the one place it matters: a
- /// spawn that was refused. The drain used to report the error alone, so a
- /// dock icon that had quietly stopped opening looked identical on the wire
- /// to one that had never been clicked.
+ /// spawn that was refused.
pub(super) fn name(self) -> &'static [u8] {
match self {
PendingApp::Terminal => b"app.terminal",
@@ -71,6 +69,10 @@ pub(super) static PENDING: Mutex> = Mutex::new(Vec::new());
/// Record a spawn request. Returns false only when the queue is saturated,
/// which the caller treats as "try again", never as a hard failure.
pub(super) fn push(app: PendingApp) -> bool {
+ // Raising init is part of queueing, not a separate courtesy: work left in
+ // a queue nobody is scheduled to drain is work that never happens, and the
+ // caller was told it was accepted.
+ super::super::wake::nudge();
let mut q = PENDING.lock();
if q.len() >= MAX_PENDING {
return false;
@@ -80,10 +82,7 @@ pub(super) fn push(app: PendingApp) -> bool {
true
}
-/// Return init to its idle band once nothing is left to spawn. The check and
-/// the demotion happen under the queue lock, the same lock `push` raises
-/// under, so a click landing here can never be left queued behind a
-/// demotion it raced.
+/// Return init to its idle band once nothing is left to spawn.
pub(super) fn settle() {
let Some(q) = PENDING.try_lock() else {
return;
@@ -105,10 +104,7 @@ pub(super) fn take() -> Vec {
core::mem::take(&mut *q)
}
-/// Whether any window-instance request is waiting to be drained. The init loop
-/// reads this to raise its priority only while there is deferred window work. A
-/// contended lock means a push is in flight, which is itself pending work, so it
-/// counts as pending rather than risking a missed boost.
+/// Whether any window-instance request is waiting to be drained.
pub(crate) fn has_pending() -> bool {
match PENDING.try_lock() {
Some(q) => !q.is_empty(),
diff --git a/src/userspace/init/mod.rs b/src/userspace/init/mod.rs
index f647fc8f7a..855f47218c 100644
--- a/src/userspace/init/mod.rs
+++ b/src/userspace/init/mod.rs
@@ -16,11 +16,17 @@
mod capsule_boot;
mod entry;
+mod install_queue;
+mod wake;
+
+pub(crate) use wake::{nudge as nudge_init, owns_the_queues, settle as settle_priority};
mod instance_spawn;
mod spawn_plan;
mod supervisor;
pub use entry::run_init;
pub(crate) use instance_spawn::has_pending as instance_spawns_pending;
+pub(crate) use install_queue::request as request_install;
+pub(crate) use install_queue::service as service_installs;
pub(crate) use instance_spawn::service as service_instance_spawns;
pub use instance_spawn::{request as request_instance, PendingApp};
diff --git a/src/userspace/init/spawn_plan/apps.rs b/src/userspace/init/spawn_plan/apps.rs
index d5c5e36bfa..58738b9ec8 100644
--- a/src/userspace/init/spawn_plan/apps.rs
+++ b/src/userspace/init/spawn_plan/apps.rs
@@ -17,6 +17,7 @@
pub(super) fn spawn() {
spawn_input_proof();
spawn_about();
+ spawn_app_store();
spawn_nonos_install();
spawn_hello();
spawn_calculator();
@@ -52,6 +53,14 @@ fn spawn_about() {
#[cfg(not(feature = "nonos-capsule-about"))]
fn spawn_about() {}
+#[cfg(feature = "nonos-capsule-app-store")]
+fn spawn_app_store() {
+ use crate::userspace::capsule_app_store as c;
+ super::boot::capsule("APP-STORE", "app_store", c::spawn_app_store_capsule, c::shared_state);
+}
+#[cfg(not(feature = "nonos-capsule-app-store"))]
+fn spawn_app_store() {}
+
// The install ritual is console-only and spawns at boot; an image built
// with this feature is a live installer image by definition.
#[cfg(feature = "nonos-capsule-nonos-install")]
diff --git a/src/userspace/init/supervisor/loop_impl.rs b/src/userspace/init/supervisor/loop_impl.rs
index b533ee9bfa..6417d4b3e1 100644
--- a/src/userspace/init/supervisor/loop_impl.rs
+++ b/src/userspace/init/supervisor/loop_impl.rs
@@ -14,11 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-//! Init's residual loop after every capsule has been spawned. Walks
-//! the lifecycle registry once per second; any capsule that exited is
-//! observed `Dead` on its next IPC. The kernel does not actively
-//! probe capsules — liveness arrives through the existing process
-//! state machine.
+//! Init's residual loop after every capsule has been spawned.
use crate::process::core::Priority;
@@ -26,10 +22,13 @@ const TICK_INTERVAL_MS: u64 = 1000;
const PARK_SLICE_MS: u64 = 20;
pub(crate) fn init_loop() -> ! {
+ // Tell the queue side which process drains it.
+ if let Some(pid) = crate::process::current_pid() {
+ crate::userspace::init::owns_the_queues(pid);
+ }
let mut last_tick = 0u64;
#[cfg(feature = "microkernel-setup-wizard")]
let mut desktop_started = false;
- let mut boosted = false;
loop {
let now = crate::time::timestamp_millis();
if now >= last_tick + TICK_INTERVAL_MS {
@@ -41,34 +40,21 @@ pub(crate) fn init_loop() -> ! {
super::super::spawn_plan::spawn_post_wizard();
desktop_started = true;
}
- // Init runs at Priority::Low so an idle system spends its cycles on the
- // apps, but the window-instance drain below (and the focus-frame
- // delivery inside it) must not be starved: a busy-yielding app with a
- // network fetch in flight would otherwise keep a low-priority init off
- // the single CPU, so a dock click never opened its second window. Raise
- // to Normal while there is queued window work and drop back to Low when
- // idle, so the drain runs promptly without making an idle init costly.
- let want = crate::userspace::init::instance_spawns_pending();
- if want != boosted {
- set_init_priority(if want { Priority::Normal } else { Priority::Low });
- boosted = want;
- }
- // Perform any window-instance spawns the shell requested. Running
- // them here, in init's context, keeps the heavy spawn out of the
- // calling capsule's syscall, which is what stopped the caller from
- // resuming (it faulted on its own code under the wrong page tables).
+ // Perform any window-instance spawns the shell requested.
crate::userspace::init::service_instance_spawns();
+ crate::userspace::init::service_installs();
+ // Back to Low now the queues are empty. Raising is the
+ // producer's job; only this loop can know when to stop.
+ if !crate::userspace::init::instance_spawns_pending() {
+ crate::userspace::init::settle_priority();
+ }
park();
}
}
-// A bare yield left init permanently runnable, so `select_next_process`
-// never came up empty and the scheduler's `sti; hlt` idle path was
-// unreachable: the vCPU spun at full load with an idle desktop. Sleeping
-// on a short deadline takes init off the run queue between passes, which
-// lets the CPU actually halt, while still draining the shell's window
-// spawn requests inside one compositor frame. Falling back to the yield
-// keeps the loop live if init runs before its pid is current.
+// A bare yield left init permanently runnable, so `select_next_process` never
+// came up empty and the scheduler's `sti; hlt` idle path was unreachable: the
+// vCPU spun at full load with an idle desktop.
fn park() {
let Some(pid) = crate::process::current_pid() else {
crate::sched::yield_now();
@@ -79,9 +65,7 @@ fn park() {
crate::sched::yield_now();
}
-// Set init's own scheduling priority. Mirrors `lower_init_priority` in entry.rs;
-// used to lift the drain out of starvation while there is a window to open, then
-// return to Low when the queue is empty.
+// Set init's own scheduling priority.
fn set_init_priority(p: Priority) {
use crate::process::core::{CURRENT_PID, PROCESS_TABLE};
use core::sync::atomic::Ordering;
diff --git a/src/userspace/init/wake.rs b/src/userspace/init/wake.rs
new file mode 100644
index 0000000000..7d1b0bac16
--- /dev/null
+++ b/src/userspace/init/wake.rs
@@ -0,0 +1,56 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Raising init when work is queued for it.
+
+use core::sync::atomic::{AtomicU32, Ordering};
+
+use alloc::sync::Arc;
+
+use crate::process::core::{Priority, ProcessControlBlock, PROCESS_TABLE};
+
+/// Recorded by init itself rather than assumed.
+static INIT_PID: AtomicU32 = AtomicU32::new(0);
+
+/// Called once, by init, before it starts draining.
+pub(crate) fn owns_the_queues(pid: u32) {
+ INIT_PID.store(pid, Ordering::Release);
+}
+
+fn init_pcb() -> Option> {
+ match INIT_PID.load(Ordering::Acquire) {
+ 0 => None,
+ pid => PROCESS_TABLE.find_by_pid(pid),
+ }
+}
+
+/// Promote init so the work just queued is drained promptly, and wake it
+/// in case it is asleep between passes.
+pub(crate) fn nudge() {
+ let pid = INIT_PID.load(Ordering::Acquire);
+ if let Some(pcb) = init_pcb() {
+ *pcb.priority.lock() = Priority::Normal;
+ crate::sched::wake_process(pid);
+ }
+}
+
+/// Drop back once the queues are empty. Called by init, the only place
+/// that knows there is nothing left to do.
+pub(crate) fn settle() {
+ if let Some(pcb) = init_pcb() {
+ *pcb.priority.lock() = Priority::Low;
+ }
+}
diff --git a/src/userspace/mod.rs b/src/userspace/mod.rs
index e894b189f0..88dc223007 100644
--- a/src/userspace/mod.rs
+++ b/src/userspace/mod.rs
@@ -27,6 +27,7 @@
// `src/userspace/*_service` directory.
pub mod capsule_about;
+pub mod capsule_app_store;
pub mod capsule_linux;
pub mod capsule_attest;
pub mod capsule_audio_player;
diff --git a/tools/nonos-icon-store b/tools/nonos-icon-store
new file mode 100755
index 0000000000..ba7d7c01fc
--- /dev/null
+++ b/tools/nonos-icon-store
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+# NONOS Operating System
+# Copyright (C) 2026 NONOS Contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+"""Draw the marketplace icon as an 8-bit coverage mask.
+
+Every other icon in the set was rasterised from an SVG by a tool that is
+not in this tree, so there is nothing here to run the store glyph through.
+Rather than hand-place bytes once and leave nobody able to change it, the
+shape is written as the same primitives the SVGs use: strokes of constant
+width on a 20-unit grid, sampled to 192 square.
+
+The stroke width and the grid match `about.svg` exactly, which is what
+keeps the mark looking like it belongs beside the others rather than
+merely being the right size.
+"""
+
+import argparse
+import math
+from pathlib import Path
+
+SIZE = 192
+GRID = 20.0
+STROKE = 1.5
+# Four samples per axis. The SVG rasteriser antialiases; a hard-edged mask
+# next to fifteen smooth ones reads as a rendering bug rather than a style.
+SUPERSAMPLE = 4
+
+
+def rounded_rect_edge(px, py, x0, y0, x1, y1, r):
+ """Distance from a point to the outline of a rounded rectangle."""
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
+ hx, hy = (x1 - x0) / 2 - r, (y1 - y0) / 2 - r
+ dx, dy = abs(px - cx) - hx, abs(py - cy) - hy
+ outside = math.hypot(max(dx, 0.0), max(dy, 0.0))
+ inside = min(max(dx, dy), 0.0)
+ return abs(outside + inside - r)
+
+
+def arc_edge(px, py, cx, cy, r, lo, hi):
+ """Distance to an arc of a circle, between two angles in radians."""
+ ang = math.atan2(py - cy, px - cx)
+ if not (lo <= ang <= hi):
+ # Outside the sweep: the nearest point is an endpoint.
+ ends = [(cx + r * math.cos(a), cy + r * math.sin(a)) for a in (lo, hi)]
+ return min(math.hypot(px - ex, py - ey) for ex, ey in ends)
+ return abs(math.hypot(px - cx, py - cy) - r)
+
+
+def covered(px, py):
+ """True where the bag outline covers this point on the 20-unit grid."""
+ half = STROKE / 2
+ body = rounded_rect_edge(px, py, 3.6, 7.2, 16.4, 17.2, 1.6) <= half
+ # The handle sits above the body. Screen y grows downward, so points
+ # above the centre carry negative angles and the upward sweep is
+ # -pi..0; asking for pi..2pi draws nothing at all, silently.
+ handle = arc_edge(px, py, 10.0, 7.2, 3.1, -math.pi, 0.0) <= half
+ return body or handle
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--out", type=Path, required=True, help="the .a8 mask")
+ ap.add_argument("--svg", type=Path, help="also write the source shape")
+ args = ap.parse_args()
+
+ step = GRID / SIZE
+ sub = 1.0 / SUPERSAMPLE
+ out = bytearray(SIZE * SIZE)
+ for y in range(SIZE):
+ for x in range(SIZE):
+ hits = 0
+ for sy in range(SUPERSAMPLE):
+ for sx in range(SUPERSAMPLE):
+ px = (x + (sx + 0.5) * sub) * step
+ py = (y + (sy + 0.5) * sub) * step
+ hits += covered(px, py)
+ out[y * SIZE + x] = (hits * 255) // (SUPERSAMPLE * SUPERSAMPLE)
+ args.out.write_bytes(bytes(out))
+ print(f"{args.out}: {len(out)} bytes, {SIZE}x{SIZE}")
+
+ if args.svg:
+ args.svg.write_text(
+ ''
+ ' '
+ ' \n'
+ )
+ print(f"{args.svg}: source shape")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/userland/assets/icons/store.a8 b/userland/assets/icons/store.a8
new file mode 100644
index 0000000000..e086216b2c
Binary files /dev/null and b/userland/assets/icons/store.a8 differ
diff --git a/userland/assets/icons/store.svg b/userland/assets/icons/store.svg
new file mode 100644
index 0000000000..9db2b1c838
--- /dev/null
+++ b/userland/assets/icons/store.svg
@@ -0,0 +1 @@
+
diff --git a/userland/capsule_app_store/Capsule.mk b/userland/capsule_app_store/Capsule.mk
new file mode 100644
index 0000000000..ab24e58a19
--- /dev/null
+++ b/userland/capsule_app_store/Capsule.mk
@@ -0,0 +1,26 @@
+# app_store: the marketplace window.
+#
+# A GUI capsule that talks to one service. IPC reaches market.index,
+# Memory backs the heap, and the two graphics capabilities register and
+# present its surface. It asks for nothing else: it installs nothing
+# itself, so it needs neither ForeignExec nor any store authority, and a
+# window that can only read the catalogue cannot be turned into one that
+# rewrites it.
+#
+# It may also ask for an install, which is not the right to perform
+# one: AppInstall names a package and the personality does the work
+# under its own manifest. The window never gains ForeignExec.
+# CoreExec|IPC|Memory|GraphicsDisplayQuery|GraphicsSurfaceCreate|AppInstall
+CAPSULE_SLUG := app_store
+CAPSULE_HANDLE := app.store
+CAPSULE_DOMAIN := systems.nonos
+CAPSULE_DIR := userland/capsule_app_store
+CAPSULE_BIN_NAME := app_store
+CAPSULE_FEATURE := nonos-capsule-app-store
+CAPSULE_NAMESPACE := systems.nonos.app.store
+CAPSULE_SERVICE_ENDPOINT := service:4940:app.store
+CAPSULE_REPLY_ENDPOINT := reply:4941:endpoint.app.store.reply
+CAPSULE_REQUIRED_CAPS := 0xC0001819
+CAPSULE_KERNEL_MIRROR := src/userspace/capsule_app_store
+
+include nonos-mk/capsule.mk
diff --git a/userland/capsule_app_store/Cargo.lock b/userland/capsule_app_store/Cargo.lock
new file mode 100644
index 0000000000..a7edfaa8ca
--- /dev/null
+++ b/userland/capsule_app_store/Cargo.lock
@@ -0,0 +1,131 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "libm",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "linked_list_allocator"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897"
+dependencies = [
+ "spinning_top",
+]
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "nonos_app_skeleton"
+version = "0.3.0"
+dependencies = [
+ "nonos_toolkit",
+ "nonos_userland_libc",
+]
+
+[[package]]
+name = "nonos_app_store"
+version = "0.1.0"
+dependencies = [
+ "nonos_app_skeleton",
+ "nonos_toolkit",
+ "nonos_userland_libc",
+]
+
+[[package]]
+name = "nonos_toolkit"
+version = "0.3.0"
+dependencies = [
+ "ab_glyph",
+ "nonos_userland_libc",
+ "spin",
+]
+
+[[package]]
+name = "nonos_userland_libc"
+version = "0.3.0"
+dependencies = [
+ "linked_list_allocator",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spinning_top"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b9eb1a2f4c41445a3a0ff9abc5221c5fcd28e1f13cd7c0397706f9ac938ddb0"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+dependencies = [
+ "core_maths",
+]
diff --git a/userland/capsule_app_store/Cargo.toml b/userland/capsule_app_store/Cargo.toml
new file mode 100644
index 0000000000..2bbb648ad0
--- /dev/null
+++ b/userland/capsule_app_store/Cargo.toml
@@ -0,0 +1,43 @@
+# NONOS userland: capsule_app_store
+# eK@nonos.systems
+#
+# The marketplace window. Reads the catalogue the market capsule serves
+# over `market.index`, groups it by the three namespaces the operator
+# signs (NONOS capsules, Linux packages, community submissions), and for
+# the selected listing shows every install gate with its own verdict so a
+# refusal names what refused rather than greying out a button.
+#
+# Holds no install logic and no payment logic: this displays the index
+# authority's answers and does not form its own.
+
+[package]
+name = "nonos_app_store"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "AGPL-3.0"
+authors = ["eK@nonos.systems"]
+description = "NONOS marketplace window"
+
+build = "build.rs"
+
+[[bin]]
+name = "app_store"
+path = "src/main.rs"
+
+[dependencies]
+nonos_libc = { package = "nonos_userland_libc", path = "../libc" }
+nonos_app_skeleton = { path = "../app_skeleton" }
+nonos_toolkit = { path = "../toolkit", default-features = false }
+
+[profile.release]
+panic = "abort"
+opt-level = 2
+lto = false
+debug = false
+strip = true
+
+[profile.dev]
+panic = "abort"
+opt-level = 0
+debug = true
diff --git a/userland/capsule_app_store/build.rs b/userland/capsule_app_store/build.rs
new file mode 100644
index 0000000000..1c62a00fa8
--- /dev/null
+++ b/userland/capsule_app_store/build.rs
@@ -0,0 +1,53 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+use std::env;
+use std::process::Command;
+
+fn main() {
+ let sha = resolve_sha();
+ println!("cargo:rustc-env=ABOUT_GIT_SHA={sha}");
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=src");
+ println!("cargo:rerun-if-changed=../../LICENSE");
+ println!("cargo:rerun-if-env-changed=NONOS_BUILD_SHA");
+ println!("cargo:rerun-if-env-changed=GITHUB_SHA");
+}
+
+fn resolve_sha() -> String {
+ if let Ok(sha) = env::var("NONOS_BUILD_SHA") {
+ if !sha.trim().is_empty() {
+ return sha.trim().chars().take(12).collect();
+ }
+ }
+ if let Ok(sha) = env::var("GITHUB_SHA") {
+ if !sha.trim().is_empty() {
+ return sha.trim().chars().take(12).collect();
+ }
+ }
+ if let Some(sha) = Command::new("git")
+ .args(["rev-parse", "--short=12", "HEAD"])
+ .output()
+ .ok()
+ .and_then(|o| if o.status.success() { String::from_utf8(o.stdout).ok() } else { None })
+ .map(|s| s.trim().to_string())
+ {
+ if !sha.is_empty() {
+ return sha;
+ }
+ }
+ "unknown".into()
+}
diff --git a/userland/capsule_app_store/src/main.rs b/userland/capsule_app_store/src/main.rs
new file mode 100644
index 0000000000..0d9c1101c8
--- /dev/null
+++ b/userland/capsule_app_store/src/main.rs
@@ -0,0 +1,31 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+#![no_std]
+#![no_main]
+
+extern crate alloc;
+
+mod store;
+
+use nonos_app_skeleton::run;
+
+/// # Safety The loader calls this once, on a fresh stack, as the process entry
+/// point.
+#[no_mangle]
+pub unsafe extern "C" fn _start() -> ! {
+ run(store::Store::new)
+}
diff --git a/userland/capsule_app_store/src/store/app.rs b/userland/capsule_app_store/src/store/app.rs
new file mode 100644
index 0000000000..d8ae81e6c6
--- /dev/null
+++ b/userland/capsule_app_store/src/store/app.rs
@@ -0,0 +1,44 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+use nonos_app_skeleton::{App, AppManifest, EventOutcome, InputEvent, PaintBuffer};
+
+use super::event::on_event;
+use super::manifest::manifest;
+use super::state::State;
+use super::ui::frame;
+
+pub struct Store {
+ state: State,
+}
+
+impl Store {
+ pub fn new() -> Self {
+ Store { state: State::new() }
+ }
+}
+
+impl App for Store {
+ fn manifest(&self) -> AppManifest {
+ manifest()
+ }
+ fn on_event(&mut self, event: InputEvent) -> EventOutcome {
+ on_event(&mut self.state, event)
+ }
+ fn paint(&mut self, fb: &mut PaintBuffer) {
+ frame(&mut self.state, fb);
+ }
+}
diff --git a/userland/capsule_app_store/src/store/consent.rs b/userland/capsule_app_store/src/store/consent.rs
new file mode 100644
index 0000000000..1fe1cbad7d
--- /dev/null
+++ b/userland/capsule_app_store/src/store/consent.rs
@@ -0,0 +1,64 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Consenting to run what this machine installs.
+
+use nonos_libc::{mk_dev_root_confirm, mk_dev_root_local};
+
+/// The challenge is a 32-bit number, so ten digits is every value it
+/// can take and one more would be a typo rather than a longer code.
+const MAX_DIGITS: usize = 10;
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Consent {
+ /// Nothing asked for yet.
+ Idle,
+ /// A code is on the console and these are the digits typed so far.
+ Typing(u32, u8),
+ Granted,
+ Refused,
+}
+
+impl Consent {
+ /// The code goes to the console, not to the return value.
+ pub fn begin() -> Consent {
+ match mk_dev_root_local() {
+ 0 => Consent::Typing(0, 0),
+ _ => Consent::Refused,
+ }
+ }
+
+ pub fn digit(self, d: u32) -> Consent {
+ match self {
+ Consent::Typing(v, n) if (n as usize) < MAX_DIGITS => {
+ Consent::Typing(v.wrapping_mul(10).wrapping_add(d), n + 1)
+ }
+ other => other,
+ }
+ }
+
+ pub fn submit(self) -> Consent {
+ let Consent::Typing(code, n) = self else { return self };
+ if n == 0 {
+ return self;
+ }
+ match mk_dev_root_confirm(code) {
+ n if n < 0 => Consent::Refused,
+ _ => Consent::Granted,
+ }
+ }
+
+}
diff --git a/userland/capsule_app_store/src/store/event.rs b/userland/capsule_app_store/src/store/event.rs
new file mode 100644
index 0000000000..f6ca93921b
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event.rs
@@ -0,0 +1,48 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Input.
+
+use nonos_app_skeleton::{EventOutcome, InputEvent, InputKind, KEY_ESC};
+
+use super::event_click::on_click;
+use super::event_keys::on_key;
+use super::state::State;
+
+pub fn on_event(state: &mut State, event: InputEvent) -> EventOutcome {
+ if event.kind == InputKind::ButtonDown {
+ return on_click(state, event.x, event.y);
+ }
+ // A wheel travels the list and leaves the selection alone.
+ if event.kind == InputKind::Wheel {
+ let rows = -(event.delta_y.signum() as isize) * 3;
+ return match state.scroll_by(rows) {
+ true => EventOutcome::Repaint,
+ false => EventOutcome::Idle,
+ };
+ }
+ if !event.is_key_down() {
+ return EventOutcome::Idle;
+ }
+ /*
+ * Escape closes the window, unless the search field has it: see
+ * `event_search`, which gives it back.
+ */
+ if event.code == KEY_ESC && !state.search.active {
+ return EventOutcome::Close;
+ }
+ on_key(state, event.code)
+}
diff --git a/userland/capsule_app_store/src/store/event_actions.rs b/userland/capsule_app_store/src/store/event_actions.rs
new file mode 100644
index 0000000000..3c09f5ac3c
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event_actions.rs
@@ -0,0 +1,57 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The keys that do something rather than move somewhere.
+
+use nonos_app_skeleton::{EventOutcome, KEY_ENTER};
+
+use super::consent::Consent;
+use super::install;
+use super::state::State;
+
+const KEY_E: u32 = b'e' as u32;
+const KEY_R: u32 = b'r' as u32;
+
+pub(super) fn act(state: &mut State, code: u32) -> EventOutcome {
+ let changed = match code {
+ KEY_E => {
+ state.consent = Consent::begin();
+ true
+ }
+ // Enter confirms a typed code, installs otherwise.
+ KEY_ENTER => {
+ match state.consent {
+ Consent::Typing(..) => state.consent = state.consent.submit(),
+ _ => state.asked = Some(install::ask(state)),
+ }
+ true
+ }
+ d if (b'0' as u32..=b'9' as u32).contains(&d) => {
+ state.consent = state.consent.digit(d - b'0' as u32);
+ true
+ }
+ KEY_R => {
+ state.asked = None;
+ state.refresh();
+ true
+ }
+ _ => false,
+ };
+ match changed {
+ true => EventOutcome::Repaint,
+ false => EventOutcome::Idle,
+ }
+}
diff --git a/userland/capsule_app_store/src/store/event_click.rs b/userland/capsule_app_store/src/store/event_click.rs
new file mode 100644
index 0000000000..362600fc9e
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event_click.rs
@@ -0,0 +1,45 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The pointer.
+
+use nonos_app_skeleton::EventOutcome;
+
+use super::state::{State, TABS};
+use super::ui::chrome::tab_rect;
+use super::ui::metrics::{HEAD_H, PAD_TOP, TAB_H};
+
+/// The tab strip first, then the list.
+pub fn on_click(state: &mut State, x: i32, y: i32) -> EventOutcome {
+ if x < 0 || y < 0 {
+ return EventOutcome::Idle;
+ }
+ let top = (PAD_TOP + HEAD_H) as i32;
+ if y < top || y >= top + TAB_H as i32 {
+ return super::event_rows::on_list_click(state, x, y);
+ }
+ match hit(x as u32) {
+ Some(i) if state.set_tab(TABS[i]) => EventOutcome::Repaint,
+ _ => EventOutcome::Idle,
+ }
+}
+
+fn hit(x: u32) -> Option {
+ (0..TABS.len()).find(|&i| {
+ let (left, w) = tab_rect(i);
+ x >= left && x < left + w
+ })
+}
diff --git a/userland/capsule_app_store/src/store/event_keys.rs b/userland/capsule_app_store/src/store/event_keys.rs
new file mode 100644
index 0000000000..f714134dd2
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event_keys.rs
@@ -0,0 +1,61 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Which key does what.
+
+use nonos_app_skeleton::{
+ EventOutcome, KEY_DOWN, KEY_END, KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT,
+ KEY_UP,
+};
+
+use super::event_actions::act;
+use super::event_search::typing;
+use super::event_tab::step_tab;
+
+use super::state::State;
+
+const KEY_SLASH: u32 = b'/' as u32;
+
+pub fn on_key(state: &mut State, code: u32) -> EventOutcome {
+ /*
+ * The field takes the keyboard while open, or a name with a digit in it
+ * types into the consent code.
+ */
+ if state.search.active {
+ if let Some(outcome) = typing(state, code) {
+ return outcome;
+ }
+ }
+ let changed = match code {
+ KEY_UP => state.move_by(-1),
+ KEY_DOWN => state.move_by(1),
+ KEY_PAGE_UP => state.move_by(-(state.rows as isize)),
+ KEY_PAGE_DOWN => state.move_by(state.rows as isize),
+ KEY_HOME => state.move_by(isize::MIN / 2),
+ KEY_END => state.move_by(isize::MAX / 2),
+ KEY_LEFT => step_tab(state, -1),
+ KEY_RIGHT => step_tab(state, 1),
+ KEY_SLASH => {
+ state.search.open();
+ true
+ }
+ c => return act(state, c),
+ };
+ match changed {
+ true => EventOutcome::Repaint,
+ false => EventOutcome::Idle,
+ }
+}
diff --git a/userland/capsule_app_store/src/store/event_rows.rs b/userland/capsule_app_store/src/store/event_rows.rs
new file mode 100644
index 0000000000..0f580c51e5
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event_rows.rs
@@ -0,0 +1,49 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Clicking a card.
+
+use nonos_app_skeleton::EventOutcome;
+
+use super::install;
+use super::state::State;
+use super::ui::geometry::{list_top, list_w, on_action, row_top, slot_at};
+use super::ui::metrics::PAD_X;
+
+pub fn on_list_click(state: &mut State, x: i32, y: i32) -> EventOutcome {
+ if y < list_top() as i32 || x < PAD_X as i32 {
+ return EventOutcome::Idle;
+ }
+ let width = list_w(state.fb_w);
+ if x >= (PAD_X + width) as i32 {
+ return EventOutcome::Idle;
+ }
+ let Some(slot) = slot_at(y, state.rows) else {
+ return EventOutcome::Idle;
+ };
+ if state.scroll + slot >= state.visible().len() {
+ return EventOutcome::Idle;
+ }
+ let moved = state.select_slot(slot);
+ if on_action(x, y, PAD_X, row_top(slot), width) {
+ state.asked = Some(install::ask(state));
+ return EventOutcome::Repaint;
+ }
+ match moved {
+ true => EventOutcome::Repaint,
+ false => EventOutcome::Idle,
+ }
+}
diff --git a/userland/capsule_app_store/src/store/event_search.rs b/userland/capsule_app_store/src/store/event_search.rs
new file mode 100644
index 0000000000..7c9423b7fc
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event_search.rs
@@ -0,0 +1,65 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The keyboard while the search field is open.
+
+use nonos_app_skeleton::{EventOutcome, KEY_BACKSPACE, KEY_ENTER, KEY_ESC};
+
+use super::state::State;
+
+pub fn typing(state: &mut State, code: u32) -> Option {
+ match code {
+ // Escape leaves the field rather than closing the window.
+ KEY_ESC => {
+ state.search.close();
+ reset(state);
+ Some(EventOutcome::Repaint)
+ }
+ /*
+ * Enter keeps the filter and gives the keyboard back, so the
+ * next Enter installs what was found.
+ */
+ KEY_ENTER => {
+ state.search.active = false;
+ Some(EventOutcome::Repaint)
+ }
+ KEY_BACKSPACE => {
+ let changed = state.search.pop();
+ reset(state);
+ Some(match changed {
+ true => EventOutcome::Repaint,
+ false => EventOutcome::Idle,
+ })
+ }
+ c if (0x20..0x7F).contains(&c) => {
+ let changed = state.search.push(c as u8);
+ reset(state);
+ Some(match changed {
+ true => EventOutcome::Repaint,
+ false => EventOutcome::Idle,
+ })
+ }
+ _ => None,
+ }
+}
+
+/// The list under the cursor just changed, so the cursor cannot stay where it
+/// was: it would point past the end, or at a row the query no longer keeps.
+fn reset(state: &mut State) {
+ state.cursor = 0;
+ state.scroll = 0;
+ state.select();
+}
diff --git a/userland/capsule_app_store/src/store/event_tab.rs b/userland/capsule_app_store/src/store/event_tab.rs
new file mode 100644
index 0000000000..0bd9530c55
--- /dev/null
+++ b/userland/capsule_app_store/src/store/event_tab.rs
@@ -0,0 +1,24 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//! Walking the tab strip with the arrow keys.
+
+use super::state::{State, TABS};
+
+pub(super) fn step_tab(state: &mut State, delta: isize) -> bool {
+ let at = TABS.iter().position(|t| *t == state.tab).unwrap_or(0) as isize;
+ let want = (at + delta).clamp(0, TABS.len() as isize - 1) as usize;
+ state.set_tab(TABS[want])
+}
diff --git a/userland/capsule_app_store/src/store/install.rs b/userland/capsule_app_store/src/store/install.rs
new file mode 100644
index 0000000000..6446ab734a
--- /dev/null
+++ b/userland/capsule_app_store/src/store/install.rs
@@ -0,0 +1,59 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Asking for the selected listing to be installed.
+
+use nonos_libc::mk_app_install;
+
+use super::state::State;
+
+/// What the user is told after asking.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Asked {
+ Queued,
+ Busy,
+ Refused,
+ NotInstallable,
+}
+
+impl Asked {
+ pub fn label(self) -> &'static [u8] {
+ match self {
+ Asked::Queued => b"install requested",
+ Asked::Busy => b"too many installs already queued",
+ Asked::Refused => b"the system refused the request",
+ Asked::NotInstallable => b"nothing to fetch for this listing",
+ }
+ }
+}
+
+pub fn ask(state: &State) -> Asked {
+ let Some(listing) = state.current() else {
+ return Asked::NotInstallable;
+ };
+ // Only a distribution package has anything to fetch.
+ let Some(package) = listing.id.strip_prefix(b"linux.".as_slice()) else {
+ return Asked::NotInstallable;
+ };
+ if !listing.ready {
+ return Asked::NotInstallable;
+ }
+ match mk_app_install(package) {
+ 0 => Asked::Queued,
+ -16 => Asked::Busy,
+ _ => Asked::Refused,
+ }
+}
diff --git a/userland/capsule_app_store/src/store/listing.rs b/userland/capsule_app_store/src/store/listing.rs
new file mode 100644
index 0000000000..799de26eb7
--- /dev/null
+++ b/userland/capsule_app_store/src/store/listing.rs
@@ -0,0 +1,61 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! One row of the catalogue.
+
+use alloc::vec::Vec;
+
+/// Where a listing came from, read off the namespace its id starts with.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Source {
+ NonOs,
+ Linux,
+ Community,
+}
+
+impl Source {
+ pub fn of(listing_id: &[u8]) -> Source {
+ match listing_id {
+ id if id.starts_with(b"linux.") => Source::Linux,
+ id if id.starts_with(b"community.") => Source::Community,
+ _ => Source::NonOs,
+ }
+ }
+
+ pub fn label(self) -> &'static [u8] {
+ match self {
+ Source::NonOs => b"NONOS",
+ Source::Linux => b"Linux",
+ Source::Community => b"Community",
+ }
+ }
+}
+
+pub struct Listing {
+ pub id: Vec,
+ pub measurement: [u8; 32],
+ pub name: Vec,
+ /// The market capsule's verdict across every install gate, taken as given.
+ pub ready: bool,
+ pub source: Source,
+}
+
+impl Listing {
+ pub fn new(id: Vec, measurement: [u8; 32], name: Vec, ready: bool) -> Listing {
+ let source = Source::of(&id);
+ Listing { id, measurement, name, ready, source }
+ }
+}
diff --git a/userland/capsule_app_store/src/store/manifest.rs b/userland/capsule_app_store/src/store/manifest.rs
new file mode 100644
index 0000000000..e028f9d999
--- /dev/null
+++ b/userland/capsule_app_store/src/store/manifest.rs
@@ -0,0 +1,43 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+use nonos_app_skeleton::{AppManifest, WindowKind};
+
+use super::ui::metrics::{WIN_H, WIN_W, WIN_X, WIN_Y};
+
+const WINDOW_ID: u32 = 0x4150_5053;
+
+/*
+ * Keys drive the list and the category; the tab strip is clickable, so the
+ * button and the absolute pointer are subscribed to keep those coordinates
+ * current.
+ */
+const INPUT_KEY_DOWN_BIT: u32 = 1 << 0;
+const INPUT_POINTER_ABS_BIT: u32 = 1 << 3;
+const INPUT_BUTTON_DOWN_BIT: u32 = 1 << 5;
+
+pub fn manifest() -> AppManifest {
+ AppManifest {
+ title: "NØNOS Marketplace".as_bytes(),
+ window_id: WINDOW_ID,
+ kind: WindowKind::Normal,
+ initial_x: WIN_X,
+ initial_y: WIN_Y,
+ width: WIN_W,
+ height: WIN_H,
+ input_kind_mask: INPUT_KEY_DOWN_BIT | INPUT_BUTTON_DOWN_BIT | INPUT_POINTER_ABS_BIT,
+ }
+}
diff --git a/userland/capsule_app_store/src/store/market/detail.rs b/userland/capsule_app_store/src/store/market/detail.rs
new file mode 100644
index 0000000000..47cd2b7923
--- /dev/null
+++ b/userland/capsule_app_store/src/store/market/detail.rs
@@ -0,0 +1,55 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Everything about one listing that the list reply leaves out.
+
+use alloc::vec::Vec;
+
+use super::wire::call;
+
+const OP_GET_APP: u16 = 3;
+
+pub struct Detail {
+ pub publisher: Vec,
+ pub description: Vec,
+}
+
+pub fn fetch(port: u32, request_id: u32, listing: &[u8]) -> Option {
+ let mut body = Vec::with_capacity(4 + listing.len());
+ body.extend_from_slice(&(listing.len() as u32).to_le_bytes());
+ body.extend_from_slice(listing);
+ let out = call(port, OP_GET_APP, request_id, &body)?;
+
+ // listing_id, capsule_id, name, publisher, pubkey, description, count
+ let (_, at) = lp(&out, 0)?;
+ let at = at + 32;
+ let (_, at) = lp(&out, at)?;
+ let (publisher, at) = lp(&out, at)?;
+ let at = at + 32;
+ let (description, at) = lp(&out, at)?;
+ // The release count closes the message.
+ out.get(at..at + 4)?;
+ Some(Detail { publisher, description })
+}
+
+/// Four bytes of length then the bytes, every bound checked against the
+/// buffer that arrived rather than the length claiming to describe it.
+fn lp(body: &[u8], at: usize) -> Option<(Vec, usize)> {
+ let len = u32::from_le_bytes(body.get(at..at + 4)?.try_into().ok()?) as usize;
+ let start = at + 4;
+ let end = start.checked_add(len)?;
+ Some((body.get(start..end)?.to_vec(), end))
+}
diff --git a/userland/capsule_app_store/src/store/market/list.rs b/userland/capsule_app_store/src/store/market/list.rs
new file mode 100644
index 0000000000..3bbd9f7c4f
--- /dev/null
+++ b/userland/capsule_app_store/src/store/market/list.rs
@@ -0,0 +1,52 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The catalogue, as the market capsule serves it.
+
+use alloc::vec::Vec;
+
+use crate::store::listing::Listing;
+
+use super::wire::call;
+
+const OP_LIST_APPS: u16 = 2;
+
+pub fn fetch(port: u32, request_id: u32) -> Option> {
+ let body = call(port, OP_LIST_APPS, request_id, &[])?;
+ let count = u32::from_le_bytes(body.get(..4)?.try_into().ok()?) as usize;
+ let mut at = 4;
+ let mut out = Vec::with_capacity(count.min(1024));
+ for _ in 0..count {
+ let (id, next) = lp(&body, at)?;
+ at = next;
+ let measurement: [u8; 32] = body.get(at..at + 32)?.try_into().ok()?;
+ at += 32;
+ let (name, next) = lp(&body, at)?;
+ at = next;
+ let ready = *body.get(at)? != 0;
+ at += 1;
+ out.push(Listing::new(id, measurement, name, ready));
+ }
+ Some(out)
+}
+
+/// A length-prefixed string: four bytes of length, then the bytes.
+fn lp(body: &[u8], at: usize) -> Option<(Vec, usize)> {
+ let len = u32::from_le_bytes(body.get(at..at + 4)?.try_into().ok()?) as usize;
+ let start = at + 4;
+ let end = start.checked_add(len)?;
+ Some((body.get(start..end)?.to_vec(), end))
+}
diff --git a/userland/capsule_app_store/src/store/market/mod.rs b/userland/capsule_app_store/src/store/market/mod.rs
new file mode 100644
index 0000000000..d8a5bd8402
--- /dev/null
+++ b/userland/capsule_app_store/src/store/market/mod.rs
@@ -0,0 +1,28 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Talking to the market capsule.
+
+mod detail;
+mod list;
+mod ready;
+mod service;
+mod wire;
+
+pub use detail::{fetch as get_app, Detail};
+pub use list::fetch as list_apps;
+pub use ready::{fetch as install_ready, Readiness, GATES};
+pub use service::{next_id, port};
diff --git a/userland/capsule_app_store/src/store/market/ready.rs b/userland/capsule_app_store/src/store/market/ready.rs
new file mode 100644
index 0000000000..33a19e4c8f
--- /dev/null
+++ b/userland/capsule_app_store/src/store/market/ready.rs
@@ -0,0 +1,55 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Why a listing cannot be installed.
+
+use super::wire::call;
+
+const OP_INSTALL_READY: u16 = 5;
+
+/// The six gates, in the order the capsule writes them after the verdict.
+pub const GATES: [&[u8]; 6] = [
+ b"index signature",
+ b"package present",
+ b"publisher signature",
+ b"operator validation",
+ b"architecture",
+ b"attestation",
+];
+
+#[derive(Clone, Copy)]
+pub struct Readiness {
+ pub install_ready: bool,
+ pub gates: [bool; 6],
+}
+
+pub fn fetch(port: u32, request_id: u32, listing: &[u8], release: &[u8]) -> Option {
+ let mut body = alloc::vec::Vec::with_capacity(8 + listing.len() + release.len());
+ body.extend_from_slice(&(listing.len() as u32).to_le_bytes());
+ body.extend_from_slice(listing);
+ body.extend_from_slice(&(release.len() as u32).to_le_bytes());
+ body.extend_from_slice(release);
+ let out = call(port, OP_INSTALL_READY, request_id, &body)?;
+ // Seven bytes: the verdict then one per gate.
+ if out.len() < 1 + GATES.len() {
+ return None;
+ }
+ let mut gates = [false; 6];
+ for (i, g) in gates.iter_mut().enumerate() {
+ *g = out[1 + i] != 0;
+ }
+ Some(Readiness { install_ready: out[0] != 0, gates })
+}
diff --git a/userland/capsule_app_store/src/store/market/service.rs b/userland/capsule_app_store/src/store/market/service.rs
new file mode 100644
index 0000000000..f73db724b1
--- /dev/null
+++ b/userland/capsule_app_store/src/store/market/service.rs
@@ -0,0 +1,48 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Where the market capsule is, and a request id to reach it with.
+
+use core::sync::atomic::{AtomicU32, Ordering};
+
+use nonos_libc::mk_service_lookup;
+
+/// The service the market capsule announces itself under.
+const SERVICE: &[u8] = b"market.index";
+
+/// Request ids only have to differ from this process's other in-flight
+/// calls, so a counter is enough and it never needs to survive a restart.
+static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+
+pub fn next_id() -> u32 {
+ NEXT_ID.fetch_add(1, Ordering::Relaxed)
+}
+
+/// The market's port, or zero when it has not announced one.
+pub fn port() -> u32 {
+ let mut pid: u32 = 0;
+ let mut port: u32 = 0;
+ let rc = mk_service_lookup(
+ SERVICE.as_ptr(),
+ SERVICE.len(),
+ &mut port as *mut u32,
+ &mut pid as *mut u32,
+ );
+ if rc < 0 || pid == 0 {
+ return 0;
+ }
+ port
+}
diff --git a/userland/capsule_app_store/src/store/market/wire.rs b/userland/capsule_app_store/src/store/market/wire.rs
new file mode 100644
index 0000000000..f586ac4f81
--- /dev/null
+++ b/userland/capsule_app_store/src/store/market/wire.rs
@@ -0,0 +1,67 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! One call to the market service, framed the way it frames replies.
+
+use alloc::vec;
+use alloc::vec::Vec;
+
+use nonos_libc::mk_ipc_call_timeout;
+
+const MAGIC: u32 = 0x4E4D_4B54;
+const VERSION: u16 = 1;
+pub const HDR_LEN: usize = 20;
+const STATUS_LEN: usize = 4;
+
+/// A catalogue reply carries every listing, so this is sized for the
+/// catalogue rather than for one entry.
+const RX_CAP: usize = 96 << 10;
+
+/// Long enough for the capsule to walk its index, short enough that a
+/// service that has stopped answering does not freeze a repaint.
+const TIMEOUT_MS: u64 = 1500;
+
+pub fn call(port: u32, op: u16, request_id: u32, body: &[u8]) -> Option> {
+ if port == 0 {
+ return None;
+ }
+ let mut tx = Vec::with_capacity(HDR_LEN + body.len());
+ tx.extend_from_slice(&MAGIC.to_le_bytes());
+ tx.extend_from_slice(&VERSION.to_le_bytes());
+ tx.extend_from_slice(&op.to_le_bytes());
+ tx.extend_from_slice(&0u16.to_le_bytes());
+ tx.extend_from_slice(&0u16.to_le_bytes());
+ tx.extend_from_slice(&request_id.to_le_bytes());
+ tx.extend_from_slice(&(body.len() as u32).to_le_bytes());
+ tx.extend_from_slice(body);
+
+ let mut rx = vec![0u8; RX_CAP];
+ let rc =
+ mk_ipc_call_timeout(port as u64, tx.as_ptr(), tx.len(), rx.as_mut_ptr(), rx.len(), TIMEOUT_MS);
+ let got = usize::try_from(rc).ok()?;
+ if got < HDR_LEN + STATUS_LEN {
+ return None;
+ }
+ let status = i32::from_le_bytes(rx.get(HDR_LEN..HDR_LEN + STATUS_LEN)?.try_into().ok()?);
+ if status != 0 {
+ return None;
+ }
+ /*
+ * The status word is part of the body on this protocol, and every reader
+ * here wants what follows it.
+ */
+ Some(rx.get(HDR_LEN + STATUS_LEN..got)?.to_vec())
+}
diff --git a/userland/capsule_app_store/src/store/mod.rs b/userland/capsule_app_store/src/store/mod.rs
new file mode 100644
index 0000000000..1e8387bc0a
--- /dev/null
+++ b/userland/capsule_app_store/src/store/mod.rs
@@ -0,0 +1,46 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The marketplace window: the catalogue the market capsule serves, the three
+//! namespaces it carries, and why any one listing can or cannot be installed
+//! on this machine.
+
+mod app;
+mod event;
+mod consent;
+mod event_actions;
+mod event_click;
+mod event_keys;
+mod event_rows;
+mod event_search;
+mod event_tab;
+mod install;
+mod listing;
+pub mod market;
+mod manifest;
+pub mod search;
+mod state;
+mod state_move;
+mod state_refresh;
+mod state_select;
+mod state_window;
+mod tab;
+mod state_ops;
+mod theme;
+mod ui;
+mod verdict;
+
+pub use app::Store;
diff --git a/userland/capsule_app_store/src/store/search.rs b/userland/capsule_app_store/src/store/search.rs
new file mode 100644
index 0000000000..4cb8a9790d
--- /dev/null
+++ b/userland/capsule_app_store/src/store/search.rs
@@ -0,0 +1,72 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The query, and what it matches.
+
+use alloc::vec::Vec;
+
+/// Longer than any name listed, so a held key cannot grow the field.
+const MAX: usize = 64;
+
+#[derive(Default)]
+pub struct Search {
+ pub active: bool,
+ text: Vec,
+}
+
+impl Search {
+ pub fn text(&self) -> &[u8] {
+ &self.text
+ }
+
+ /// Keeps what is typed: reopening to add a letter should not
+ /// discard the word.
+ pub fn open(&mut self) {
+ self.active = true;
+ }
+
+ /// Leave and clear: a closed field that went on filtering would
+ /// hide rows with nothing on screen to say why.
+ pub fn close(&mut self) {
+ self.active = false;
+ self.text.clear();
+ }
+
+ pub fn push(&mut self, byte: u8) -> bool {
+ if self.text.len() >= MAX {
+ return false;
+ }
+ self.text.push(byte.to_ascii_lowercase());
+ true
+ }
+
+ pub fn pop(&mut self) -> bool {
+ self.text.pop().is_some()
+ }
+
+ /// Case-insensitive substring. Empty accepts everything; longer
+ /// than the name matches nothing rather than panicking.
+ pub fn accepts(&self, name: &[u8]) -> bool {
+ if self.text.is_empty() {
+ return true;
+ }
+ if self.text.len() > name.len() {
+ return false;
+ }
+ let lower = |b: &u8| b.to_ascii_lowercase();
+ name.windows(self.text.len()).any(|w| w.iter().map(lower).eq(self.text.iter().copied()))
+ }
+}
diff --git a/userland/capsule_app_store/src/store/state.rs b/userland/capsule_app_store/src/store/state.rs
new file mode 100644
index 0000000000..595c4a50d8
--- /dev/null
+++ b/userland/capsule_app_store/src/store/state.rs
@@ -0,0 +1,47 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! What the window is showing.
+
+pub use super::tab::{Tab, TABS};
+
+use alloc::vec::Vec;
+
+use super::listing::Listing;
+use super::market;
+
+
+pub struct State {
+ pub listings: Vec,
+ pub tab: Tab,
+ pub cursor: usize,
+ pub scroll: usize,
+ pub rows: usize,
+ pub fb_w: u32,
+ pub fb_h: u32,
+ /// Set when the catalogue could not be read.
+ pub trouble: Option<&'static [u8]>,
+ pub ready: Option,
+ /// What the last install request was answered with, shown until the next
+ /// one.
+ pub asked: Option,
+ /// Where enrolment has got to.
+ pub consent: super::consent::Consent,
+ /// Description and publisher for the selected listing, fetched once per
+ /// selection.
+ pub search: super::search::Search,
+ pub detail: Option,
+}
diff --git a/userland/capsule_app_store/src/store/state_move.rs b/userland/capsule_app_store/src/store/state_move.rs
new file mode 100644
index 0000000000..e6797625f3
--- /dev/null
+++ b/userland/capsule_app_store/src/store/state_move.rs
@@ -0,0 +1,56 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The cursor and the window onto the list.
+
+use super::state::{State, Tab};
+
+impl State {
+ pub fn move_by(&mut self, delta: isize) -> bool {
+ let n = self.visible().len();
+ if n == 0 {
+ return false;
+ }
+ let want = (self.cursor as isize + delta).clamp(0, n as isize - 1) as usize;
+ if want == self.cursor {
+ return false;
+ }
+ self.cursor = want;
+ self.follow();
+ self.select();
+ true
+ }
+
+ /// Keep the cursor inside the rows the pane can show.
+ fn follow(&mut self) {
+ if self.cursor < self.scroll {
+ self.scroll = self.cursor;
+ } else if self.cursor >= self.scroll + self.rows {
+ self.scroll = self.cursor + 1 - self.rows;
+ }
+ }
+
+ pub fn set_tab(&mut self, tab: Tab) -> bool {
+ if self.tab == tab {
+ return false;
+ }
+ self.tab = tab;
+ self.cursor = 0;
+ self.scroll = 0;
+ self.select();
+ true
+ }
+}
diff --git a/userland/capsule_app_store/src/store/state_ops.rs b/userland/capsule_app_store/src/store/state_ops.rs
new file mode 100644
index 0000000000..09de3ca200
--- /dev/null
+++ b/userland/capsule_app_store/src/store/state_ops.rs
@@ -0,0 +1,50 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Moving through the catalogue.
+
+use alloc::vec::Vec;
+
+use super::state::{State, Tab};
+
+impl State {
+ pub fn new() -> State {
+ let mut state = State {
+ listings: Vec::new(),
+ tab: Tab::All,
+ cursor: 0,
+ scroll: 0,
+ rows: 1,
+ fb_w: 0,
+ fb_h: 0,
+ trouble: None,
+ ready: None,
+ asked: None,
+ consent: super::consent::Consent::Idle,
+ search: super::search::Search::default(),
+ detail: None,
+ };
+ state.refresh();
+ state
+ }
+
+ /// Indices into `listings` that the current tab shows.
+ pub fn visible(&self) -> Vec {
+ let keep =
+ |(i, l): (usize, &super::listing::Listing)| self.tab.accepts(l.source).then_some(i);
+ self.listings.iter().enumerate().filter_map(keep).collect()
+ }
+}
diff --git a/userland/capsule_app_store/src/store/state_refresh.rs b/userland/capsule_app_store/src/store/state_refresh.rs
new file mode 100644
index 0000000000..a5cc25c47e
--- /dev/null
+++ b/userland/capsule_app_store/src/store/state_refresh.rs
@@ -0,0 +1,49 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Fetching the catalogue.
+
+use super::market;
+use super::state::State;
+
+impl State {
+ /// Ask the market for the catalogue again.
+ pub fn refresh(&mut self) {
+ let port = market::port();
+ if port == 0 {
+ self.listings.clear();
+ self.trouble = Some(b"market service has not announced itself");
+ return;
+ }
+ match market::list_apps(port, market::next_id()) {
+ Some(found) => {
+ self.listings = found;
+ self.trouble = None;
+ }
+ /*
+ * The call failed, which is not the same as the catalogue being
+ * empty and must not be reported as it.
+ */
+ None => {
+ self.listings.clear();
+ self.trouble = Some(b"market did not answer");
+ }
+ }
+ self.cursor = 0;
+ self.scroll = 0;
+ self.select();
+ }
+}
diff --git a/userland/capsule_app_store/src/store/state_select.rs b/userland/capsule_app_store/src/store/state_select.rs
new file mode 100644
index 0000000000..e8115347fc
--- /dev/null
+++ b/userland/capsule_app_store/src/store/state_select.rs
@@ -0,0 +1,40 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! What selecting a listing costs.
+
+use super::market;
+use super::state::State;
+
+impl State {
+ /// Ask the market why the selected listing can or cannot be installed.
+ pub fn select(&mut self) {
+ self.ready = None;
+ self.detail = None;
+ let Some(listing) = self.current() else { return };
+ let (id, port) = (listing.id.clone(), market::port());
+ self.detail = market::get_app(port, market::next_id(), &id);
+ /*
+ * The release is left unnamed because the capsule resolves the default
+ * when it is.
+ */
+ self.ready = market::install_ready(port, market::next_id(), &id, &[]);
+ }
+
+ pub fn current(&self) -> Option<&super::listing::Listing> {
+ self.listings.get(*self.visible().get(self.cursor)?)
+ }
+}
diff --git a/userland/capsule_app_store/src/store/state_window.rs b/userland/capsule_app_store/src/store/state_window.rs
new file mode 100644
index 0000000000..02feb215a8
--- /dev/null
+++ b/userland/capsule_app_store/src/store/state_window.rs
@@ -0,0 +1,56 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//! The window onto the list: which rows are showing, and which row the cursor
+//! is on when a pointer puts it there.
+
+use super::state::State;
+
+impl State {
+ /// Keep the window inside the list. Called from the frame, which is
+ /// the only place the row count is known.
+ pub fn clamp_scroll(&mut self) {
+ let n = self.visible().len();
+ let most = n.saturating_sub(self.rows);
+ if self.scroll > most {
+ self.scroll = most;
+ }
+ }
+
+ /// Move the window without moving the cursor, which is what a wheel does:
+ /// the selection stays where the user put it and the list travels under
+ /// it.
+ pub fn scroll_by(&mut self, delta: isize) -> bool {
+ let n = self.visible().len();
+ let most = n.saturating_sub(self.rows) as isize;
+ let want = (self.scroll as isize + delta).clamp(0, most.max(0)) as usize;
+ if want == self.scroll {
+ return false;
+ }
+ self.scroll = want;
+ true
+ }
+
+ /// Put the cursor on a visible slot, as a click does.
+ pub fn select_slot(&mut self, slot: usize) -> bool {
+ let want = self.scroll + slot;
+ if want >= self.visible().len() || want == self.cursor {
+ return false;
+ }
+ self.cursor = want;
+ self.select();
+ true
+ }
+}
diff --git a/userland/capsule_app_store/src/store/tab.rs b/userland/capsule_app_store/src/store/tab.rs
new file mode 100644
index 0000000000..02b23db63c
--- /dev/null
+++ b/userland/capsule_app_store/src/store/tab.rs
@@ -0,0 +1,50 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The source filter across the top of the list.
+
+use super::listing::Source;
+
+/// Which of the three namespaces the list is filtered to, or all of them.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Tab {
+ All,
+ NonOs,
+ Linux,
+ Community,
+}
+
+pub const TABS: [Tab; 4] = [Tab::All, Tab::NonOs, Tab::Linux, Tab::Community];
+
+impl Tab {
+ pub fn label(self) -> &'static [u8] {
+ match self {
+ Tab::All => b"All",
+ Tab::NonOs => b"NONOS",
+ Tab::Linux => b"Linux",
+ Tab::Community => b"Community",
+ }
+ }
+
+ pub fn accepts(self, source: Source) -> bool {
+ match self {
+ Tab::All => true,
+ Tab::NonOs => source == Source::NonOs,
+ Tab::Linux => source == Source::Linux,
+ Tab::Community => source == Source::Community,
+ }
+ }
+}
diff --git a/userland/capsule_app_store/src/store/theme.rs b/userland/capsule_app_store/src/store/theme.rs
new file mode 100644
index 0000000000..730761c730
--- /dev/null
+++ b/userland/capsule_app_store/src/store/theme.rs
@@ -0,0 +1,50 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+// The house palette, the same values the About and Settings restyles
+// established. Layout sizes are not here: they live in ui/metrics.rs.
+pub const BACKGROUND: u32 = 0xFF0B1319;
+pub const CARD_BG: u32 = 0xFF0E1920;
+pub const CARD_SEL_BG: u32 = 0xFF13242E;
+pub const CARD_SEL_EDGE: u32 = 0xFF35C4E2;
+pub const PANE_BG: u32 = 0xFF101C24;
+pub const STATUS_BG: u32 = 0xFF0D171E;
+pub const RULE: u32 = 0xFF16262F;
+
+pub const TITLE: u32 = 0xFFEAF4F8;
+pub const FOREGROUND: u32 = 0xFFCDDDE5;
+pub const MUTED: u32 = 0xFF6D818C;
+pub const ACCENT: u32 = 0xFF35C4E2;
+
+pub const TAB_FG: u32 = 0xFF93A7B2;
+pub const TAB_FG_ACTIVE: u32 = 0xFFA8E7F6;
+pub const TAB_BG_ACTIVE: u32 = 0x2035C4E2;
+
+/// The tile behind a listing's initial. Tinted per source so the three
+/// namespaces are distinguishable before a single word is read.
+pub const TILE_NONOS: u32 = 0xFF17323D;
+pub const TILE_LINUX: u32 = 0xFF1B2E3A;
+pub const TILE_COMMUNITY: u32 = 0xFF2A2438;
+
+/// The install action, filled rather than lettered: a coloured word is not
+/// obviously a control, and every row on this screen is an offer to do
+/// something.
+pub const BUTTON_BG: u32 = 0xFF1B6E5A;
+pub const BUTTON_FG: u32 = 0xFFD6F5EA;
+pub const BUTTON_OFF_BG: u32 = 0xFF17242B;
+pub const BUTTON_OFF_FG: u32 = 0xFF6D818C;
+
+pub const OK: u32 = 0xFF33CF7D;
+pub const DANGER: u32 = 0xFFE06C75;
diff --git a/userland/capsule_app_store/src/store/ui/card.rs b/userland/capsule_app_store/src/store/ui/card.rs
new file mode 100644
index 0000000000..6caa0c62d1
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/card.rs
@@ -0,0 +1,70 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! One listing, drawn as a card.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::listing::{Listing, Source};
+use crate::store::theme::{
+ BUTTON_BG, BUTTON_FG, BUTTON_OFF_BG, BUTTON_OFF_FG, CARD_BG, CARD_SEL_BG, CARD_SEL_EDGE, MUTED,
+ TILE_COMMUNITY, TILE_LINUX, TILE_NONOS, TITLE,
+};
+
+use super::geometry::action_rect;
+use super::metrics::{CARD_H, CARD_PAD, NAME_PX, SMALL_PX, TILE, TILE_GAP};
+use super::text;
+
+pub fn paint(fb: &mut PaintBuffer, l: &Listing, x: u32, y: u32, w: u32, selected: bool) {
+ fb.fill_rect(x, y, w, CARD_H, if selected { CARD_SEL_BG } else { CARD_BG });
+ if selected {
+ /*
+ * A rule down the leading edge rather than a full border: it marks the
+ * row without boxing every card on the screen.
+ */
+ fb.fill_rect(x, y, 3, CARD_H, CARD_SEL_EDGE);
+ }
+
+ let tile_y = y + (CARD_H - TILE) / 2;
+ fb.fill_rect(x + CARD_PAD, tile_y, TILE, TILE, tint(l.source));
+ let initial = [l.name.first().copied().unwrap_or(b'?').to_ascii_uppercase()];
+ let ix = x + CARD_PAD + (TILE - text::width_of(&initial, NAME_PX)) / 2;
+ text::line(fb, ix, text::top_of(tile_y as i32, TILE, NAME_PX), &initial, TITLE, NAME_PX);
+
+ let text_x = x + CARD_PAD + TILE + TILE_GAP;
+ text::line(fb, text_x, y as i32 + 12, &l.name, TITLE, NAME_PX);
+ text::line(fb, text_x, y as i32 + 34, l.source.label(), MUTED, SMALL_PX);
+
+ action(fb, l, action_rect(x, y, w));
+}
+
+fn action(fb: &mut PaintBuffer, l: &Listing, (x, y, w, h): (u32, u32, u32, u32)) {
+ let (bg, fg, word): (u32, u32, &[u8]) = match l.ready {
+ true => (BUTTON_BG, BUTTON_FG, b"Install"),
+ false => (BUTTON_OFF_BG, BUTTON_OFF_FG, b"Details"),
+ };
+ fb.fill_rect(x, y, w, h, bg);
+ let tx = x + (w - text::width_of(word, SMALL_PX)) / 2;
+ text::line(fb, tx, text::top_of(y as i32, h, SMALL_PX), word, fg, SMALL_PX);
+}
+
+fn tint(source: Source) -> u32 {
+ match source {
+ Source::NonOs => TILE_NONOS,
+ Source::Linux => TILE_LINUX,
+ Source::Community => TILE_COMMUNITY,
+ }
+}
diff --git a/userland/capsule_app_store/src/store/ui/chrome.rs b/userland/capsule_app_store/src/store/ui/chrome.rs
new file mode 100644
index 0000000000..f37923e382
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/chrome.rs
@@ -0,0 +1,64 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The head band and the source tabs.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::state::{State, TABS};
+use crate::store::theme::{MUTED, TAB_BG_ACTIVE, TAB_FG, TAB_FG_ACTIVE, TITLE};
+
+use super::counter::listed;
+use super::metrics::{
+ BODY_PX, HEAD_H, PAD_TOP, PAD_X, SMALL_PX, TAB_GAP, TAB_H, TAB_PAD_X, TITLE_PX,
+};
+use super::searchbar;
+use super::text;
+
+pub fn head(fb: &mut PaintBuffer, state: &State) {
+ let top = text::top_of(PAD_TOP as i32, HEAD_H, TITLE_PX);
+ text::line(fb, PAD_X, top, b"Marketplace", TITLE, TITLE_PX);
+ let right = state.fb_w.saturating_sub(PAD_X);
+ let field = searchbar::paint(fb, &state.search, right);
+ let meta_top = text::top_of(PAD_TOP as i32, HEAD_H, BODY_PX);
+ let count = state.visible().len();
+ let at = right.saturating_sub(field + if field == 0 { 0 } else { PAD_X });
+ text::right(fb, at, meta_top, &listed(count), MUTED, BODY_PX);
+}
+
+/// Where each tab sits.
+pub fn tab_rect(index: usize) -> (u32, u32) {
+ let mut x = PAD_X;
+ for tab in TABS.iter().take(index) {
+ x += text::width_of(tab.label(), SMALL_PX) + TAB_PAD_X * 2 + TAB_GAP;
+ }
+ let w = text::width_of(TABS[index].label(), SMALL_PX) + TAB_PAD_X * 2;
+ (x, w)
+}
+
+pub fn tabs(fb: &mut PaintBuffer, state: &State) {
+ let y = PAD_TOP + HEAD_H;
+ for (i, tab) in TABS.iter().enumerate() {
+ let (x, w) = tab_rect(i);
+ let active = *tab == state.tab;
+ if active {
+ fb.fill_rect(x, y, w, TAB_H, TAB_BG_ACTIVE);
+ }
+ let fg = if active { TAB_FG_ACTIVE } else { TAB_FG };
+ let top = text::top_of(y as i32, TAB_H, SMALL_PX);
+ text::line(fb, x + TAB_PAD_X, top, tab.label(), fg, SMALL_PX);
+ }
+}
diff --git a/userland/capsule_app_store/src/store/ui/consent_text.rs b/userland/capsule_app_store/src/store/ui/consent_text.rs
new file mode 100644
index 0000000000..ec9e594184
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/consent_text.rs
@@ -0,0 +1,29 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! How each stage of enrolment reads to the user.
+
+use crate::store::consent::Consent;
+
+pub fn label(c: Consent) -> &'static [u8] {
+ match c {
+ Consent::Idle => b"",
+ Consent::Typing(_, 0) => b"code is on the console; type it",
+ Consent::Typing(..) => b"typing code, Enter to confirm",
+ Consent::Granted => b"this machine will run what it installs",
+ Consent::Refused => b"enrolment refused",
+ }
+}
diff --git a/userland/capsule_app_store/src/store/ui/counter.rs b/userland/capsule_app_store/src/store/ui/counter.rs
new file mode 100644
index 0000000000..b8f836b1e8
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/counter.rs
@@ -0,0 +1,34 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! "N listed", built without a formatter because this is `no_std`.
+
+/// Right-aligned in a fixed field so the head band does not reflow as the
+/// count changes.
+pub fn listed(n: usize) -> [u8; 16] {
+ let mut out = *b" 0 listed ";
+ let mut at = 8;
+ let mut left = n;
+ loop {
+ at -= 1;
+ out[at] = b'0' + (left % 10) as u8;
+ left /= 10;
+ if left == 0 || at == 0 {
+ break;
+ }
+ }
+ out
+}
diff --git a/userland/capsule_app_store/src/store/ui/detail.rs b/userland/capsule_app_store/src/store/ui/detail.rs
new file mode 100644
index 0000000000..68ce3d5e49
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/detail.rs
@@ -0,0 +1,54 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//! The selected listing: what it is, who stands behind it, and whether this
+//! machine will run it.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::state::State;
+use crate::store::theme::{ACCENT, FOREGROUND, MUTED, PANE_BG, TITLE};
+use super::hex::short;
+use super::metrics::{BODY_PX, DETAIL_PAD, SMALL_PX, TITLE_PX};
+use super::text;
+use super::wrap::wrap;
+
+pub fn paint(state: &State, fb: &mut PaintBuffer, x: u32, y: u32, w: u32, h: u32) {
+ fb.fill_rect(x, y, w, h, PANE_BG);
+ let left = x + DETAIL_PAD;
+ let room = w.saturating_sub(DETAIL_PAD * 2);
+ let Some(listing) = state.current() else {
+ text::line(fb, left, y as i32 + DETAIL_PAD as i32, b"Nothing selected", MUTED, BODY_PX);
+ return;
+ };
+ let mut top = y as i32 + DETAIL_PAD as i32;
+ text::line(fb, left, top, &listing.name, TITLE, TITLE_PX);
+ top += 32;
+
+ if let Some(d) = &state.detail {
+ text::line(fb, left, top, &d.publisher, ACCENT, SMALL_PX);
+ top += 26;
+ for line in wrap(&d.description, room, SMALL_PX).iter().take(4) {
+ text::line(fb, left, top, line, FOREGROUND, SMALL_PX);
+ top += 20;
+ }
+ top += 10;
+ }
+
+ top = super::standing::paint(fb, state, left, top);
+ text::line(fb, left, top, b"measurement", MUTED, SMALL_PX);
+ top += 20;
+ text::line(fb, left, top, &short(&listing.measurement), MUTED, SMALL_PX);
+}
diff --git a/userland/capsule_app_store/src/store/ui/frame.rs b/userland/capsule_app_store/src/store/ui/frame.rs
new file mode 100644
index 0000000000..0c431d4192
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/frame.rs
@@ -0,0 +1,49 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! One frame: ground, head, tabs, list, detail, status.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::state::State;
+use crate::store::theme::BACKGROUND;
+
+use super::metrics::{CARD_GAP, CARD_H, DETAIL_W, PAD_X, STATUS_H};
+use super::{chrome, detail, geometry, rows, scrollbar, status};
+
+pub fn frame(state: &mut State, fb: &mut PaintBuffer) {
+ fb.clear(BACKGROUND);
+ state.fb_w = fb.width;
+ state.fb_h = fb.height;
+ chrome::head(fb, state);
+ chrome::tabs(fb, state);
+ let top = geometry::list_top();
+
+ let bottom = fb.height.saturating_sub(STATUS_H + PAD_X);
+ let pane_h = bottom.saturating_sub(top);
+ state.rows = (pane_h / (CARD_H + CARD_GAP)).max(1) as usize;
+ // Clamped here because this is where the row count is known.
+ state.clamp_scroll();
+
+ let list_w = geometry::list_w(fb.width);
+ rows::paint(state, fb, PAD_X, top, list_w, state.rows);
+ let total = state.visible().len();
+ scrollbar::paint(fb, PAD_X, top, list_w, state.rows, total, state.scroll);
+
+ let detail_x = PAD_X + list_w + PAD_X;
+ detail::paint(state, fb, detail_x, top, DETAIL_W, pane_h);
+ status::paint(fb, state);
+}
diff --git a/userland/capsule_app_store/src/store/ui/gates.rs b/userland/capsule_app_store/src/store/ui/gates.rs
new file mode 100644
index 0000000000..ec55841f19
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/gates.rs
@@ -0,0 +1,53 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Why the selected listing can or cannot be installed.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::market::{Readiness, GATES};
+use crate::store::theme::{ACCENT, DANGER, MUTED, OK};
+use crate::store::verdict::Verdict;
+
+use super::metrics::{BODY_PX, GATE_ROW_H, SMALL_PX};
+use super::text;
+
+/// The column the verdicts line up in, left of the pane's right edge by
+/// enough that the longest label above still clears it.
+const MARK_X: u32 = 190;
+
+pub fn paint(fb: &mut PaintBuffer, x: u32, mut top: i32, r: &Readiness) {
+ let verdict = Verdict::of(r);
+ let hue = match verdict {
+ Verdict::Ready => OK,
+ Verdict::Installed => ACCENT,
+ Verdict::Blocked => DANGER,
+ };
+ text::line(fb, x, top, verdict.label(), hue, BODY_PX);
+ top += 24;
+ if verdict == Verdict::Installed {
+ // The package gate below will read as a failure.
+ text::line(fb, x, top, b"in this image; nothing to fetch", MUTED, SMALL_PX);
+ }
+ top += 24;
+ for (label, pass) in GATES.iter().zip(r.gates.iter()) {
+ let mark: &[u8] = if *pass { b"pass" } else { b"fail" };
+ let hue = if *pass { OK } else { DANGER };
+ text::line(fb, x, top, label, MUTED, SMALL_PX);
+ text::line(fb, x + MARK_X, top, mark, hue, SMALL_PX);
+ top += GATE_ROW_H as i32;
+ }
+}
diff --git a/userland/capsule_app_store/src/store/ui/geometry.rs b/userland/capsule_app_store/src/store/ui/geometry.rs
new file mode 100644
index 0000000000..168827bd5b
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/geometry.rs
@@ -0,0 +1,65 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Where the list is.
+
+use super::metrics::{
+ ACTION_H, ACTION_W, CARD_GAP, CARD_H, CARD_PAD, DETAIL_W, HEAD_H, PAD_TOP, PAD_X, TAB_H,
+ TAB_TO_LIST,
+};
+
+/// The y the first card starts at.
+pub fn list_top() -> u32 {
+ PAD_TOP + HEAD_H + TAB_H + TAB_TO_LIST
+}
+
+/// How wide the list is, given the surface. The detail pane and three
+/// gutters take the rest.
+pub fn list_w(fb_w: u32) -> u32 {
+ fb_w.saturating_sub(PAD_X * 3 + DETAIL_W)
+}
+
+pub fn row_top(slot: usize) -> u32 {
+ list_top() + slot as u32 * (CARD_H + CARD_GAP)
+}
+
+/// Which visible slot a point falls in.
+pub fn slot_at(y: i32, rows: usize) -> Option {
+ let top = list_top() as i32;
+ if y < top {
+ return None;
+ }
+ let pitch = (CARD_H + CARD_GAP) as i32;
+ let slot = (y - top) / pitch;
+ let within = (y - top) % pitch;
+ match within < CARD_H as i32 && (slot as usize) < rows {
+ true => Some(slot as usize),
+ false => None,
+ }
+}
+
+/// The action control inside a card whose box is `x, top, w`.
+pub fn action_rect(x: u32, top: u32, w: u32) -> (u32, u32, u32, u32) {
+ let ax = x + w.saturating_sub(CARD_PAD + ACTION_W);
+ let ay = top + (CARD_H - ACTION_H) / 2;
+ (ax, ay, ACTION_W, ACTION_H)
+}
+
+/// Whether a point is inside that control.
+pub fn on_action(x: i32, y: i32, card_x: u32, top: u32, w: u32) -> bool {
+ let (ax, ay, aw, ah) = action_rect(card_x, top, w);
+ x >= ax as i32 && x < (ax + aw) as i32 && y >= ay as i32 && y < (ay + ah) as i32
+}
diff --git a/userland/capsule_app_store/src/store/ui/hex.rs b/userland/capsule_app_store/src/store/ui/hex.rs
new file mode 100644
index 0000000000..bdaea50799
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/hex.rs
@@ -0,0 +1,28 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! A measurement, short enough to read off the screen.
+
+/// The first six bytes.
+pub fn short(m: &[u8; 32]) -> [u8; 12] {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut out = [0u8; 12];
+ for i in 0..6 {
+ out[i * 2] = HEX[(m[i] >> 4) as usize];
+ out[i * 2 + 1] = HEX[(m[i] & 0xF) as usize];
+ }
+ out
+}
diff --git a/userland/capsule_app_store/src/store/ui/metrics.rs b/userland/capsule_app_store/src/store/ui/metrics.rs
new file mode 100644
index 0000000000..8900c4fab9
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/metrics.rs
@@ -0,0 +1,60 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+// Every layout size in real pixels at 1x. The list is a column of cards
+/*
+ * rather than table rows: a row of one name reads as a database dump, and the
+ * catalogue has a description and a publisher for every entry that were going
+ * unshown.
+ */
+
+pub const WIN_W: u32 = 1000;
+pub const WIN_H: u32 = 680;
+pub const WIN_X: u32 = 188;
+pub const WIN_Y: u32 = 52;
+
+pub const PAD_X: u32 = 22;
+pub const PAD_TOP: u32 = 18;
+pub const HEAD_H: u32 = 44;
+pub const TAB_H: u32 = 32;
+pub const TAB_GAP: u32 = 6;
+pub const TAB_PAD_X: u32 = 14;
+/// Air between the tab strip and the first card.
+pub const TAB_TO_LIST: u32 = 10;
+
+/// A card holds two lines of text over a tile, so it is tall enough for
+/// both plus the breathing room that stops a list looking like a table.
+pub const CARD_H: u32 = 64;
+pub const CARD_GAP: u32 = 6;
+pub const CARD_PAD: u32 = 14;
+pub const TILE: u32 = 36;
+pub const TILE_GAP: u32 = 14;
+
+/// The action sits at a fixed width on the right so every card's button
+/// starts at the same x and the eye can run straight down them.
+pub const ACTION_W: u32 = 96;
+pub const ACTION_H: u32 = 28;
+
+pub const DETAIL_W: u32 = 332;
+pub const DETAIL_PAD: u32 = 18;
+pub const GATE_ROW_H: u32 = 24;
+
+pub const STATUS_H: u32 = 28;
+pub const STATUS_PAD_X: u32 = 16;
+
+pub const TITLE_PX: f32 = 23.0;
+pub const NAME_PX: f32 = 18.0;
+pub const BODY_PX: f32 = 17.0;
+pub const SMALL_PX: f32 = 17.0;
diff --git a/userland/capsule_app_store/src/store/ui/mod.rs b/userland/capsule_app_store/src/store/ui/mod.rs
new file mode 100644
index 0000000000..9112fe8750
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/mod.rs
@@ -0,0 +1,37 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The painter.
+
+pub mod chrome;
+mod card;
+mod consent_text;
+mod counter;
+mod detail;
+mod frame;
+mod gates;
+pub mod geometry;
+mod hex;
+pub mod metrics;
+mod rows;
+mod scrollbar;
+mod searchbar;
+mod standing;
+mod status;
+mod text;
+mod wrap;
+
+pub use frame::frame;
diff --git a/userland/capsule_app_store/src/store/ui/rows.rs b/userland/capsule_app_store/src/store/ui/rows.rs
new file mode 100644
index 0000000000..8ee6983c89
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/rows.rs
@@ -0,0 +1,49 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//! The catalogue, as a column of cards.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::state::State;
+use crate::store::theme::MUTED;
+
+use super::card;
+use super::metrics::{BODY_PX, CARD_GAP, CARD_H};
+use super::text;
+
+pub fn paint(state: &State, fb: &mut PaintBuffer, x: u32, y: u32, w: u32, rows: usize) {
+ let visible = state.visible();
+ if visible.is_empty() {
+ text::line(fb, x, y as i32 + 10, empty_because(state), MUTED, BODY_PX);
+ return;
+ }
+ for slot in 0..rows {
+ let Some(&index) = visible.get(state.scroll + slot) else { break };
+ let Some(listing) = state.listings.get(index) else { break };
+ let top = y + slot as u32 * (CARD_H + CARD_GAP);
+ card::paint(fb, listing, x, top, w, state.scroll + slot == state.cursor);
+ }
+}
+
+/// Why there is nothing to show.
+fn empty_because(state: &State) -> &'static [u8] {
+ match (state.trouble, state.listings.is_empty()) {
+ (Some(why), _) => why,
+ (None, true) => b"the catalogue is empty",
+ (None, false) if !state.search.text().is_empty() => b"nothing matches that",
+ (None, false) => b"nothing under this tab",
+ }
+}
diff --git a/userland/capsule_app_store/src/store/ui/scrollbar.rs b/userland/capsule_app_store/src/store/ui/scrollbar.rs
new file mode 100644
index 0000000000..72f35182c6
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/scrollbar.rs
@@ -0,0 +1,45 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! How far down the list you are.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::theme::{CARD_SEL_EDGE, RULE};
+
+use super::metrics::{CARD_GAP, CARD_H};
+
+const W: u32 = 3;
+const GAP: u32 = 6;
+
+pub fn paint(fb: &mut PaintBuffer, x: u32, y: u32, w: u32, rows: usize, total: usize, at: usize) {
+ if total <= rows || rows == 0 {
+ return;
+ }
+ let track_h = rows as u32 * (CARD_H + CARD_GAP) - CARD_GAP;
+ let left = x + w + GAP;
+ fb.fill_rect(left, y, W, track_h, RULE);
+ /*
+ * The thumb is the fraction of the list in view, never thinner than it can
+ * be seen: a two hundred entry catalogue would otherwise round it away to
+ * nothing at the very moment it is most wanted.
+ */
+ let span = (track_h as usize * rows / total).max(12) as u32;
+ let travel = track_h.saturating_sub(span);
+ let most = total.saturating_sub(rows);
+ let top = y + (travel as usize * at.min(most) / most.max(1)) as u32;
+ fb.fill_rect(left, top, W, span, CARD_SEL_EDGE);
+}
diff --git a/userland/capsule_app_store/src/store/ui/searchbar.rs b/userland/capsule_app_store/src/store/ui/searchbar.rs
new file mode 100644
index 0000000000..b029babfd3
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/searchbar.rs
@@ -0,0 +1,53 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The search field, in the head band.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::search::Search;
+use crate::store::theme::{ACCENT, CARD_BG, MUTED, TITLE};
+
+use super::metrics::{HEAD_H, PAD_TOP, SMALL_PX};
+use super::text;
+
+const W: u32 = 260;
+const H: u32 = 26;
+const PAD: u32 = 10;
+
+/// Paints the field and reports how much width it took, so the head
+/// can put its count to the left of it rather than underneath.
+pub fn paint(fb: &mut PaintBuffer, search: &Search, right: u32) -> u32 {
+ if !search.active && search.text().is_empty() {
+ return 0;
+ }
+ let x = right.saturating_sub(W);
+ let y = PAD_TOP + (HEAD_H - H) / 2;
+ fb.fill_rect(x, y, W, H, CARD_BG);
+ if search.active {
+ fb.fill_rect(x, y + H - 2, W, 2, ACCENT);
+ }
+ let top = text::top_of(y as i32, H, SMALL_PX);
+ match search.text().is_empty() {
+ true => text::line(fb, x + PAD, top, b"type to search", MUTED, SMALL_PX),
+ false => text::line(fb, x + PAD, top, search.text(), TITLE, SMALL_PX),
+ };
+ if search.active {
+ let caret = x + PAD + text::width_of(search.text(), SMALL_PX) + 2;
+ fb.fill_rect(caret.min(x + W - 3), y + 5, 1, H - 10, ACCENT);
+ }
+ W
+}
diff --git a/userland/capsule_app_store/src/store/ui/standing.rs b/userland/capsule_app_store/src/store/ui/standing.rs
new file mode 100644
index 0000000000..84248fb7ee
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/standing.rs
@@ -0,0 +1,50 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Where the selected listing stands with this machine.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::state::State;
+use crate::store::theme::{ACCENT, DANGER, MUTED, OK};
+use crate::store::verdict::Verdict;
+
+use super::gates;
+use super::metrics::{BODY_PX, GATE_ROW_H, SMALL_PX};
+use super::text;
+
+/// Paints and returns the y to carry on from.
+pub fn paint(fb: &mut PaintBuffer, state: &State, left: u32, mut top: i32) -> i32 {
+ match state.ready {
+ Some(r) => {
+ let v = Verdict::of(&r);
+ let hue = match v {
+ Verdict::Ready => OK,
+ Verdict::Installed => ACCENT,
+ Verdict::Blocked => DANGER,
+ };
+ text::line(fb, left, top, v.sentence(), hue, BODY_PX);
+ top += 30;
+ gates::paint(fb, left, top, &r);
+ top += 6 * GATE_ROW_H as i32 + 14;
+ }
+ None => {
+ text::line(fb, left, top, b"checking", MUTED, SMALL_PX);
+ top += 26;
+ }
+ }
+ top
+}
diff --git a/userland/capsule_app_store/src/store/ui/status.rs b/userland/capsule_app_store/src/store/ui/status.rs
new file mode 100644
index 0000000000..672f84d7ba
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/status.rs
@@ -0,0 +1,43 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! The strip along the bottom: what the keys do, and what the last
+//! install request was told.
+
+use nonos_app_skeleton::PaintBuffer;
+
+use crate::store::state::State;
+use crate::store::theme::{ACCENT, MUTED, RULE, STATUS_BG};
+
+use super::metrics::{SMALL_PX, STATUS_H, STATUS_PAD_X};
+use super::text;
+
+pub fn paint(fb: &mut PaintBuffer, state: &State) {
+ let y = state.fb_h.saturating_sub(STATUS_H);
+ fb.fill_rect(0, y, state.fb_w, STATUS_H, STATUS_BG);
+ fb.fill_rect(0, y, state.fb_w, 1, RULE);
+ let top = text::top_of(y as i32, STATUS_H, SMALL_PX);
+ let keys: &[u8] = b"up/down select Enter install e enrol r refresh Esc close";
+ text::line(fb, STATUS_PAD_X, top, keys, MUTED, SMALL_PX);
+ // The answer to the last request sits opposite the keys.
+ let right = state.fb_w.saturating_sub(STATUS_PAD_X);
+ let consent = super::consent_text::label(state.consent);
+ if !consent.is_empty() {
+ text::right(fb, right, top, consent, ACCENT, SMALL_PX);
+ } else if let Some(asked) = state.asked {
+ text::right(fb, right, top, asked.label(), ACCENT, SMALL_PX);
+ }
+}
diff --git a/userland/capsule_app_store/src/store/ui/text.rs b/userland/capsule_app_store/src/store/ui/text.rs
new file mode 100644
index 0000000000..f31afda24f
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/text.rs
@@ -0,0 +1,51 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Text placement. Every string this window draws goes through here so a
+//! painter and a hit test cannot disagree about where a line sits.
+
+use nonos_app_skeleton::PaintBuffer;
+use nonos_toolkit::font::ttf::line_height;
+
+fn valid(bytes: &[u8]) -> &str {
+ core::str::from_utf8(bytes).unwrap_or("")
+}
+
+/// The rasteriser takes a signed baseline box and drops pixels outside the
+/// target, so a scrolled line needs no clamping of its own.
+pub fn line(fb: &mut PaintBuffer, x: u32, top: i32, bytes: &[u8], argb: u32, px: f32) -> i32 {
+ fb.text_ttf(x as i32, top, valid(bytes), argb, px)
+}
+
+pub fn width(fb: &PaintBuffer, bytes: &[u8], px: f32) -> u32 {
+ fb.measure_ttf(valid(bytes), px).max(0) as u32
+}
+
+/// The same advance sum without a surface.
+pub fn width_of(bytes: &[u8], px: f32) -> u32 {
+ nonos_toolkit::paint::measure_ttf(valid(bytes), px).max(0) as u32
+}
+
+pub fn right(fb: &mut PaintBuffer, right_x: u32, top: i32, bytes: &[u8], argb: u32, px: f32) {
+ let w = width(fb, bytes, px);
+ line(fb, right_x.saturating_sub(w), top, bytes, argb, px);
+}
+
+/// `text_ttf` takes the top of the line box, so centring one line inside a
+/// box is the caller's job. Painter and hit test both come through here.
+pub fn top_of(y: i32, h: u32, px: f32) -> i32 {
+ y + (h.saturating_sub(line_height(px).max(1) as u32) / 2) as i32
+}
diff --git a/userland/capsule_app_store/src/store/ui/wrap.rs b/userland/capsule_app_store/src/store/ui/wrap.rs
new file mode 100644
index 0000000000..a8b44318b7
--- /dev/null
+++ b/userland/capsule_app_store/src/store/ui/wrap.rs
@@ -0,0 +1,42 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//! Breaking a description to the width it has.
+
+use alloc::vec::Vec;
+
+use super::text::width_of;
+
+pub fn wrap(text: &[u8], room: u32, px: f32) -> Vec> {
+ let mut out: Vec> = Vec::new();
+ let mut line: Vec = Vec::new();
+ for word in text.split(|b| *b == b' ').filter(|w| !w.is_empty()) {
+ let mut candidate = line.clone();
+ if !candidate.is_empty() {
+ candidate.push(b' ');
+ }
+ candidate.extend_from_slice(word);
+ if width_of(&candidate, px) > room && !line.is_empty() {
+ out.push(core::mem::take(&mut line));
+ line.extend_from_slice(word);
+ } else {
+ line = candidate;
+ }
+ }
+ if !line.is_empty() {
+ out.push(line);
+ }
+ out
+}
diff --git a/userland/capsule_app_store/src/store/verdict.rs b/userland/capsule_app_store/src/store/verdict.rs
new file mode 100644
index 0000000000..327a106526
--- /dev/null
+++ b/userland/capsule_app_store/src/store/verdict.rs
@@ -0,0 +1,61 @@
+// NONOS Operating System
+// Copyright (C) 2026 NONOS Contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! What a listing's gate vector actually means for the user.
+
+use crate::store::market::Readiness;
+
+/// The package gate's position in the vector the capsule returns.
+const PACKAGE_GATE: usize = 1;
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Verdict {
+ Ready,
+ /// Every gate passes except the one asking for something to fetch.
+ /// That is what a capsule already in the image looks like.
+ Installed,
+ Blocked,
+}
+
+impl Verdict {
+ pub fn of(r: &Readiness) -> Verdict {
+ if r.install_ready {
+ return Verdict::Ready;
+ }
+ let others = r.gates.iter().enumerate().all(|(i, ok)| *ok || i == PACKAGE_GATE);
+ match others && !r.gates[PACKAGE_GATE] {
+ true => Verdict::Installed,
+ false => Verdict::Blocked,
+ }
+ }
+
+ pub fn label(self) -> &'static [u8] {
+ match self {
+ Verdict::Ready => b"Install",
+ Verdict::Installed => b"Installed",
+ Verdict::Blocked => b"Blocked",
+ }
+ }
+
+ /// The same verdict as something to read rather than a word to decode.
+ pub fn sentence(self) -> &'static [u8] {
+ match self {
+ Verdict::Ready => b"Ready to install on this machine",
+ Verdict::Installed => b"Already in this image, nothing to fetch",
+ Verdict::Blocked => b"This machine will not run it yet",
+ }
+ }
+}
diff --git a/userland/capsule_desktop_shell/src/render/icons.rs b/userland/capsule_desktop_shell/src/render/icons.rs
index 950e964645..ac07ab454b 100644
--- a/userland/capsule_desktop_shell/src/render/icons.rs
+++ b/userland/capsule_desktop_shell/src/render/icons.rs
@@ -44,6 +44,7 @@ fn icon_bytes(icon: LauncherIcon) -> &'static [u8] {
LauncherIcon::Calculator => IconId::Calc,
LauncherIcon::Clock => IconId::Clock,
LauncherIcon::Snake => IconId::Snake,
+ LauncherIcon::Store => IconId::Store,
LauncherIcon::Wallet => IconId::Wallet,
LauncherIcon::Browser => IconId::Browser,
LauncherIcon::ImageViewer => IconId::ImageViewer,
diff --git a/userland/capsule_desktop_shell/src/state/apps.rs b/userland/capsule_desktop_shell/src/state/apps.rs
index c057d9bf69..9591d14b6b 100644
--- a/userland/capsule_desktop_shell/src/state/apps.rs
+++ b/userland/capsule_desktop_shell/src/state/apps.rs
@@ -25,6 +25,7 @@ pub enum LauncherIcon {
Calculator,
Clock,
Snake,
+ Store,
Wallet,
Browser,
ImageViewer,
@@ -38,7 +39,7 @@ pub struct LauncherApp {
pub service: &'static [u8],
}
-pub const LAUNCHER_APPS: [LauncherApp; 12] = [
+pub const LAUNCHER_APPS: [LauncherApp; 13] = [
LauncherApp { icon: LauncherIcon::Terminal, label: b"Terminal", service: b"app.terminal" },
LauncherApp { icon: LauncherIcon::FileManager, label: b"Files", service: b"app.file_manager" },
LauncherApp { icon: LauncherIcon::TextEditor, label: b"Editor", service: b"app.text_editor" },
@@ -49,6 +50,7 @@ pub const LAUNCHER_APPS: [LauncherApp; 12] = [
service: b"app.process_manager",
},
LauncherApp { icon: LauncherIcon::About, label: b"About", service: b"app.about" },
+ LauncherApp { icon: LauncherIcon::Store, label: b"Marketplace", service: b"app.store" },
LauncherApp {
icon: LauncherIcon::Calculator,
label: b"Calculator",