From ac266189ebb4eb39427170d686eb3282c21b36c2 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Sun, 20 Sep 2026 12:00:00 +0200 Subject: [PATCH 1/2] store: the app store capsule Reads the signed marketplace index, lists what the running image can install, and installs through the queue init already owns. The install buttons did nothing: the painter and the hit test each computed their own rectangles, so a click never matched a row. Both derive from one geometry module now and the click path reaches the same install::ask as Enter. Search filters as typed, the list scrolls with a real scrollbar, and selection survives a refresh. init gains an install queue and a wake path so a store request is serviced on arrival rather than on the next supervisor spin. The surface registry can attach frames to a surface a capsule owns. Scrollbar arithmetic mirrored in python: empty, shorter than the viewport, thumb at both ends, single row overflow. --- .keys/app_store_publisher_ed25519.pub | Bin 0 -> 43 bytes .keys/app_store_publisher_mldsa65.pub | Bin 0 -> 1963 bytes Cargo.toml | 4 + mk/20-build.mk | 75 ++++++++-- .../surface_registry/share/attach_frames.rs | 60 ++++++++ .../surface_registry/share/attach_surface.rs | 70 ++-------- src/kernel_core/surface_registry/share/mod.rs | 2 + .../surface_registry/share/self_attach.rs | 49 +++++++ src/userspace/capsule_app_store/embed.rs | 49 +++++++ src/userspace/capsule_app_store/mod.rs | 24 ++++ src/userspace/capsule_app_store/spawn.rs | 62 +++++++++ src/userspace/capsule_app_store/state.rs | 27 ++++ src/userspace/init/install_queue.rs | 61 ++++++++ src/userspace/init/instance_spawn/queue.rs | 18 +-- src/userspace/init/mod.rs | 6 + src/userspace/init/spawn_plan/apps.rs | 20 +++ src/userspace/init/supervisor/loop_impl.rs | 48 +++---- src/userspace/init/wake.rs | 56 ++++++++ src/userspace/mod.rs | 15 +- tools/nonos-icon-store | 108 +++++++++++++++ userland/assets/icons/store.a8 | Bin 0 -> 36864 bytes userland/assets/icons/store.svg | 1 + userland/capsule_app_store/Capsule.mk | 26 ++++ userland/capsule_app_store/Cargo.lock | 131 ++++++++++++++++++ userland/capsule_app_store/Cargo.toml | 43 ++++++ userland/capsule_app_store/build.rs | 53 +++++++ userland/capsule_app_store/src/main.rs | 31 +++++ userland/capsule_app_store/src/store/app.rs | 44 ++++++ .../capsule_app_store/src/store/consent.rs | 64 +++++++++ userland/capsule_app_store/src/store/event.rs | 48 +++++++ .../src/store/event_actions.rs | 57 ++++++++ .../src/store/event_click.rs | 45 ++++++ .../capsule_app_store/src/store/event_keys.rs | 61 ++++++++ .../capsule_app_store/src/store/event_rows.rs | 49 +++++++ .../src/store/event_search.rs | 65 +++++++++ .../capsule_app_store/src/store/event_tab.rs | 24 ++++ .../capsule_app_store/src/store/install.rs | 59 ++++++++ .../capsule_app_store/src/store/listing.rs | 61 ++++++++ .../capsule_app_store/src/store/manifest.rs | 43 ++++++ .../src/store/market/detail.rs | 55 ++++++++ .../src/store/market/list.rs | 52 +++++++ .../capsule_app_store/src/store/market/mod.rs | 28 ++++ .../src/store/market/ready.rs | 55 ++++++++ .../src/store/market/service.rs | 48 +++++++ .../src/store/market/wire.rs | 67 +++++++++ userland/capsule_app_store/src/store/mod.rs | 46 ++++++ .../capsule_app_store/src/store/search.rs | 72 ++++++++++ userland/capsule_app_store/src/store/state.rs | 47 +++++++ .../capsule_app_store/src/store/state_move.rs | 56 ++++++++ .../capsule_app_store/src/store/state_ops.rs | 50 +++++++ .../src/store/state_refresh.rs | 49 +++++++ .../src/store/state_select.rs | 40 ++++++ .../src/store/state_window.rs | 56 ++++++++ userland/capsule_app_store/src/store/tab.rs | 50 +++++++ userland/capsule_app_store/src/store/theme.rs | 50 +++++++ .../capsule_app_store/src/store/ui/card.rs | 70 ++++++++++ .../capsule_app_store/src/store/ui/chrome.rs | 64 +++++++++ .../src/store/ui/consent_text.rs | 29 ++++ .../capsule_app_store/src/store/ui/counter.rs | 34 +++++ .../capsule_app_store/src/store/ui/detail.rs | 54 ++++++++ .../capsule_app_store/src/store/ui/frame.rs | 49 +++++++ .../capsule_app_store/src/store/ui/gates.rs | 53 +++++++ .../src/store/ui/geometry.rs | 65 +++++++++ .../capsule_app_store/src/store/ui/hex.rs | 28 ++++ .../capsule_app_store/src/store/ui/metrics.rs | 60 ++++++++ .../capsule_app_store/src/store/ui/mod.rs | 37 +++++ .../capsule_app_store/src/store/ui/rows.rs | 49 +++++++ .../src/store/ui/scrollbar.rs | 45 ++++++ .../src/store/ui/searchbar.rs | 53 +++++++ .../src/store/ui/standing.rs | 50 +++++++ .../capsule_app_store/src/store/ui/status.rs | 43 ++++++ .../capsule_app_store/src/store/ui/text.rs | 51 +++++++ .../capsule_app_store/src/store/ui/wrap.rs | 42 ++++++ .../capsule_app_store/src/store/verdict.rs | 61 ++++++++ .../capsule_desktop_shell/src/render/icons.rs | 1 + .../capsule_desktop_shell/src/state/apps.rs | 4 +- 76 files changed, 3296 insertions(+), 126 deletions(-) create mode 100644 .keys/app_store_publisher_ed25519.pub create mode 100644 .keys/app_store_publisher_mldsa65.pub create mode 100644 src/kernel_core/surface_registry/share/attach_frames.rs create mode 100644 src/kernel_core/surface_registry/share/self_attach.rs create mode 100644 src/userspace/capsule_app_store/embed.rs create mode 100644 src/userspace/capsule_app_store/mod.rs create mode 100644 src/userspace/capsule_app_store/spawn.rs create mode 100644 src/userspace/capsule_app_store/state.rs create mode 100644 src/userspace/init/install_queue.rs create mode 100644 src/userspace/init/wake.rs create mode 100755 tools/nonos-icon-store create mode 100644 userland/assets/icons/store.a8 create mode 100644 userland/assets/icons/store.svg create mode 100644 userland/capsule_app_store/Capsule.mk create mode 100644 userland/capsule_app_store/Cargo.lock create mode 100644 userland/capsule_app_store/Cargo.toml create mode 100644 userland/capsule_app_store/build.rs create mode 100644 userland/capsule_app_store/src/main.rs create mode 100644 userland/capsule_app_store/src/store/app.rs create mode 100644 userland/capsule_app_store/src/store/consent.rs create mode 100644 userland/capsule_app_store/src/store/event.rs create mode 100644 userland/capsule_app_store/src/store/event_actions.rs create mode 100644 userland/capsule_app_store/src/store/event_click.rs create mode 100644 userland/capsule_app_store/src/store/event_keys.rs create mode 100644 userland/capsule_app_store/src/store/event_rows.rs create mode 100644 userland/capsule_app_store/src/store/event_search.rs create mode 100644 userland/capsule_app_store/src/store/event_tab.rs create mode 100644 userland/capsule_app_store/src/store/install.rs create mode 100644 userland/capsule_app_store/src/store/listing.rs create mode 100644 userland/capsule_app_store/src/store/manifest.rs create mode 100644 userland/capsule_app_store/src/store/market/detail.rs create mode 100644 userland/capsule_app_store/src/store/market/list.rs create mode 100644 userland/capsule_app_store/src/store/market/mod.rs create mode 100644 userland/capsule_app_store/src/store/market/ready.rs create mode 100644 userland/capsule_app_store/src/store/market/service.rs create mode 100644 userland/capsule_app_store/src/store/market/wire.rs create mode 100644 userland/capsule_app_store/src/store/mod.rs create mode 100644 userland/capsule_app_store/src/store/search.rs create mode 100644 userland/capsule_app_store/src/store/state.rs create mode 100644 userland/capsule_app_store/src/store/state_move.rs create mode 100644 userland/capsule_app_store/src/store/state_ops.rs create mode 100644 userland/capsule_app_store/src/store/state_refresh.rs create mode 100644 userland/capsule_app_store/src/store/state_select.rs create mode 100644 userland/capsule_app_store/src/store/state_window.rs create mode 100644 userland/capsule_app_store/src/store/tab.rs create mode 100644 userland/capsule_app_store/src/store/theme.rs create mode 100644 userland/capsule_app_store/src/store/ui/card.rs create mode 100644 userland/capsule_app_store/src/store/ui/chrome.rs create mode 100644 userland/capsule_app_store/src/store/ui/consent_text.rs create mode 100644 userland/capsule_app_store/src/store/ui/counter.rs create mode 100644 userland/capsule_app_store/src/store/ui/detail.rs create mode 100644 userland/capsule_app_store/src/store/ui/frame.rs create mode 100644 userland/capsule_app_store/src/store/ui/gates.rs create mode 100644 userland/capsule_app_store/src/store/ui/geometry.rs create mode 100644 userland/capsule_app_store/src/store/ui/hex.rs create mode 100644 userland/capsule_app_store/src/store/ui/metrics.rs create mode 100644 userland/capsule_app_store/src/store/ui/mod.rs create mode 100644 userland/capsule_app_store/src/store/ui/rows.rs create mode 100644 userland/capsule_app_store/src/store/ui/scrollbar.rs create mode 100644 userland/capsule_app_store/src/store/ui/searchbar.rs create mode 100644 userland/capsule_app_store/src/store/ui/standing.rs create mode 100644 userland/capsule_app_store/src/store/ui/status.rs create mode 100644 userland/capsule_app_store/src/store/ui/text.rs create mode 100644 userland/capsule_app_store/src/store/ui/wrap.rs create mode 100644 userland/capsule_app_store/src/store/verdict.rs diff --git a/.keys/app_store_publisher_ed25519.pub b/.keys/app_store_publisher_ed25519.pub new file mode 100644 index 0000000000000000000000000000000000000000..38557b00d3cd1f522c37f562c16d3360b7f523c5 GIT binary patch literal 43 zcmebC_wx@9@HS**P}n0q%~;*UyvIIJPCGHV)%3P(-8ZSE&26uzEEgqrKV6uZ_kK>4$kj?9cl<-D z{j@c@ZQcp{7m@dF>|w*AYZk)G*$Of*CR9~|H;mz%jSH`F+}YlA5_J= zwz<6dcc+;x6_8Jz>C3_b;)Ook#}5|jqE!i?Eshuj(C(uSUclW)07ex6IVTB?iNY%5 zJQ~02W%IRWFm}qRyS&dxo4Lv;R5?(mz-Z?#q^GT`VK1t@?Q)oZfcmcXR@g99DDkrg zGS!#_+se&!*5?f)eaeLYQ-D01Mm&4o)ts-W#!w~rs__VWKdF6v%U{Lw8l7aStmQaJGH~V zu@F@GJus6kC`mPz%9G3(Q8f(&vN;KjI>nwA&}^D~Dnq7A;=ROXsS8OUtMIZx+;#Up zM42oF&PcCP)KJNzb>dA6!!W|5>fBe%6jEI&X@&UusbFS%XRK2kl+r?*I(Vs_WgkgW zbK}qeLXIBr>RiVb&zwI8Qc^A&!HgT^tTwnem zD3@Luuw;zNWc8vI;(paUor@GvkGfqvy7~+)hIwftk7~FTz z`uDE^Hyzq*kkc{_?>-?*i;00np$k4Sh_`Xit9}|XfG>P&jQY%;o3;RPq8ka17FGGX znw^#rrNjAmYTby51^bQydoA1IFK{FLUr_2qeY>}+jLZ1)jAhhh+bP*8=5o5%6taz) zxLX`%CzYq8v_p@bJ8-GPs)gg!@>0-l+Yomg(2ya-2RcKogo?9Z+wRt;Bc35`xS-x+ z4JqsSV@YQ9`^w0N1_u`7&xo-i10&_6s$^gkZQS_GeQb;M;Urxut6AnSOktX~!Aa_n zfw;tXj4{s}6#Pw+dWjF!?KyyQXShcx_>W!}VwtEb#(6tvc$a=4!WWaruYHXGhH%jxZyh05 zX8;ernqE*|$yhnfOE_{^$n4{7uQ{;021c8wkH*N~DY_Pxc!D)M#h|KxDV9x<}>6-Nqv5c+VSlwwmVdF)7BsI3te~c=q z4|j^!nkPR~3hEMqEx%3z8{((2hzrq*Veg3|pamTXs79mTZqwnPrw}2{^w(dYPDG}t zb|IIAnBU(D1Fmtq_8=j|frKlkSukfvdjfNXSTY!C!jXo*^mV;3C>Mdr$h4rk4s1sva{`g5TCRWXlpmmpr^&>yX;Q$YO?!AnhlzZnCu5!~u;Diz!A`<^nv!BGOOn z5Uy2~{)*=ycvj>Fy+%oWQ^?DDM95iR;h0@uV*%LF3F<#QFI!S(xvH2IJM%GJCBE@K zEnGkhG0QkZ_kCocuNv_M99j6}7ju9%b9+9jH2wo~7Kg)<=5EAI4qA$-iXpC3vG}$( zQ1cFxUE+6ua9Dyq-K&!$0 z$2R&we#)1@(f>ejoMAxT=dS0EY3tuoyv4u&NETHgGM0tAjn}4=|GL-Y&=?Wep6g6Y zrp5__Se3#fbiaqaNvpcNobC&kwQcTH2ro;Py?az;ILd*dtozVWNoEQ6TV=4|R|m>9 z=zqZ7GFksDX?NC5*3qz_?(jmde|S?=o*dJ9_xG^jV9<6Hm$ literal 0 HcmV?d00001 diff --git a/Cargo.toml b/Cargo.toml index 5a63748cf6..9f907d1a46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,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 = [] @@ -160,6 +161,7 @@ nonos-capsule-net-core = [] nonos-capsule-net-sockets = [] nonos-capsule-net-nym = [] nonos-capsule-socks5 = [] +nonos-capsule-linux = [] nonos-capsule-market = [] # Layout-stable debug ring. Allocation-free, formatter-free fixed-VA @@ -550,6 +552,8 @@ 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", "nonos-capsule-boot-splash", diff --git a/mk/20-build.mk b/mk/20-build.mk index 4119617f38..6c55ebb2cf 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -3,7 +3,7 @@ # STARK attestation for the kernel and every capsule. This is where make, # make qemu, and make from-config all resolve their real work. -.PHONY: nonos-mk-check-driver-ahci-keys nonos-mk-check-driver-e1000-keys nonos-mk-check-driver-hda-keys nonos-mk-check-driver-i2c-hid-keys nonos-mk-check-driver-i2c-pci-keys nonos-mk-check-driver-iwlwifi-keys nonos-mk-check-driver-nvme-keys nonos-mk-check-driver-rtl8139-keys nonos-mk-check-driver-rtl8169-keys nonos-mk-check-driver-rtl8821ce-keys nonos-mk-check-driver-usb-msc-keys nonos-mk-check-driver-virtio-gpu-keys nonos-mk-check-ps2-input-keys nonos-mk-check-ramfs-keys nonos-mk-check-virtio-blk-keys nonos-mk-check-virtio-net-keys nonos-mk-check-virtio-rng-keys nonos-mk-check-xhci-keys nonos-mk-crypto nonos-mk-driver-ahci nonos-mk-driver-ahci-sign nonos-mk-driver-e1000 nonos-mk-driver-e1000-sign nonos-mk-driver-hda nonos-mk-driver-hda-sign nonos-mk-driver-i2c-hid nonos-mk-driver-i2c-hid-sign nonos-mk-driver-i2c-pci nonos-mk-driver-i2c-pci-sign nonos-mk-driver-iwlwifi nonos-mk-driver-iwlwifi-sign nonos-mk-driver-nvme nonos-mk-driver-nvme-sign nonos-mk-driver-rtl8139 nonos-mk-driver-rtl8139-sign nonos-mk-driver-rtl8169 nonos-mk-driver-rtl8169-sign nonos-mk-driver-rtl8821ce nonos-mk-driver-rtl8821ce-sign nonos-mk-driver-usb-msc nonos-mk-driver-usb-msc-sign nonos-mk-driver-virtio-gpu nonos-mk-driver-virtio-gpu-sign nonos-mk-entropy nonos-mk-keyring nonos-mk-market nonos-mk-proof-io nonos-mk-proof-io-sign nonos-mk-ps2-input nonos-mk-ps2-input-sign nonos-mk-ramfs nonos-mk-ramfs-sign nonos-mk-vfs nonos-mk-virtio-blk nonos-mk-virtio-blk-sign nonos-mk-virtio-net nonos-mk-virtio-net-sign nonos-mk-virtio-rng nonos-mk-virtio-rng-sign nonos-mk-wallpaper nonos-mk-xhci nonos-mk-xhci-sign nonos-mk-all-capsules-attested nonos-mk-attest nonos-mk-attestation nonos-mk-attestation-receipt nonos-mk-bootloader nonos-mk-capsules nonos-mk-check nonos-mk-check-trust-keys nonos-mk-check-trust-manifest nonos-mk-core nonos-mk-core-attested nonos-mk-desktop-gui-prod nonos-mk-smp-prod nonos-mk-ensure-zk-keys nonos-mk-esp nonos-mk-from-config nonos-mk-host-trust-verify nonos-mk-libc nonos-mk-live-production-proof nonos-mk-marketplace-abi nonos-mk-marketplace-index-tool nonos-mk-menuconfig nonos-mk-sign nonos-mk-terminal-test nonos-mk-trust-policy nonos-mk-usb-img nonos-mk-userland-clean nonos-mk-verify-capsule-attest nonos-mk-verify-trust nonos-mk-zerostate nonos-mk-zk-report nonos-mk-zk-tools nonos-mk-zk-verify-live +.PHONY: nonos-mk-check-driver-ahci-keys nonos-mk-check-driver-e1000-keys nonos-mk-check-driver-hda-keys nonos-mk-check-driver-i2c-hid-keys nonos-mk-check-driver-i2c-pci-keys nonos-mk-check-driver-iwlwifi-keys nonos-mk-check-driver-nvme-keys nonos-mk-check-driver-rtl8139-keys nonos-mk-check-driver-rtl8169-keys nonos-mk-check-driver-rtl8821ce-keys nonos-mk-check-driver-usb-msc-keys nonos-mk-check-driver-virtio-gpu-keys nonos-mk-check-ps2-input-keys nonos-mk-check-ramfs-keys nonos-mk-check-virtio-blk-keys nonos-mk-check-virtio-net-keys nonos-mk-check-virtio-rng-keys nonos-mk-check-xhci-keys nonos-mk-crypto nonos-mk-driver-ahci nonos-mk-driver-ahci-sign nonos-mk-driver-e1000 nonos-mk-driver-e1000-sign nonos-mk-driver-hda nonos-mk-driver-hda-sign nonos-mk-driver-i2c-hid nonos-mk-driver-i2c-hid-sign nonos-mk-driver-i2c-pci nonos-mk-driver-i2c-pci-sign nonos-mk-driver-iwlwifi nonos-mk-driver-iwlwifi-sign nonos-mk-driver-nvme nonos-mk-driver-nvme-sign nonos-mk-driver-rtl8139 nonos-mk-driver-rtl8139-sign nonos-mk-driver-rtl8169 nonos-mk-driver-rtl8169-sign nonos-mk-driver-rtl8821ce nonos-mk-driver-rtl8821ce-sign nonos-mk-driver-usb-msc nonos-mk-driver-usb-msc-sign nonos-mk-driver-virtio-gpu nonos-mk-driver-virtio-gpu-sign nonos-mk-entropy nonos-mk-keyring nonos-mk-market nonos-mk-proof-io nonos-mk-proof-io-sign nonos-mk-ps2-input nonos-mk-ps2-input-sign nonos-mk-ramfs nonos-mk-ramfs-sign nonos-mk-vfs nonos-mk-virtio-blk nonos-mk-virtio-blk-sign nonos-mk-virtio-net nonos-mk-virtio-net-sign nonos-mk-virtio-rng nonos-mk-virtio-rng-sign nonos-mk-wallpaper nonos-mk-xhci nonos-mk-xhci-sign nonos-mk-all-capsules-attested nonos-mk-attest nonos-mk-attestation nonos-mk-attestation-receipt nonos-mk-bootloader nonos-mk-capsules nonos-mk-check nonos-mk-check-trust-keys nonos-mk-check-trust-manifest nonos-mk-core nonos-mk-core-attested nonos-mk-desktop-gui-prod nonos-mk-smp-prod nonos-mk-ensure-zk-keys nonos-mk-esp nonos-mk-from-config nonos-mk-host-trust-verify nonos-mk-libc nonos-mk-live-production-proof nonos-mk-cap-audit nonos-mk-market-catalogue nonos-mk-market-catalogue-sign nonos-mk-marketplace-abi nonos-mk-marketplace-index-tool nonos-mk-menuconfig nonos-mk-sign nonos-mk-terminal-test nonos-mk-trust-policy nonos-mk-usb-img nonos-mk-userland-clean nonos-mk-verify-capsule-attest nonos-mk-verify-trust nonos-mk-zerostate nonos-mk-zk-report nonos-mk-zk-tools nonos-mk-zk-verify-live # ZK attestation: transparent enrolled-secret tools @@ -556,6 +556,8 @@ 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 include userland/capsule_game_2048/Capsule.mk @@ -731,7 +733,7 @@ NONOS_DESKTOP_GUI_CAPSULE_CHECKS = \ $(driver-usb-hid_VERIFY) \ $(net-core_VERIFY) $(net-sockets_VERIFY) $(net-nym_VERIFY) \ $(policy_VERIFY) $(wallpaper_catalog_VERIFY) \ - $(installer_VERIFY) \ + $(installer_VERIFY) $(linux_VERIFY) \ $(input-router_VERIFY) $(compositor_VERIFY) $(wm_VERIFY) \ $(desktop-shell_VERIFY) $(image-codec_VERIFY) $(image-viewer_VERIFY) $(clipboard_VERIFY) \ $(login_VERIFY) $(wallpaper_VERIFY) $(toolkit_VERIFY) \ @@ -1139,7 +1141,7 @@ DESKTOP_BASE_SLUGS := proof-io ramfs keyring entropy crypto vfs \ driver-virtio-net driver-ps2-input driver-xhci driver-usb-hid \ net-core net-sockets net-nym socks5 policy wallpaper_catalog \ installer input-router compositor wm desktop-shell image-codec \ - clipboard login wallpaper toolkit about boot-splash calculator \ + clipboard login wallpaper toolkit about linux boot-splash calculator \ browser wallet-nonos terminal file-manager text-editor \ settings process-manager attest power \ audio driver-hda audio_player video-player @@ -1164,6 +1166,15 @@ nonos-mk-desktop-gui-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) \ nonos-mk-check-deps nonos-mk-ensure-signing-key $(call nonos_kernel_build,microkernel-desktop-gui + nonos-stark-attest,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest) +# nonos-mk-install-prod: the desktop profile with the NVMe driver capsule in +# it. The desktop cut leaves NVMe out because a driver whose hardware is absent +# blocks on spawn; the install lane presents an NVMe target to QEMU, so the +# driver has a device and the installer has a disk that is not the store. +nonos-mk-install-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) $(driver-nvme_ARTIFACTS) \ + nonos-mk-verify-desktop-gui-capsules \ + nonos-mk-check-deps nonos-mk-ensure-signing-key + $(call nonos_kernel_build,microkernel-desktop-gui + nvme + install,microkernel-desktop-gui$(_boot_comma)nonos-stark-attest$(_boot_comma)nonos-capsule-driver-nvme) + # nonos-mk-smp-prod: the desktop profile with the secondary CPUs turned on. # Same capsule set and the same attestation, so a difference between this boot # and the single-CPU one is the AP bring-up and nothing else. @@ -1236,10 +1247,6 @@ nonos-mk-input-probe-inject-esp: $(NONOS_BOOT_EFI) @cp $(TARGET_DIR)/kernel_attested.bin $(NONOS_INPUT_PROBE_INJECT_ESP)/EFI/nonos/kernel.bin @printf "timeout=0\ndefault=nonos\n" > $(NONOS_INPUT_PROBE_INJECT_ESP)/EFI/nonos/boot.cfg @echo 'fs0:\EFI\Boot\BOOTX64.EFI' > $(NONOS_INPUT_PROBE_INJECT_ESP)/startup.nsh - @# This target packs its own ESP instead of going through nonos-mk-esp, so - @# it needs the same check: the staged kernel is the one just linked. - @$(NONOS_PYTHON) scripts/check_staged_kernel.py --elf $(MICROKERNEL_BIN) \ - --staged $(NONOS_INPUT_PROBE_INJECT_ESP)/EFI/nonos/kernel.bin nonos-mk-terminal-only-prod: $(proof-io_ARTIFACTS) $(ramfs_ARTIFACTS) $(keyring_ARTIFACTS) \ $(entropy_ARTIFACTS) $(crypto_ARTIFACTS) $(vfs_ARTIFACTS) \ @@ -1370,13 +1377,6 @@ endif @cp $(TARGET_DIR)/kernel_attested.bin $(ESP_DIR)/EFI/nonos/kernel.bin @printf "timeout=0\ndefault=nonos\n" > $(ESP_DIR)/EFI/nonos/boot.cfg @echo 'fs0:\EFI\Boot\BOOTX64.EFI' > $(ESP_DIR)/startup.nsh - @# The ELF just linked is a byte prefix of what was staged, or the pack - @# chain raced the link and this ESP boots an older kernel. Checked here - @# rather than in each boot target, so nothing that consumes an ESP can - @# skip it and no boot verdict can describe a kernel that is not in the - @# tree. - @$(NONOS_PYTHON) scripts/check_staged_kernel.py \ - --elf $(MICROKERNEL_BIN) --staged $(ESP_DIR)/EFI/nonos/kernel.bin @echo "ESP ready at $(ESP_DIR)" # Produce a real, flashable GPT disk image with a FAT32 EFI System Partition. @@ -1396,3 +1396,50 @@ nonos-mk-usb-img: nonos-mk-esp @echo " Validate as a real disk: make nonos-mk-usb-run" @echo " Flash (macOS): sudo dd if=$(USB_IMG) of=/dev/rdiskN bs=4m && sync" @echo " Flash (Linux): sudo dd if=$(USB_IMG) of=/dev/sdX bs=4M oflag=direct && sync" + +# The marketplace catalogue. The generator reads what is actually on +# disk (signed capsules, fetched packages, community submissions) and +# writes plain JSON; the CLI encodes it to the canonical binary the +# market capsule ingests. Signing is a separate step with the operator +# seed, which never appears in a build rule. +MARKET_CATALOGUE_JSON := nonos-data/marketplace/index.json +MARKET_CATALOGUE_BIN := nonos-data/marketplace/index.bin +MARKET_OPERATOR_PUBKEY ?= a7c92db24d99e7baee8b45a06dc353ccd4142622c1a90a52b5327db2e6d17811 +MARKET_SERIAL ?= 1 +# The operator seed. Gitignored, 0600, generated by `marketplace-index +# keygen`. Overridable so a release build can sign from elsewhere. +MARKET_OPERATOR_SEED ?= .keys/marketplace_operator_ed25519.seed + +# The catalogue records the hash of each capsule's trailer, and +# enrolment rewrites every trailer, so generating it first would record +# hashes of files that no longer exist. Ordering it after the policy +# root is the difference between a catalogue and a list of stale +# digests that still looks well formed. +# A capability that cannot reach a token is not a capability, and a +# syscall with no gate is not gated. Both failures compile, boot and stay +# silent, so they are checked here rather than trusted. +nonos-mk-cap-audit: + @python3 tools/nonos-cap-audit --root . + +nonos-mk-market-catalogue: $(ZK_CAPSULE_ROOT) + @python3 tools/nonos-market-catalogue \ + --out $(MARKET_CATALOGUE_JSON) \ + --operator-pubkey $(MARKET_OPERATOR_PUBKEY) \ + --serial $(MARKET_SERIAL) \ + --linux-list nonos-data/marketplace/linux.list + +# Requires MARKET_OPERATOR_SEED to name a file holding the 32-byte +# operator seed. Without it the catalogue encodes but stays unsigned, +# and the capsule refuses an unsigned index, which is the point. +# Not `nonos-mk-market-sign`: capsule.mk emits that name for the market +# capsule itself, and two recipes under one target is a coin toss. +nonos-mk-market-catalogue-sign: $(MARKETPLACE_INDEX_TOOL) $(MARKET_CATALOGUE_JSON) + @test -n "$(MARKET_OPERATOR_SEED)" || \ + { echo "::error::set MARKET_OPERATOR_SEED="; exit 1; } + @$(MARKETPLACE_INDEX_TOOL) sign \ + --in $(MARKET_CATALOGUE_JSON) \ + --key-file $(MARKET_OPERATOR_SEED) \ + --pubkey $(MARKET_OPERATOR_PUBKEY) \ + --out $(MARKET_CATALOGUE_BIN) + @$(MARKETPLACE_INDEX_TOOL) verify \ + --in $(MARKET_CATALOGUE_BIN) --pubkey $(MARKET_OPERATOR_PUBKEY) 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 beaded5dbd..c3ee11bae1 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_hello(); spawn_calculator(); spawn_clock(); @@ -26,6 +27,7 @@ pub(super) fn spawn() { spawn_terminal(); spawn_file_manager(); spawn_audio_player(); + spawn_linux(); super::apps_tools::spawn(); } @@ -50,6 +52,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() {} + #[cfg(feature = "nonos-capsule-hello")] fn spawn_hello() { use crate::userspace::capsule_hello as c; @@ -146,3 +156,13 @@ fn spawn_snake() { } #[cfg(not(feature = "nonos-capsule-snake"))] fn spawn_snake() {} + +// The Linux personality. +#[cfg(feature = "nonos-capsule-linux")] +fn spawn_linux() { + use crate::userspace::capsule_linux as c; + super::boot::capsule("APP-LINUX", "app_linux", c::spawn_linux_capsule, c::shared_state); +} + +#[cfg(not(feature = "nonos-capsule-linux"))] +fn spawn_linux() {} 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 0b0e96e1a6..0d8f104bee 100644 --- a/src/userspace/mod.rs +++ b/src/userspace/mod.rs @@ -14,19 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Microkernel runtime: init bootstrap and the kernel-side mirrors -// for every userland capsule the boot path spawns. Real capsule -// binaries live under `userland//`; the mirror here only -// carries the signed embed bytes (ELF + manifest + cert), the -// spawn entry, and liveness state. No protocol logic lives in -// the kernel — that runs inside the spawned capsule. -// -// Kernel-resident `*_engine` wrappers live under `src/services/` -// and are not real userspace. The CI grep gate in -// `nonos-ci/run-static-checks.sh` rejects any new -// `src/userspace/*_service` directory. +// Microkernel runtime: init bootstrap and the kernel-side mirrors for every +// userland capsule the boot path spawns. pub mod capsule_about; +pub mod capsule_app_store; +pub mod capsule_linux; pub mod capsule_attest; pub mod capsule_audio_player; pub mod capsule_boot_splash; 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 0000000000000000000000000000000000000000..e086216b2c331c0e487616ce1ed05662085076cc GIT binary patch literal 36864 zcmeI5Z`Gqb5XEs22}r1fNI(J-kbndvL;@0!fP_du0^9DsPtW8JeVFMb;XP;gvS~WG z_cxcM<-=~bY4p+rGyzRO6VL=S0Zl*?&;&FAO+XXS1T+CnKoigeGyzRO6VL=S0Zrgs z0>%#G^4~QM)|^{)k1EqSzeksM-T$PA&PC|&m#(`UC;a0egL>2bA#C=Iz8-1T*<~p|OuDR0 ztw}cizTc?<0sfsD5aHjafqoZW_c+A%kbYgJah#U*u)x+juf*5n9~t~a;OykHJ#07l zBFld3Is*T%SL<>e%p{09!!Cohv^kb`$&_}v%7SyoCs5lVQm&Y?AaUE(Hc%9#BdvWLy0GtR8)yqT zPR8Ff(G95e3G@i@*N>S8Z?56WNATulFWSK^q45>37B`%}k&IT&5QTOH`;X>27zWyj zQZq(-{m1F5KerLtLFU2tD)6`N@niUjyn_D_+Jg=H^u4MF(+DbZaj^eD}0nh&3ip^TxaK&yk^PSN@OQx&s&q0H$*L}N9wi!06YdnO8Y04gCZ!N-hzNmV3?bGK5e76Vg;+yG2oz%ov4)5+ph+pj8X`iV z7(<9PM1%oNN+H$|5dy^+LaZSo3}{jcv4)5cD8>+C4H02LlTwH^M1(*wh7fCr2m_jw zLaZSo1d1_)SVKe@(4-V%4G|$wj3LAtBEov0YtRW%{Xi^HXc00W){`~<*I%5n+iob7+ zm0y=`f%PcU3;A~|Q<1E)0sb!JkNy>w@GeUDUC1x|muGFLQtk%W$hehc@Lc0Vo$Vpi zVJ>@wyMOo0_3v^Q^6zDSyLpK4<-gP#9e`&yez5QEE*aRtN1^SzaQi>`QmsV%`U&y& zX`23ey5Al8FK)E|UHC=Pfh+NS6F_G9M2n>rK0qB!77=;0G;R z^qXJ?)4y((>1SW!F8KMv|0L$pAOHC)&f3>ntO;lWnt&#t31|YEfF_^`XabsmCZGwN GOW+S$=4$!? literal 0 HcmV?d00001 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..106bb334e5 --- /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", From 1d95cf3f67f489fe9780b4cf1a8ba48606fa9dd3 Mon Sep 17 00:00:00 2001 From: senseix21 Date: Thu, 24 Sep 2026 22:08:22 +0600 Subject: [PATCH 2/2] fix(store): end the search-bar match as a statement text::line returns the drawn width, so the bare match evaluated to i32 where the function body expects (), failing the capsule build. --- userland/capsule_app_store/src/store/ui/searchbar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userland/capsule_app_store/src/store/ui/searchbar.rs b/userland/capsule_app_store/src/store/ui/searchbar.rs index 106bb334e5..b029babfd3 100644 --- a/userland/capsule_app_store/src/store/ui/searchbar.rs +++ b/userland/capsule_app_store/src/store/ui/searchbar.rs @@ -44,7 +44,7 @@ pub fn paint(fb: &mut PaintBuffer, search: &Search, right: u32) -> u32 { 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);