feat(settings): settings app: apps list + per-capability allow/revoke, about (who/role), sign out; system-only caps permissions/session (install refused); pm MANAGE_BADGE copy (list/apps/allow/revoke only), APPS op; init WHO + session pass-on; factory decisions seeded for new system apps; k-ui list_item/toggle, screens/back/scroll

This commit is contained in:
2026-10-28 13:14:35 +01:00
parent a2d4a7d085
commit 29becf297c
38 changed files with 947 additions and 85 deletions

10
Cargo.lock generated
View File

@@ -644,6 +644,16 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "settings"
version = "0.1.0"
dependencies = [
"k-ui",
"libkos",
"manifest",
"types",
]
[[package]] [[package]]
name = "shell" name = "shell"
version = "0.1.0" version = "0.1.0"

View File

@@ -5,6 +5,7 @@ members = [
"crates/k-ui", "crates/k-ui",
"crates/apps/flaky", "crates/apps/flaky",
"crates/apps/tally", "crates/apps/tally",
"crates/apps/settings",
"crates/apps/hello", "crates/apps/hello",
"crates/apps/shell", "crates/apps/shell",
"crates/apps/cat", "crates/apps/cat",

View File

@@ -54,7 +54,7 @@ kani: kani-types kani-domain kani-kernel
PROGRAMS = -p init -p echo -p sleep -p snake \ PROGRAMS = -p init -p echo -p sleep -p snake \
-p display-qemu -p input-qemu -p storage-qemu \ -p display-qemu -p input-qemu -p storage-qemu \
-p log-server -p permission-manager -p session-server -p smptest -p spawntest -p hello \ -p log-server -p permission-manager -p session-server -p smptest -p spawntest -p hello \
-p terminal-server -p shell -p cat -p ls -p counter -p gatekeeper -p flaky -p tally -p terminal-server -p shell -p cat -p ls -p counter -p gatekeeper -p flaky -p tally -p settings
# Programs first: the kernel image packs init and storage (see the # Programs first: the kernel image packs init and storage (see the
# adapter's build.rs). # adapter's build.rs).

View File

@@ -0,0 +1,15 @@
[package]
name = "settings"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "settings"
test = false
bench = false
[dependencies]
types = { workspace = true }
libkos = { workspace = true }
k-ui = { workspace = true }
manifest = { workspace = true }

View File

@@ -0,0 +1,10 @@
[app]
id = "kos.settings"
name = "Settings"
version = "1.0.0"
binary = "settings"
author = "K-OS"
memory = "3 MiB"
[capabilities]
required = ["display", "permissions", "session"]

View File

@@ -0,0 +1,239 @@
//! Settings: what each app may use (allow or revoke, through the
//! permission manager), who is signed in, and signing out. A system app:
//! `permissions` and `session` are only ever granted to apps on the system
//! image.
#![no_std]
#![forbid(unsafe_code)]
#![no_main]
extern crate alloc;
extern crate libkos;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use k_ui::{App, ButtonVariant, Element, Spacing, TextColour, TextStyle, button, column, list_item, text, toggle};
use libkos::apps::Apps;
use libkos::session::Role;
use manifest::{Capabilities, Capability};
use types::address::VirtualAddress;
/// Where the request page for the permission manager is mapped.
const REQUEST_PAGE_VA: VirtualAddress = VirtualAddress::from_raw(0x0190_0000);
const LISTING: usize = 4096;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Screen {
Home,
Apps,
/// One app, by its place in the list.
App(usize),
About,
}
#[derive(Clone, Copy)]
enum Event {
Show(Screen),
/// Allow or revoke this capability of the app on screen.
Flip(Capability),
SignOut,
}
struct AppRow {
id: String,
name: String,
required: Capabilities,
requested: Capabilities,
granted: Capabilities,
}
struct Settings {
screen: Screen,
apps: Option<Apps>,
rows: Vec<AppRow>,
/// Who is signed in, and how.
who: Option<(String, Role)>,
/// What went wrong last, shown until the next change.
trouble: Option<String>,
}
/// What a capability lets an app do, as the user reads it.
fn describe(c: Capability) -> &'static str {
match c {
Capability::Display => "A window, and the keyboard while it is in front",
Capability::Storage => "A folder of its own",
Capability::Permissions => "Change what other apps may use",
Capability::Session => "See who is signed in, and sign out",
Capability::Network => "The network",
_ => c.name(),
}
}
impl Settings {
fn reload(&mut self) {
self.rows.clear();
let Some(apps) = &self.apps else { return };
let mut buf = [0u8; LISTING];
let Ok(n) = apps.apps(&mut buf) else {
self.trouble = Some("The permission manager did not answer.".to_string());
return;
};
let text = core::str::from_utf8(&buf[..n]).unwrap_or("");
for e in libkos::apps::entries(text) {
let required = Capabilities::from_bits(e.required);
self.rows.push(AppRow {
id: e.id.to_string(),
name: e.name.to_string(),
required,
requested: required.union(Capabilities::from_bits(e.optional)),
granted: Capabilities::from_bits(e.granted),
});
}
}
fn flip(&mut self, index: usize, cap: Capability) {
let (Some(apps), Some(row)) = (&self.apps, self.rows.get(index)) else { return };
let change = if row.granted.contains(cap) { apps.revoke(&row.id, cap.index()) } else { apps.allow(&row.id, cap.index()) };
self.trouble = change.err().map(|r| format!("That did not change (code {}).", r.code));
self.reload();
}
fn heading(title: &str) -> k_ui::element::FlexBuilder<Event> {
column().padding(Spacing::Xl).gap(Spacing::Md).child(text(title).style(TextStyle::Heading))
}
fn hint(view: k_ui::element::FlexBuilder<Event>, s: &str) -> k_ui::element::FlexBuilder<Event> {
view.child(text(s).style(TextStyle::Caption).colour(TextColour::Secondary))
}
fn home(&self) -> Element<Event> {
Self::heading("Settings")
.child(list_item("Apps", Event::Show(Screen::Apps)).subtitle("What each app may use"))
.child(list_item("About", Event::Show(Screen::About)).subtitle("Who is signed in, and this device"))
.child(button("Sign out", Event::SignOut).variant(ButtonVariant::Destructive))
.into()
}
fn app_list(&self) -> Element<Event> {
let rows = self.rows.iter().enumerate().map(|(i, r)| {
let allowed = r.granted.intersect(r.requested).iter().count();
let asked = r.requested.iter().count();
list_item(r.name.clone(), Event::Show(Screen::App(i))).subtitle(format!("{} · {} of {} allowed", r.id, allowed, asked))
});
let view = Self::hint(Self::heading("Apps"), "Esc goes back").children(rows);
self.with_trouble(view).into()
}
fn app(&self, index: usize) -> Element<Event> {
let Some(r) = self.rows.get(index) else { return self.app_list() };
let mut view = Self::hint(Self::heading(&r.name), &r.id);
if r.requested.is_empty() {
view = Self::hint(view, "It asks for nothing.");
}
for cap in r.requested.iter() {
let label = match r.required.contains(cap) {
true => format!("{} (needed to run)", describe(cap)),
false => describe(cap).to_string(),
};
view = view.child(toggle(label, r.granted.contains(cap), Event::Flip(cap)));
}
if !r.requested.is_empty() {
view = Self::hint(view, "Taking something away from a running app stops it.");
}
self.with_trouble(view).into()
}
fn about(&self) -> Element<Event> {
let (name, role) = match &self.who {
Some((n, Role::Owner)) => (n.as_str(), "an owner of this device"),
Some((n, Role::Standard)) => (n.as_str(), "a user of this device"),
Some((_, Role::Guest)) => ("Guest", "a guest: nothing is kept"),
None => ("?", "unknown"),
};
Self::hint(Self::heading("About"), "Esc goes back")
.child(text(format!("Signed in as {name}")).style(TextStyle::Subheading))
.child(text(format!("You are {role}.")))
.child(text(format!("K-OS {}", env!("CARGO_PKG_VERSION"))))
.child(text("RISC-V, on QEMU's virt board").colour(TextColour::Secondary))
.into()
}
fn with_trouble(&self, view: k_ui::element::FlexBuilder<Event>) -> k_ui::element::FlexBuilder<Event> {
match &self.trouble {
Some(t) => view.child(text(t.clone()).colour(TextColour::Accent)),
None => view,
}
}
}
impl App for Settings {
type Event = Event;
fn init() -> Self {
let apps = libkos::start::permissions().and_then(|ep| Apps::connect(ep, REQUEST_PAGE_VA));
let mut name = [0u8; types::name::BYTES];
let who = libkos::start::session()
.and_then(|s| libkos::session::who(s, &mut name))
.map(|(len, role)| (String::from_utf8_lossy(&name[..len]).into_owned(), role));
let mut s = Settings { screen: Screen::Home, apps, rows: Vec::new(), who, trouble: None };
s.reload();
s
}
fn update(&mut self, event: Event) {
match event {
Event::Show(screen) => {
self.trouble = None;
if screen == Screen::Apps {
self.reload();
}
self.screen = screen;
}
Event::Flip(cap) => {
if let Screen::App(i) = self.screen {
self.flip(i, cap);
}
}
Event::SignOut => {
if let Some(s) = libkos::start::session() {
libkos::session::sign_out(s);
}
}
}
}
fn view(&self) -> Element<Event> {
match self.screen {
Screen::Home => self.home(),
Screen::Apps => self.app_list(),
Screen::App(i) => self.app(i),
Screen::About => self.about(),
}
}
fn back(&mut self) -> bool {
self.trouble = None;
self.screen = match self.screen {
Screen::Home => return false,
Screen::App(_) => Screen::Apps,
Screen::Apps | Screen::About => Screen::Home,
};
true
}
fn screen(&self) -> usize {
match self.screen {
Screen::Home => 0,
Screen::Apps => 1,
Screen::About => 2,
Screen::App(i) => 3 + i,
}
}
}
libkos::entry!(main);
fn main() {
k_ui::run::<Settings>();
}

View File

@@ -133,6 +133,7 @@ fn refused(what: &str, r: Refused) {
reply::BAD_MANIFEST if r.detail > 0 => println!("shell: {}: bad manifest, line {}", what, r.detail), reply::BAD_MANIFEST if r.detail > 0 => println!("shell: {}: bad manifest, line {}", what, r.detail),
reply::BAD_MANIFEST => println!("shell: {}: bad manifest", what), reply::BAD_MANIFEST => println!("shell: {}: bad manifest", what),
reply::NOT_REQUESTED => println!("shell: {}: never asked for that", what), reply::NOT_REQUESTED => println!("shell: {}: never asked for that", what),
reply::SYSTEM_ONLY => println!("shell: {}: asks for {}, which only system apps may have", what, names(r.detail)),
reply::BUSY => println!("shell: no room to run {}", what), reply::BUSY => println!("shell: no room to run {}", what),
_ => println!("shell: {}: failed", what), _ => println!("shell: {}: failed", what),
} }

View File

@@ -109,6 +109,19 @@ fn copy_tree(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result
Ok(()) Ok(())
} }
/// Copies the files in `from` that `to` lacks (no folders: decisions are
/// one file per app).
fn add_missing(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result<(), &'static str> {
let Some((list, n)) = entries(fs, from) else { return Ok(()) };
for entry in list[..n].iter().filter(|e| !e.is_dir) {
let dst = to.join(entry.name()).ok_or("path too long")?;
if fs.open(dst.as_bytes()).and_then(|h| fs.close(h)).is_err() {
copy_file(fs, &from.join(entry.name()).ok_or("path too long")?, &dst, buf)?;
}
}
Ok(())
}
/// Readies `folder`'s areas once storage has its key. /// Readies `folder`'s areas once storage has its key.
pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &mut [u8]) -> Result<(), &'static str> { pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &mut [u8]) -> Result<(), &'static str> {
let name = core::str::from_utf8(folder.as_bytes()).map_err(|_| "a user folder named oddly")?; let name = core::str::from_utf8(folder.as_bytes()).map_err(|_| "a user folder named oddly")?;
@@ -121,6 +134,11 @@ pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &m
for (area, factory) in AREAS { for (area, factory) in AREAS {
let path = base.join(area.as_bytes()).ok_or("path too long")?; let path = base.join(area.as_bytes()).ok_or("path too long")?;
if fs.list_dir(path.as_bytes(), |_| {}).is_ok() { if fs.list_dir(path.as_bytes(), |_| {}).is_ok() {
if area == "permissions" {
// A system app new since this folder was made comes with
// its decision; one the user changed is left as it is.
add_missing(fs, &Path::new(factory), &path, buf)?;
}
continue; continue;
} }
fs.mkdir(path.as_bytes()).map_err(|_| "cannot make an area")?; fs.mkdir(path.as_bytes()).map_err(|_| "cannot make an area")?;

View File

@@ -164,11 +164,15 @@ impl<'a> Launcher<'a> {
let control = crate::devices::system_control().ok_or("no SystemControl to give")?; let control = crate::devices::system_control().ok_or("no SystemControl to give")?;
syscall::delegate(control, child, perms(&[Permission::Execute])).ok_or("cannot give SystemControl")?; syscall::delegate(control, child, perms(&[Permission::Execute])).ok_or("cannot give SystemControl")?;
} }
if p.signs_out { if p.session != crate::manifest::Session::No {
let badge = Badge::new(types::spawn::SESSION_BADGE); let badge = Badge::new(types::spawn::SESSION_BADGE);
// Delegate only to hand it over: the shell's copy cannot pass it on.
let copy = syscall::mint(self.spawn_ep, badge, perms(&[Permission::Execute, Permission::Delegate])).map_err(|_| "cannot mint")?; let copy = syscall::mint(self.spawn_ep, badge, perms(&[Permission::Execute, Permission::Delegate])).map_err(|_| "cannot mint")?;
let given = syscall::delegate(copy, child, perms(&[Permission::Execute])); // Only a program that passes it on may.
let rights = match p.session {
crate::manifest::Session::PassOn => perms(&[Permission::Execute, Permission::Delegate]),
_ => perms(&[Permission::Execute]),
};
let given = syscall::delegate(copy, child, rights);
syscall::drop_cap(copy); syscall::drop_cap(copy);
given.ok_or("cannot give signing out")?; given.ok_or("cannot give signing out")?;
} }

View File

@@ -2,6 +2,16 @@
use types::budget::Limits; use types::budget::Limits;
/// Whether a program holds the session: who is signed in, signing out.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Session {
No,
/// For itself only (the shell).
Own,
/// To pass on to apps granted `session` (the permission manager).
PassOn,
}
/// A board device init hands to a program over its endpoint. /// A board device init hands to a program over its endpoint.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Device { pub enum Device {
@@ -49,8 +59,8 @@ pub struct Program {
pub spawns: Option<Limits>, pub spawns: Option<Limits>,
/// May halt the machine. /// May halt the machine.
pub system_control: bool, pub system_control: bool,
/// May sign the user out (`types::spawn::SIGN_OUT`), in the next slot. /// The session (`types::spawn::SIGN_OUT`, `WHO`), in the next slot.
pub signs_out: bool, pub session: Session,
/// Takes Ctrl+C: a terminal copy with `types::terminal::CONTROL`, last. /// Takes Ctrl+C: a terminal copy with `types::terminal::CONTROL`, last.
pub takes_ctrl_c: bool, pub takes_ctrl_c: bool,
/// Started again if it dies (see init's supervise.rs). /// Started again if it dies (see init's supervise.rs).
@@ -84,7 +94,7 @@ const fn server(name: &'static str, file: &'static [u8], limits: Limits) -> Prog
console: false, console: false,
spawns: None, spawns: None,
system_control: false, system_control: false,
signs_out: false, session: Session::No,
takes_ctrl_c: false, takes_ctrl_c: false,
restart: true, restart: true,
restarts_clients: false, restarts_clients: false,
@@ -141,6 +151,7 @@ pub const SESSION: &[Program] = &[Program {
spawns: Some(Limits { pages: 2048, endpoints: 8, notifications: 8, regions: 8 }), spawns: Some(Limits { pages: 2048, endpoints: 8, notifications: 8, regions: 8 }),
// The shell holds handles to the apps it ran, which die with it. // The shell holds handles to the apps it ran, which die with it.
restarts_clients: true, restarts_clients: true,
session: Session::PassOn,
// Installing waits for the user's consent. // Installing waits for the user's consent.
pinged: false, pinged: false,
..server("permission", b"bin/permission", Limits { pages: 96, ..SMALL }) ..server("permission", b"bin/permission", Limits { pages: 96, ..SMALL })
@@ -167,7 +178,7 @@ pub const DESKTOP: &[Program] = &[Program {
serves: false, serves: false,
uses: &["terminal", "permission"], uses: &["terminal", "permission"],
storage: &[users(b"home", true)], storage: &[users(b"home", true)],
signs_out: true, session: Session::Own,
takes_ctrl_c: true, takes_ctrl_c: true,
..server("shell", b"bin/shell", SMALL) ..server("shell", b"bin/shell", SMALL)
}]; }];

View File

@@ -22,12 +22,25 @@ const LONGEST_NAME: usize = 16;
pub(crate) struct User { pub(crate) struct User {
pub(crate) folder: UserFolder, pub(crate) folder: UserFolder,
pub(crate) guest: bool, pub(crate) guest: bool,
name: [u8; LONGEST_NAME],
name_len: usize,
/// As `types::spawn::role` says.
role: usize,
}
impl User {
/// The reply to `types::spawn::WHO`.
pub(crate) fn who(&self) -> [usize; 4] {
let [a, b, c] = types::name::pack(&self.name[..self.name_len]);
[self.role, a, b, c]
}
} }
/// All the gatekeeper hands over. /// All the gatekeeper hands over.
struct Handover { struct Handover {
flags: usize, flags: usize,
id: u64, id: u64,
role: usize,
key: [usize; 4], key: [usize; 4],
seed: [usize; 4], seed: [usize; 4],
name: [u8; LONGEST_NAME], name: [u8; LONGEST_NAME],
@@ -44,10 +57,10 @@ impl Drop for Handover {
/// Waits for the gatekeeper behind `ep` to let someone in. /// Waits for the gatekeeper behind `ep` to let someone in.
fn ask(ep: CapSlot) -> Result<Handover, &'static str> { fn ask(ep: CapSlot) -> Result<Handover, &'static str> {
let [reply::OK, flags, id, _role] = syscall::request(ep, [UNLOCK, 0, 0, 0]).map_err(|_| "the gatekeeper is gone")? else { let [reply::OK, flags, id, role] = syscall::request(ep, [UNLOCK, 0, 0, 0]).map_err(|_| "the gatekeeper is gone")? else {
return Err("the gatekeeper could not unlock"); return Err("the gatekeeper could not unlock");
}; };
let mut h = Handover { flags, id: id as u64, key: [0; 4], seed: [0; 4], name: [0; LONGEST_NAME], name_len: 0, removed: [None; MAX_REMOVED] }; let mut h = Handover { flags, id: id as u64, role, key: [0; 4], seed: [0; 4], name: [0; LONGEST_NAME], name_len: 0, removed: [None; MAX_REMOVED] };
h.key = syscall::request(ep, [KEY, 0, 0, 0]).map_err(|_| "the gatekeeper gave no key")?; h.key = syscall::request(ep, [KEY, 0, 0, 0]).map_err(|_| "the gatekeeper gave no key")?;
h.seed = syscall::request(ep, [SEED, 0, 0, 0]).map_err(|_| "the gatekeeper gave no seed")?; h.seed = syscall::request(ep, [SEED, 0, 0, 0]).map_err(|_| "the gatekeeper gave no seed")?;
let name = syscall::request(ep, [NAME, 0, 0, 0]).map_err(|_| "the gatekeeper gave no name")?; let name = syscall::request(ep, [NAME, 0, 0, 0]).map_err(|_| "the gatekeeper gave no name")?;
@@ -111,7 +124,12 @@ pub(crate) fn sign_in(l: &mut Launcher) -> Result<(), &'static str> {
if let Some(display) = l.endpoint("display") { if let Some(display) = l.endpoint("display") {
crate::output::post(display, libkos::display::set_user_words(name.as_bytes())); crate::output::post(display, libkos::display::set_user_words(name.as_bytes()));
} }
l.user = Some(User { folder, guest }); let role = match (guest, h.role) {
(true, _) => types::spawn::role::GUEST,
(_, unlocked::OWNER) => types::spawn::role::OWNER,
_ => types::spawn::role::STANDARD,
};
l.user = Some(User { folder, guest, name: h.name, name_len: h.name_len, role });
start_session(l); start_session(l);
Ok(()) Ok(())
} }

View File

@@ -182,10 +182,14 @@ impl Server for Launcher<'_> {
fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<libkos::syscall::Give>) { fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<libkos::syscall::Give>) {
if badge == types::spawn::SESSION_BADGE { if badge == types::spawn::SESSION_BADGE {
if words[0] == types::spawn::SIGN_OUT { return match words[0] {
crate::session::sign_out(self); types::spawn::SIGN_OUT => {
} crate::session::sign_out(self);
return ([MALFORMED, 0, 0, 0], None); ([MALFORMED, 0, 0, 0], None)
}
types::spawn::WHO => (self.user.map_or([MALFORMED, 0, 0, 0], |u| u.who()), None),
_ => ([MALFORMED, 0, 0, 0], None),
};
} }
match (Request::decode(words), cap) { match (Request::decode(words), cap) {
(Some(r), Some(binary)) => { (Some(r), Some(binary)) => {

View File

@@ -44,10 +44,43 @@ pub struct Button<E> {
pub on_press: E, pub on_press: E,
} }
/// A row of a list: pressing it sends `on_press`.
pub struct Item<E> {
pub title: String,
pub subtitle: Option<String>,
pub on_press: E,
}
/// A labelled switch; pressing it sends `on_toggle` (the app flips `on`).
pub struct Toggle<E> {
pub label: String,
pub on: bool,
pub on_toggle: E,
}
pub enum Kind<E> { pub enum Kind<E> {
Flex(Flex<E>), Flex(Flex<E>),
Text(Text), Text(Text),
Button(Button<E>), Button(Button<E>),
Item(Item<E>),
Toggle(Toggle<E>),
}
impl<E> Kind<E> {
/// What pressing it sends, if it can be pressed.
pub fn event(&self) -> Option<&E> {
match self {
Kind::Button(b) => Some(&b.on_press),
Kind::Item(i) => Some(&i.on_press),
Kind::Toggle(t) => Some(&t.on_toggle),
Kind::Flex(_) | Kind::Text(_) => None,
}
}
/// Whether it fills its column across (rows of a list do).
pub fn stretches(&self) -> bool {
matches!(self, Kind::Flex(_) | Kind::Item(_) | Kind::Toggle(_))
}
} }
pub struct Element<E> { pub struct Element<E> {
@@ -74,6 +107,19 @@ pub fn button<E>(label: impl Into<String>, on_press: E) -> ButtonBuilder<E> {
ButtonBuilder(Button { label: label.into(), variant: ButtonVariant::Secondary, on_press }, Size::Shrink) ButtonBuilder(Button { label: label.into(), variant: ButtonVariant::Secondary, on_press }, Size::Shrink)
} }
/// A row of a list titled `title`, sending `on_press` when pressed.
pub fn list_item<E>(title: impl Into<String>, on_press: E) -> ItemBuilder<E> {
ItemBuilder(Item { title: title.into(), subtitle: None, on_press }, Size::Shrink)
}
/// A switch labelled `label`, shown `on` or off, sending `on_toggle` when
/// pressed.
pub fn toggle<E>(label: impl Into<String>, on: bool, on_toggle: E) -> ToggleBuilder<E> {
ToggleBuilder(Toggle { label: label.into(), on, on_toggle }, Size::Shrink)
}
pub struct ItemBuilder<E>(Item<E>, Size);
pub struct ToggleBuilder<E>(Toggle<E>, Size);
pub struct FlexBuilder<E>(Flex<E>, Size); pub struct FlexBuilder<E>(Flex<E>, Size);
pub struct TextBuilder(Text, Size); pub struct TextBuilder(Text, Size);
pub struct ButtonBuilder<E>(Button<E>, Size); pub struct ButtonBuilder<E>(Button<E>, Size);
@@ -139,6 +185,25 @@ impl<E> ButtonBuilder<E> {
} }
} }
impl<E> ItemBuilder<E> {
pub fn subtitle(mut self, s: impl Into<String>) -> Self {
self.0.subtitle = Some(s.into());
self
}
}
impl<E> From<ItemBuilder<E>> for Element<E> {
fn from(b: ItemBuilder<E>) -> Self {
Element { kind: Kind::Item(b.0), size: b.1 }
}
}
impl<E> From<ToggleBuilder<E>> for Element<E> {
fn from(b: ToggleBuilder<E>) -> Self {
Element { kind: Kind::Toggle(b.0), size: b.1 }
}
}
impl<E> From<FlexBuilder<E>> for Element<E> { impl<E> From<FlexBuilder<E>> for Element<E> {
fn from(b: FlexBuilder<E>) -> Self { fn from(b: FlexBuilder<E>) -> Self {
Element { kind: Kind::Flex(b.0), size: b.1 } Element { kind: Kind::Flex(b.0), size: b.1 }

View File

@@ -33,6 +33,14 @@ pub fn button_padding(theme: &Theme) -> (f32, f32) {
(theme.space(Spacing::Lg), theme.space(Spacing::Sm)) (theme.space(Spacing::Lg), theme.space(Spacing::Sm))
} }
/// A list row's room around its text.
pub fn row_padding(theme: &Theme) -> f32 {
theme.space(Spacing::Md)
}
/// A toggle's switch: its track's size.
pub const SWITCH: (f32, f32) = (48.0, 28.0);
/// What `e` needs, width and height. /// What `e` needs, width and height.
pub fn natural<E>(e: &Element<E>, m: &impl Measure, t: &Theme) -> (f32, f32) { pub fn natural<E>(e: &Element<E>, m: &impl Measure, t: &Theme) -> (f32, f32) {
match &e.kind { match &e.kind {
@@ -42,6 +50,17 @@ pub fn natural<E>(e: &Element<E>, m: &impl Measure, t: &Theme) -> (f32, f32) {
let (px, py) = button_padding(t); let (px, py) = button_padding(t);
((w + 2.0 * px).max(MIN_TARGET), (h + 2.0 * py).max(MIN_TARGET)) ((w + 2.0 * px).max(MIN_TARGET), (h + 2.0 * py).max(MIN_TARGET))
} }
Kind::Item(i) => {
let (tw, th) = m.text(&i.title, TextStyle::Label);
let (sw, sh) = i.subtitle.as_ref().map_or((0.0, 0.0), |s| m.text(s, TextStyle::Caption));
let pad = 2.0 * row_padding(t);
(tw.max(sw) + pad, (th + sh + pad).max(MIN_TARGET))
}
Kind::Toggle(g) => {
let (w, h) = m.text(&g.label, TextStyle::Body);
let pad = 2.0 * row_padding(t);
(w + t.space(Spacing::Lg) + SWITCH.0 + pad, (h.max(SWITCH.1) + pad).max(MIN_TARGET))
}
Kind::Flex(f) => { Kind::Flex(f) => {
let pad = 2.0 * t.space(f.padding); let pad = 2.0 * t.space(f.padding);
let gaps = t.space(f.gap) * f.children.len().saturating_sub(1) as f32; let gaps = t.space(f.gap) * f.children.len().saturating_sub(1) as f32;
@@ -109,7 +128,7 @@ fn place<E>(e: &Element<E>, r: Rect, m: &impl Measure, t: &Theme, out: &mut Vec<
Size::Fill => share, Size::Fill => share,
Size::Shrink => *main, Size::Shrink => *main,
}; };
let cross = if matches!(c.kind, Kind::Flex(_)) { inner_cross } else { cross.min(inner_cross) }; let cross = if c.kind.stretches() { inner_cross } else { cross.min(inner_cross) };
let (dx, dy) = across(f.axis, at, 0.0); let (dx, dy) = across(f.axis, at, 0.0);
let (w, h) = across(f.axis, main, cross); let (w, h) = across(f.axis, main, cross);
place(c, Rect { x: inner.x + dx, y: inner.y + dy, w, h }, m, t, out); place(c, Rect { x: inner.x + dx, y: inner.y + dy, w, h }, m, t, out);

View File

@@ -16,7 +16,7 @@ pub mod text;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;
pub use element::{Element, Size, button, column, row, text}; pub use element::{Element, Size, button, column, list_item, row, text, toggle};
pub use run::run; pub use run::run;
pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle}; pub use theme::{ButtonVariant, Spacing, TextColour, TextStyle};
pub use ui::App; pub use ui::App;

View File

@@ -106,19 +106,58 @@ impl Painter<'_, '_> {
Kind::Text(t) => self.text_in(&t.content, t.style, r, false, theme.text_colour(t.colour)), Kind::Text(t) => self.text_in(&t.content, t.style, r, false, theme.text_colour(t.colour)),
Kind::Button(b) => { Kind::Button(b) => {
let (fill, label) = theme.button(b.variant); let (fill, label) = theme.button(b.variant);
if let (Some(fill), Some(path)) = (fill, rounded(r, theme.corner_radius)) { if let Some(fill) = fill {
self.pm.fill_path(&path, &paint(fill), FillRule::Winding, Transform::identity(), None); self.fill(r, theme.corner_radius, fill);
} }
self.text_in(&b.label, TextStyle::Label, r, true, label); self.text_in(&b.label, TextStyle::Label, r, true, label);
if self.focused == Some(me) { }
let ring = Rect { x: r.x - RING_GAP, y: r.y - RING_GAP, w: r.w + 2.0 * RING_GAP, h: r.h + 2.0 * RING_GAP }; Kind::Item(i) => {
if let Some(path) = rounded(ring, theme.corner_radius + RING_GAP) { self.fill(r, theme.corner_radius, theme.surface);
let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() }; let inner = self.inset(r);
self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None); match &i.subtitle {
Some(sub) => {
let half = Rect { h: inner.h / 2.0, ..inner };
self.text_in(&i.title, TextStyle::Label, half, false, theme.text_primary);
self.text_in(sub, TextStyle::Caption, Rect { y: inner.y + inner.h / 2.0, ..half }, false, theme.text_secondary);
} }
None => self.text_in(&i.title, TextStyle::Label, inner, false, theme.text_primary),
} }
// A chevron: there is more behind it.
let chevron = Rect { x: inner.x + inner.w - 12.0, w: 12.0, ..inner };
self.text_in(">", TextStyle::Label, chevron, true, theme.text_secondary);
}
Kind::Toggle(g) => {
self.fill(r, theme.corner_radius, theme.surface);
let inner = self.inset(r);
self.text_in(&g.label, TextStyle::Body, inner, false, theme.text_primary);
let (tw, th) = crate::layout::SWITCH;
let track = Rect { x: inner.x + inner.w - tw, y: inner.y + (inner.h - th) / 2.0, w: tw, h: th };
self.fill(track, th / 2.0, if g.on { theme.accent } else { theme.surface_raised });
let knob = th - 6.0;
let kx = if g.on { track.x + tw - knob - 3.0 } else { track.x + 3.0 };
self.fill(Rect { x: kx, y: track.y + 3.0, w: knob, h: knob }, knob / 2.0, theme.text_primary);
} }
} }
if self.focused == Some(me) && e.kind.event().is_some() {
let ring = Rect { x: r.x - RING_GAP, y: r.y - RING_GAP, w: r.w + 2.0 * RING_GAP, h: r.h + 2.0 * RING_GAP };
if let Some(path) = rounded(ring, theme.corner_radius + RING_GAP) {
let stroke = Stroke { width: RING_WIDTH, ..Stroke::default() };
self.pm.stroke_path(&path, &paint(theme.focus), &stroke, Transform::identity(), None);
}
}
}
/// `r` filled with `c`, its corners rounded by `radius`.
fn fill(&mut self, r: Rect, radius: f32, c: Rgba) {
if let Some(path) = rounded(r, radius) {
self.pm.fill_path(&path, &paint(c), FillRule::Winding, Transform::identity(), None);
}
}
/// `r` less a list row's padding all round.
fn inset(&self, r: Rect) -> Rect {
let p = crate::layout::row_padding(self.theme);
Rect { x: r.x + p, y: r.y + p, w: (r.w - 2.0 * p).max(0.0), h: (r.h - 2.0 * p).max(0.0) }
} }
} }

View File

@@ -1,6 +1,7 @@
//! An app's event loop on K-OS: its window (which the permission manager //! An app's event loop on K-OS: its window (which the permission manager
//! opened, named from its manifest), keys from the display while it is in //! opened, named from its manifest), keys from the display while it is in
//! front, a redraw after every change. Escape leaves. //! front, a redraw after every change. Escape goes back, and leaves from
//! the first screen.
use libkos::display::{self, Canvas}; use libkos::display::{self, Canvas};
use libkos::input::{InputEvent, Key as Pressed}; use libkos::input::{InputEvent, Key as Pressed};
@@ -11,18 +12,19 @@ use types::display::ScreenCoord;
use crate::text::Fonts; use crate::text::Fonts;
use crate::theme::Theme; use crate::theme::Theme;
use crate::ui::{App, Key, Ui}; use crate::ui::{App, Key, Outcome, Ui};
fn key(k: Pressed) -> Option<Key> { fn key(k: Pressed) -> Option<Key> {
match k { match k {
Pressed::Tab | Pressed::Down | Pressed::Right => Some(Key::Next), Pressed::Tab | Pressed::Down | Pressed::Right => Some(Key::Next),
Pressed::Up | Pressed::Left => Some(Key::Previous), Pressed::Up | Pressed::Left => Some(Key::Previous),
Pressed::Enter | Pressed::Space => Some(Key::Press), Pressed::Enter | Pressed::Space => Some(Key::Press),
Pressed::Escape => Some(Key::Back),
_ => None, _ => None,
} }
} }
/// Runs `A` until Escape (or until it is stopped). /// Runs `A` until it is left (or stopped).
pub fn run<A: App>() -> ! { pub fn run<A: App>() -> ! {
let window = libkos::start::display(); let window = libkos::start::display();
let Some(mut canvas) = window.and_then(Canvas::attach) else { let Some(mut canvas) = window.and_then(Canvas::attach) else {
@@ -41,21 +43,22 @@ pub fn run<A: App>() -> ! {
let mut dirty = true; let mut dirty = true;
loop { loop {
if dirty { if dirty {
draw(&ui, &mut canvas, &fonts); draw(&mut ui, &mut canvas, &fonts);
dirty = false; dirty = false;
} }
let _ = syscall::wait(notify); let _ = syscall::wait(notify);
while let Some(event) = display::poll_input(canvas.window()) { while let Some(event) = display::poll_input(canvas.window()) {
match event { let InputEvent::KeyDown(k) = event else { continue };
InputEvent::KeyDown(Pressed::Escape) => syscall::exit(), match key(k).map(|k| ui.key(k)) {
InputEvent::KeyDown(k) => dirty |= key(k).is_some_and(|k| ui.key(k)), Some(Outcome::Leave) => syscall::exit(),
InputEvent::KeyUp(_) => {} Some(Outcome::Changed) => dirty = true,
Some(Outcome::Nothing) | None => {}
} }
} }
} }
} }
fn draw<A: App>(ui: &Ui<A>, canvas: &mut Canvas, fonts: &Fonts) { fn draw<A: App>(ui: &mut Ui<A>, canvas: &mut Canvas, fonts: &Fonts) {
let (w, h) = (canvas.width(), canvas.height()); let (w, h) = (canvas.width(), canvas.height());
if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) { if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) {
ui.draw(&mut pm, fonts); ui.draw(&mut pm, fonts);

View File

@@ -7,7 +7,8 @@ use crate::element::{Element, button, column, row, text};
use crate::layout::{MIN_TARGET, Measure, Rect, layout}; use crate::layout::{MIN_TARGET, Measure, Rect, layout};
use crate::text::Fonts; use crate::text::Fonts;
use crate::theme::{ButtonVariant, Spacing, TextStyle, Theme}; use crate::theme::{ButtonVariant, Spacing, TextStyle, Theme};
use crate::ui::{App, Key, Ui}; use crate::element::{list_item, toggle};
use crate::ui::{App, Key, Outcome, Ui};
/// Every character 10 wide, every line 20 high. /// Every character 10 wide, every line 20 high.
struct Fixed; struct Fixed;
@@ -80,17 +81,17 @@ impl App for Counter {
#[test] #[test]
fn keys_move_focus_and_press_the_focused_button() { fn keys_move_focus_and_press_the_focused_button() {
let mut ui = Ui::<Counter>::new(T); let mut ui = Ui::<Counter>::new(T);
assert!(ui.key(Key::Press), "Add has focus first"); assert_eq!(ui.key(Key::Press), Outcome::Changed, "Add has focus first");
assert!(ui.key(Key::Press)); assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.0, 2); assert_eq!(ui.app.0, 2);
assert!(ui.key(Key::Next)); assert_eq!(ui.key(Key::Next), Outcome::Changed);
assert!(ui.key(Key::Press), "Reset"); assert_eq!(ui.key(Key::Press), Outcome::Changed, "Reset");
assert_eq!(ui.app.0, 0); assert_eq!(ui.app.0, 0);
// Reset is gone, so focus is back on Add. // Reset is gone, so focus is back on Add.
assert!(ui.key(Key::Press)); assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.0, 1); assert_eq!(ui.app.0, 1);
assert!(ui.key(Key::Previous)); assert_eq!(ui.key(Key::Previous), Outcome::Changed);
assert!(ui.key(Key::Press)); assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.0, 0, "Previous wraps round to Reset"); assert_eq!(ui.app.0, 0, "Previous wraps round to Reset");
} }
@@ -103,7 +104,7 @@ fn at(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) {
#[test] #[test]
fn the_view_is_drawn_in_the_theme_with_a_focus_ring() { fn the_view_is_drawn_in_the_theme_with_a_focus_ring() {
let fonts = Fonts::load().expect("the bundled fonts parse"); let fonts = Fonts::load().expect("the bundled fonts parse");
let ui = Ui::<Counter>::new(T); let mut ui = Ui::<Counter>::new(T);
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap(); let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts); ui.draw(&mut pm.as_mut(), &fonts);
let bg = T.background; let bg = T.background;
@@ -122,3 +123,73 @@ fn the_view_is_drawn_in_the_theme_with_a_focus_ring() {
let lit = (h.x as u32..(h.x + h.w) as u32).any(|x| (h.y as u32..(h.y + h.h) as u32).any(|y| at(&pm, x, y).0 > 150)); let lit = (h.x as u32..(h.x + h.w) as u32).any(|x| (h.y as u32..(h.y + h.h) as u32).any(|y| at(&pm, x, y).0 > 150));
assert!(lit, "the count is drawn"); assert!(lit, "the count is drawn");
} }
/// Two screens: a list of rows, and a detail with a toggle.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Nav {
Open(usize),
Flip,
}
struct Pages {
open: Option<usize>,
flag: bool,
}
impl App for Pages {
type Event = Nav;
fn init() -> Self {
Pages { open: None, flag: false }
}
fn update(&mut self, e: Nav) {
match e {
Nav::Open(i) => self.open = Some(i),
Nav::Flip => self.flag = !self.flag,
}
}
fn view(&self) -> Element<Nav> {
match self.open {
None => column().gap(Spacing::Sm).children((0..30).map(|i| list_item(format!("Row {i}"), Nav::Open(i)).subtitle("more"))).into(),
Some(_) => column().child(text("Detail")).child(toggle("Flag", self.flag, Nav::Flip)).into(),
}
}
fn back(&mut self) -> bool {
self.open.take().is_some()
}
fn screen(&self) -> usize {
self.open.map_or(0, |i| i + 1)
}
}
#[test]
fn rows_open_screens_toggles_flip_and_escape_goes_back_then_leaves() {
let mut ui = Ui::<Pages>::new(T);
assert_eq!(ui.key(Key::Next), Outcome::Changed);
assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.open, Some(1), "the second row");
// A new screen: focus is on its first pressable thing, the toggle.
assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert!(ui.app.flag);
assert_eq!(ui.key(Key::Back), Outcome::Changed);
assert_eq!(ui.app.open, None);
assert_eq!(ui.key(Key::Back), Outcome::Leave, "nowhere left to go back to");
}
#[test]
fn rows_stretch_across_and_the_view_scrolls_to_the_focused_one() {
let fonts = Fonts::load().unwrap();
let mut ui = Ui::<Pages>::new(T);
let view = ui.app.view();
let rects = layout(&view, Rect { x: 0.0, y: 0.0, w: 320.0, h: 5000.0 }, &crate::text::Measurer { fonts: &fonts, theme: T }, T);
assert_eq!(rects[1].w, 320.0, "a row fills its column");
// Thirty rows do not fit in 200 pixels; go to the last one.
for _ in 0..29 {
ui.key(Key::Next);
}
let mut pm = tiny_skia::Pixmap::new(320, 200).unwrap();
ui.draw(&mut pm.as_mut(), &fonts);
let focus = T.focus;
// Its ring is drawn somewhere on screen: the view scrolled to it.
let ringed = (0..200).any(|y| at(&pm, 160, y) == (focus.0, focus.1, focus.2));
assert!(ringed, "the last row is in sight");
}

View File

@@ -1,6 +1,7 @@
//! An app and what K-UI keeps about it: which pressable thing has focus. //! An app and what K-UI keeps about it: which pressable thing has focus,
//! Keys move focus (Tab, the arrows) and press (Enter, Space); a press is //! and how far the view is scrolled to keep it in sight. Keys move focus
//! the button's event, handed to the app. Pure, apart from the pixels it //! (Tab, the arrows), press (Enter, Space) and go back (Escape); a press is
//! the element's event, handed to the app. Pure, apart from the pixels it
//! draws into. //! draws into.
use alloc::vec::Vec; use alloc::vec::Vec;
@@ -21,6 +22,16 @@ pub trait App {
fn update(&mut self, event: Self::Event); fn update(&mut self, event: Self::Event);
/// What the state looks like: a pure function of it. /// What the state looks like: a pure function of it.
fn view(&self) -> Element<Self::Event>; fn view(&self) -> Element<Self::Event>;
/// Escape: back one screen, if there is one to go back to; otherwise
/// the app is left.
fn back(&mut self) -> bool {
false
}
/// Which screen it shows: when this changes, focus starts again from
/// the top.
fn screen(&self) -> usize {
0
}
} }
/// A key, as far as K-UI cares. /// A key, as far as K-UI cares.
@@ -29,6 +40,16 @@ pub enum Key {
Next, Next,
Previous, Previous,
Press, Press,
Back,
}
/// What a key did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
Nothing,
Changed,
/// Back, with nowhere to go back to: the app is to be left.
Leave,
} }
pub struct Ui<A: App> { pub struct Ui<A: App> {
@@ -36,12 +57,14 @@ pub struct Ui<A: App> {
/// Which pressable element has focus: its place among them, in /// Which pressable element has focus: its place among them, in
/// pre-order. /// pre-order.
focus: usize, focus: usize,
/// How far down the view is scrolled, in pixels.
scroll: f32,
theme: &'static Theme, theme: &'static Theme,
} }
/// The pre-order indices of every pressable element in `e`. /// The pre-order indices of every pressable element in `e`.
fn pressables<E>(e: &Element<E>, index: &mut usize, out: &mut Vec<usize>) { fn pressables<E>(e: &Element<E>, index: &mut usize, out: &mut Vec<usize>) {
if matches!(e.kind, Kind::Button(_)) { if e.kind.event().is_some() {
out.push(*index); out.push(*index);
} }
*index += 1; *index += 1;
@@ -57,15 +80,15 @@ fn event_at<E: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Optio
let me = *index; let me = *index;
*index += 1; *index += 1;
match &e.kind { match &e.kind {
Kind::Button(b) if me == target => Some(b.on_press.clone()),
Kind::Flex(f) => f.children.iter().find_map(|c| event_at(c, index, target)), Kind::Flex(f) => f.children.iter().find_map(|c| event_at(c, index, target)),
k if me == target => k.event().cloned(),
_ => None, _ => None,
} }
} }
impl<A: App> Ui<A> { impl<A: App> Ui<A> {
pub fn new(theme: &'static Theme) -> Self { pub fn new(theme: &'static Theme) -> Self {
Self { app: A::init(), focus: 0, theme } Self { app: A::init(), focus: 0, scroll: 0.0, theme }
} }
fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> { fn pressables(&self, view: &Element<A::Event>) -> Vec<usize> {
@@ -74,8 +97,21 @@ impl<A: App> Ui<A> {
out out
} }
/// Handles a key; whether anything changed. /// Handles a key.
pub fn key(&mut self, key: Key) -> bool { pub fn key(&mut self, key: Key) -> Outcome {
if key == Key::Back {
if !self.app.back() {
return Outcome::Leave;
}
// A new screen: from its top.
(self.focus, self.scroll) = (0, 0.0);
return Outcome::Changed;
}
if self.press(key) { Outcome::Changed } else { Outcome::Nothing }
}
/// Moves focus or presses; whether anything changed.
fn press(&mut self, key: Key) -> bool {
let view = self.app.view(); let view = self.app.view();
let targets = self.pressables(&view); let targets = self.pressables(&view);
if targets.is_empty() { if targets.is_empty() {
@@ -83,25 +119,55 @@ impl<A: App> Ui<A> {
} }
let n = targets.len(); let n = targets.len();
match key { match key {
Key::Back => return false,
Key::Next => self.focus = (self.focus + 1) % n, Key::Next => self.focus = (self.focus + 1) % n,
Key::Previous => self.focus = (self.focus + n - 1) % n, Key::Previous => self.focus = (self.focus + n - 1) % n,
Key::Press => { Key::Press => {
let Some(event) = event_at(&view, &mut 0, targets[self.focus.min(n - 1)]) else { return false }; let Some(event) = event_at(&view, &mut 0, targets[self.focus.min(n - 1)]) else { return false };
let screen = self.app.screen();
self.app.update(event); self.app.update(event);
// The view may have fewer pressable things now. if self.app.screen() != screen {
let now = self.pressables(&self.app.view()).len(); (self.focus, self.scroll) = (0, 0.0);
self.focus = self.focus.min(now.saturating_sub(1)); } else {
// The view may have fewer pressable things now.
let now = self.pressables(&self.app.view()).len();
self.focus = self.focus.min(now.saturating_sub(1));
}
} }
} }
true true
} }
/// Lays the view out over the whole of `pm` and draws it. /// Lays the view out over the whole of `pm` and draws it, scrolled so
pub fn draw(&self, pm: &mut PixmapMut, fonts: &Fonts) { /// the focused element is in sight.
pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) {
let view = self.app.view(); let view = self.app.view();
let area = Rect { x: 0.0, y: 0.0, w: pm.width() as f32, h: pm.height() as f32 }; let (w, h) = (pm.width() as f32, pm.height() as f32);
let rects = layout::layout(&view, area, &Measurer { fonts, theme: self.theme }, self.theme); let measurer = Measurer { fonts, theme: self.theme };
// As tall as it needs: what does not fit is scrolled to.
let tall = layout::natural(&view, &measurer, self.theme).1.max(h);
let mut rects = layout::layout(&view, Rect { x: 0.0, y: 0.0, w, h: tall }, &measurer, self.theme);
let focused = self.pressables(&view).get(self.focus).copied(); let focused = self.pressables(&view).get(self.focus).copied();
if let Some(r) = focused.and_then(|i| rects.get(i)) {
self.scroll = keep_in_sight(self.scroll, r.y, r.h, h, tall);
}
for r in &mut rects {
r.y -= self.scroll;
}
render::render(pm, &view, &rects, fonts, self.theme, focused); render::render(pm, &view, &rects, fonts, self.theme, focused);
} }
} }
/// The scroll that shows `top..top + height` within a window `window` high
/// over a view `view` high, moving as little as possible from `scroll`.
fn keep_in_sight(scroll: f32, top: f32, height: f32, window: f32, view: f32) -> f32 {
const MARGIN: f32 = 16.0;
let mut s = scroll;
if top - MARGIN < s {
s = top - MARGIN;
}
if top + height + MARGIN > s + window {
s = top + height + MARGIN - window;
}
s.clamp(0.0, (view - window).max(0.0))
}

View File

@@ -100,6 +100,15 @@ impl Apps {
Ok(len) Ok(len)
} }
/// Every installed app, for a program to read (see `entries`), into
/// `out`; how much.
pub fn apps(&self, out: &mut [u8]) -> Result<usize> {
let (len, _) = self.with_page(wire::APPS, 0)?;
let len = len.min(out.len()).min(PAGE_SIZE);
self.page.copy_to(ByteOffset::ZERO, &mut out[..len]);
Ok(len)
}
/// Takes the capability with catalog index `capability` away from app /// Takes the capability with catalog index `capability` away from app
/// `id` (stopping it if it runs with it). /// `id` (stopping it if it runs with it).
pub fn revoke(&self, id: &str, capability: usize) -> Result<()> { pub fn revoke(&self, id: &str, capability: usize) -> Result<()> {
@@ -114,3 +123,37 @@ impl Apps {
self.with_page(wire::ALLOW, capability).map(|_| ()) self.with_page(wire::ALLOW, capability).map(|_| ())
} }
} }
/// One installed app, as `Apps::apps` lists it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppEntry<'a> {
pub id: &'a str,
pub name: &'a str,
/// Capability bits (`manifest::Capabilities`).
pub required: u32,
pub optional: u32,
pub granted: u32,
}
/// The apps in a listing; a line that does not parse is skipped.
pub fn entries(text: &str) -> impl Iterator<Item = AppEntry<'_>> {
text.lines().filter_map(|line| {
let mut f = line.split('\t');
let (id, name) = (f.next()?, f.next()?);
let mut bits = || f.next()?.parse().ok();
Some(AppEntry { id, name, required: bits()?, optional: bits()?, granted: bits()? })
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_listing_parses_and_bad_lines_are_skipped() {
let text = "kos.snake\tSnake\t1\t0\t1\nnonsense\nkos.notes\tNotes\t0\t3\t2\n";
let all: alloc::vec::Vec<_> = entries(text).collect();
assert_eq!(all.len(), 2);
assert_eq!(all[1], AppEntry { id: "kos.notes", name: "Notes", required: 0, optional: 3, granted: 2 });
}
}

View File

@@ -12,6 +12,7 @@ pub mod input;
pub mod io; pub mod io;
mod rt; mod rt;
pub mod spawn; pub mod spawn;
pub mod session;
pub mod start; pub mod start;
pub mod terminal; pub mod terminal;
pub mod syscall; pub mod syscall;

View File

@@ -0,0 +1,33 @@
//! The session capability's client (`types::spawn::WHO`, `SIGN_OUT`): who
//! is signed in, and signing out. Init answers it.
use crate::syscall;
use types::bounded::CapSlot;
use types::spawn::{SIGN_OUT, WHO, role};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Owner,
Standard,
Guest,
}
/// Who is signed in: their name (into `name`, how long) and role.
pub fn who(session: CapSlot, name: &mut [u8; types::name::BYTES]) -> Option<(usize, Role)> {
let [r, a, b, c] = syscall::request(session, [WHO, 0, 0, 0]).ok()?;
let role = match r {
role::OWNER => Role::Owner,
role::STANDARD => Role::Standard,
role::GUEST => Role::Guest,
_ => return None,
};
let (bytes, len) = types::name::unpack([a, b, c]);
*name = bytes;
Some((len, role))
}
/// Signs the user out: init stops everything the session runs, this
/// program included, and the lock screen comes back.
pub fn sign_out(session: CapSlot) -> bool {
syscall::send_msg(session, [SIGN_OUT, 0, 0, 0], None).is_ok()
}

View File

@@ -44,6 +44,18 @@ pub fn data() -> Option<CapSlot> {
StartArg::decode(arg()).data StartArg::decode(arg()).data
} }
/// The permission manager's management interface, if this app was granted
/// `permissions`.
pub fn permissions() -> Option<CapSlot> {
StartArg::decode(arg()).permissions
}
/// The session (who is signed in, signing out), if this app was granted
/// `session`.
pub fn session() -> Option<CapSlot> {
StartArg::decode(arg()).session
}
/// The display server endpoint this process was given, if any. /// The display server endpoint this process was given, if any.
pub fn display() -> Option<CapSlot> { pub fn display() -> Option<CapSlot> {
StartArg::decode(arg()).display StartArg::decode(arg()).display

View File

@@ -59,6 +59,9 @@ catalog! {
Sync = "sync", Sync = "sync",
Accessibility = "accessibility", Accessibility = "accessibility",
Print = "print", Print = "print",
// System-only (see `Capabilities::SYSTEM_ONLY`).
Permissions = "permissions",
Session = "session",
} }
impl Capability { impl Capability {
@@ -73,6 +76,12 @@ pub struct Capabilities(u32);
impl Capabilities { impl Capabilities {
pub const NONE: Self = Self(0); pub const NONE: Self = Self(0);
/// Only apps on the system image may hold these, pre-approved by
/// whoever built it; an app a user installs that asks for one is
/// refused, so no prompt can talk a user into handing them over.
/// `permissions`: changing what other apps may use. `session`: who is
/// signed in, and signing out.
pub const SYSTEM_ONLY: Self = Self(1 << Capability::Permissions as u32 | 1 << Capability::Session as u32);
/// As sent in a message word. /// As sent in a message word.
pub const fn bits(self) -> u32 { pub const fn bits(self) -> u32 {

View File

@@ -29,6 +29,8 @@ const DISPLAY: CapSlot = CapSlot::const_new(2);
const USER: CapSlot = CapSlot::const_new(3); const USER: CapSlot = CapSlot::const_new(3);
const SYSTEM: CapSlot = CapSlot::const_new(4); const SYSTEM: CapSlot = CapSlot::const_new(4);
const INIT: CapSlot = CapSlot::const_new(5); const INIT: CapSlot = CapSlot::const_new(5);
/// Init's session, to pass on to apps granted `session`.
const SESSION: CapSlot = CapSlot::const_new(6);
const USER_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0180_0000); const USER_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0180_0000);
const SOURCE_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0190_0000); const SOURCE_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0190_0000);
@@ -45,7 +47,8 @@ pub fn run() -> ! {
}; };
let store = store::DiskStore::new(user, system, SOURCE_BUF_VA); let store = store::DiskStore::new(user, system, SOURCE_BUF_VA);
let consent = consent::TerminalConsent { terminal: TERMINAL }; let consent = consent::TerminalConsent { terminal: TERMINAL };
let mut srv = server::PermissionServer::new(store, consent, INIT, Some(DISPLAY), deaths); let ports = server::Ports { init: INIT, display: Some(DISPLAY), session: Some(SESSION), endpoint: EP };
let mut srv = server::PermissionServer::new(store, consent, ports, deaths);
libkos::println!("permission: serving"); libkos::println!("permission: serving");
server_framework::serve(&mut srv, EP) server_framework::serve(&mut srv, EP)
} }

View File

@@ -46,6 +46,8 @@ pub enum Refusal {
Missing(Capabilities), Missing(Capabilities),
/// It never asked for that capability. /// It never asked for that capability.
NotRequested, NotRequested,
/// It asks for these, which only system apps may have.
SystemOnly(Capabilities),
/// Reading or writing the store failed. /// Reading or writing the store failed.
Store, Store,
} }
@@ -68,6 +70,10 @@ pub fn install<S: Store>(
if store.is_installed(&app.id) { if store.is_installed(&app.id) {
return Err(Refusal::AlreadyInstalled); return Err(Refusal::AlreadyInstalled);
} }
let system = app.requested().intersect(Capabilities::SYSTEM_ONLY);
if !system.is_empty() {
return Err(Refusal::SystemOnly(system));
}
let lacking = app.required.minus(available); let lacking = app.required.minus(available);
if !lacking.is_empty() { if !lacking.is_empty() {
return Err(Refusal::Unavailable(lacking)); return Err(Refusal::Unavailable(lacking));

View File

@@ -46,6 +46,9 @@ struct Running {
data: Option<CapSlot>, data: Option<CapSlot>,
/// Its window, closed when it dies. /// Its window, closed when it dies.
window: Option<CapSlot>, window: Option<CapSlot>,
/// The management copy of this server's endpoint (`permissions`),
/// revoked when it dies.
manage: Option<CapSlot>,
} }
pub struct PermissionServer { pub struct PermissionServer {
@@ -54,6 +57,11 @@ pub struct PermissionServer {
/// Init's spawn endpoint. /// Init's spawn endpoint.
init: CapSlot, init: CapSlot,
display: Option<CapSlot>, display: Option<CapSlot>,
/// Init's session (who is signed in, signing out), for apps granted
/// `session`.
session: Option<CapSlot>,
/// This server's own endpoint, which management copies are minted from.
endpoint: CapSlot,
/// Bound to the serving thread: deaths of running apps. /// Bound to the serving thread: deaths of running apps.
deaths: CapSlot, deaths: CapSlot,
staged: Staged, staged: Staged,
@@ -70,6 +78,7 @@ fn refused(r: Refusal) -> [usize; 4] {
Refusal::Declined => (reply::DECLINED, 0), Refusal::Declined => (reply::DECLINED, 0),
Refusal::Missing(c) => (reply::MISSING, c.bits() as usize), Refusal::Missing(c) => (reply::MISSING, c.bits() as usize),
Refusal::NotRequested => (reply::NOT_REQUESTED, 0), Refusal::NotRequested => (reply::NOT_REQUESTED, 0),
Refusal::SystemOnly(c) => (reply::SYSTEM_ONLY, c.bits() as usize),
Refusal::Store => (reply::FAILED, 0), Refusal::Store => (reply::FAILED, 0),
}; };
[code, detail, 0, 0] [code, detail, 0, 0]
@@ -116,14 +125,21 @@ fn parts(page: &[u8]) -> impl Iterator<Item = &str> {
} }
impl PermissionServer { impl PermissionServer {
pub fn new(store: DiskStore, consent: TerminalConsent, init: CapSlot, display: Option<CapSlot>, deaths: CapSlot) -> Self { pub fn new(store: DiskStore, consent: TerminalConsent, ports: Ports, deaths: CapSlot) -> Self {
Self { store, consent, init, display, deaths, staged: Staged::default(), running: [const { None }; MAX_RUNNING] } let Ports { init, display, session, endpoint } = ports;
Self { store, consent, init, display, session, endpoint, deaths, staged: Staged::default(), running: [const { None }; MAX_RUNNING] }
} }
/// What this device has a server for. /// What this device has a server for.
fn available(&self) -> Capabilities { fn available(&self) -> Capabilities {
let base = Capabilities::NONE.with(Capability::Storage); let mut c = Capabilities::NONE.with(Capability::Storage).with(Capability::Permissions);
if self.display.is_some() { base.with(Capability::Display) } else { base } if self.display.is_some() {
c = c.with(Capability::Display);
}
if self.session.is_some() {
c = c.with(Capability::Session);
}
c
} }
fn stage(&mut self, badge: usize, op: usize, cap: Option<CapSlot>) -> [usize; 4] { fn stage(&mut self, badge: usize, op: usize, cap: Option<CapSlot>) -> [usize; 4] {
@@ -185,6 +201,16 @@ impl PermissionServer {
true => self.display.and_then(|d| libkos::display::open(d, plan.app.name.as_str().as_bytes())).map(|o| o.window), true => self.display.and_then(|d| libkos::display::open(d, plan.app.name.as_str().as_bytes())).map(|o| o.window),
false => None, false => None,
}; };
// Managing others through a copy of this endpoint that can do no
// more (see `MANAGE_BADGE`).
let manage = match plan.granted.contains(Capability::Permissions) {
true => {
let perms = PermissionSet::empty().with(Permission::Execute).with(Permission::Delegate);
syscall::mint(self.endpoint, types::badge::Badge::new(wire::MANAGE_BADGE | index), perms).ok()
}
false => None,
};
let session = self.session.filter(|_| plan.granted.contains(Capability::Session));
let staged = (badge == self.staged.badge).then_some(&self.staged); let staged = (badge == self.staged.badge).then_some(&self.staged);
let arg = StartArg { let arg = StartArg {
log: staged.and_then(|s| s.output).and_then(|o| child.give(o, call)).or(child.log()), log: staged.and_then(|s| s.output).and_then(|o| child.give(o, call)).or(child.log()),
@@ -195,6 +221,9 @@ impl PermissionServer {
display: window.and_then(|w| child.give_sending(w, call)), display: window.and_then(|w| child.give_sending(w, call)),
args: child.set_args(args), args: child.set_args(args),
console: None, console: None,
// Delegate, to lend its request page with each call.
permissions: manage.and_then(|m| child.give_sending(m, call)),
session: session.and_then(|s| child.give(s, call)),
}; };
let watched = self.watch(child.process, index); let watched = self.watch(child.process, index);
if !watched || !child.start_with(arg) { if !watched || !child.start_with(arg) {
@@ -203,10 +232,11 @@ impl PermissionServer {
let _ = libkos::fs::release(d); let _ = libkos::fs::release(d);
} }
close_window(window); close_window(window);
revoke(manage);
syscall::drop_cap(child.process); syscall::drop_cap(child.process);
return Err(code(reply::FAILED)); return Err(code(reply::FAILED));
} }
self.running[index] = Some(Running { id: plan.app.id, process: child.process, granted: plan.granted, data, window }); self.running[index] = Some(Running { id: plan.app.id, process: child.process, granted: plan.granted, data, window, manage });
Ok((child.process, plan.granted)) Ok((child.process, plan.granted))
} }
@@ -248,6 +278,20 @@ impl PermissionServer {
if write_page(page, &text.0[..len]) { [reply::OK, len, 0, 0] } else { code(reply::FAILED) } if write_page(page, &text.0[..len]) { [reply::OK, len, 0, 0] } else { code(reply::FAILED) }
} }
/// Every app, for a program to read (`types::apps::APPS`).
fn apps(&mut self, page: CapSlot) -> [usize; 4] {
use core::fmt::Write;
let mut ids = Vec::new();
self.store.ids(|id| ids.push(id));
let mut text = Text(Vec::new());
for id in ids {
let (Some(app), Some(d)) = (self.store.manifest(&id), self.store.decision(&id)) else { continue };
let _ = writeln!(text, "{}\t{}\t{}\t{}\t{}", app.id, app.name.as_str(), app.required.bits(), app.optional.bits(), d.granted.bits());
}
let len = text.0.len().min(PAGE_SIZE);
if write_page(page, &text.0[..len]) { [reply::OK, len, 0, 0] } else { code(reply::FAILED) }
}
/// The owner changes app `id`'s (in the page) capability `index`. /// The owner changes app `id`'s (in the page) capability `index`.
fn change(&mut self, page: CapSlot, index: usize, allow: bool) -> [usize; 4] { fn change(&mut self, page: CapSlot, index: usize, allow: bool) -> [usize; 4] {
let page = read_page(page); let page = read_page(page);
@@ -282,6 +326,7 @@ impl PermissionServer {
let _ = libkos::fs::release(d); let _ = libkos::fs::release(d);
} }
close_window(r.window); close_window(r.window);
revoke(r.manage);
syscall::drop_cap(r.process); syscall::drop_cap(r.process);
} }
} }
@@ -322,11 +367,18 @@ impl Server for PermissionServer {
} }
fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<Give>) { fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option<CapSlot>) -> ([usize; 4], Option<Give>) {
if !allowed(badge, words[0]) {
if let Some(c) = cap {
syscall::drop_cap(c);
}
return ([MALFORMED, 0, 0, 0], None);
}
match (words[0], cap) { match (words[0], cap) {
(wire::PASS_OUTPUT | wire::PASS_FOLDER, cap) => (self.stage(badge, words[0], cap), None), (wire::PASS_OUTPUT | wire::PASS_FOLDER, cap) => (self.stage(badge, words[0], cap), None),
(wire::LAUNCH, Some(page)) => self.launch(badge, page), (wire::LAUNCH, Some(page)) => self.launch(badge, page),
(wire::INSTALL, source) => (self.install(source), None), (wire::INSTALL, source) => (self.install(source), None),
(wire::LIST, Some(page)) => (self.list(page), None), (wire::LIST, Some(page)) => (self.list(page), None),
(wire::APPS, Some(page)) => (self.apps(page), None),
(wire::REVOKE, Some(page)) => (self.change(page, words[1], false), None), (wire::REVOKE, Some(page)) => (self.change(page, words[1], false), None),
(wire::ALLOW, Some(page)) => (self.change(page, words[1], true), None), (wire::ALLOW, Some(page)) => (self.change(page, words[1], true), None),
(_, cap) => { (_, cap) => {
@@ -349,3 +401,26 @@ fn close_window(window: Option<CapSlot>) {
syscall::drop_cap(w); syscall::drop_cap(w);
} }
} }
/// Whether `op` may come through the copy of this endpoint badged `badge`:
/// a management copy (`permissions`) decides, never installs or launches.
pub fn allowed(badge: usize, op: usize) -> bool {
badge & wire::MANAGE_BADGE == 0 || matches!(op, wire::LIST | wire::APPS | wire::REVOKE | wire::ALLOW)
}
fn revoke(cap: Option<CapSlot>) {
if let Some(c) = cap {
let _ = syscall::revoke(c);
syscall::drop_cap(c);
}
}
/// What the permission manager was given to reach others.
pub struct Ports {
/// Init's spawn endpoint.
pub init: CapSlot,
pub display: Option<CapSlot>,
pub session: Option<CapSlot>,
/// Its own endpoint.
pub endpoint: CapSlot,
}

View File

@@ -1 +1,15 @@
mod policy; mod policy;
#[test]
fn a_management_copy_only_decides() {
use crate::server::allowed;
use types::apps::*;
let manager = MANAGE_BADGE | 3;
for op in [LIST, APPS, REVOKE, ALLOW] {
assert!(allowed(manager, op));
}
for op in [LAUNCH, INSTALL, PASS_OUTPUT, PASS_FOLDER, 99] {
assert!(!allowed(manager, op), "op {op}");
}
assert!(allowed(5, LAUNCH) && allowed(5, INSTALL), "a launcher's copy does everything");
}

View File

@@ -202,3 +202,16 @@ fn a_server_gone_since_install_stops_an_app_that_requires_it() {
let headless = Capabilities::NONE.with(STORAGE); let headless = Capabilities::NONE.with(STORAGE);
assert_eq!(plan_launch(&mut store, "snake", headless), Err(Refusal::Missing(set(&[DISPLAY])))); assert_eq!(plan_launch(&mut store, "snake", headless), Err(Refusal::Missing(set(&[DISPLAY]))));
} }
#[test]
fn system_only_capabilities_are_refused_to_installed_apps() {
let mut store = MemoryStore::default();
let mut owner = Owner::says(true, &[]);
for (required, optional) in [("\"permissions\"", ""), ("", "\"session\"")] {
let src = app("kos.sneaky", "sneaky", required, optional);
let refused = install(&mut store, &mut owner, &src, device());
assert!(matches!(refused, Err(Refusal::SystemOnly(c)) if !c.is_empty()), "{refused:?}");
}
assert!(owner.asked.is_empty(), "the user is never asked to hand them over");
assert!(!store.is_installed(&id("kos.sneaky")));
}

View File

@@ -24,6 +24,15 @@ pub const LIST: usize = 5;
/// owner takes it away (a running app that had it is stopped) or gives it. /// owner takes it away (a running app that had it is stopped) or gives it.
pub const REVOKE: usize = 6; pub const REVOKE: usize = 6;
pub const ALLOW: usize = 7; pub const ALLOW: usize = 7;
/// Writes one line per installed app into the page, for a program to read:
/// id, name, then the required, optional and granted capabilities' bits
/// (decimal), tab separated. Reply `OK` with the text's length in word 1.
pub const APPS: usize = 8;
/// On the badge of the copy of its endpoint the permission manager gives
/// an app granted `permissions`: through it only `LIST`, `APPS`, `REVOKE`
/// and `ALLOW` work, never installing or launching.
pub const MANAGE_BADGE: usize = 1 << 61;
/// The request page's size. /// The request page's size.
pub const PAGE_SIZE: usize = 4096; pub const PAGE_SIZE: usize = 4096;
@@ -46,4 +55,6 @@ pub mod reply {
pub const FAILED: usize = 9; pub const FAILED: usize = 9;
/// The offered folder has no `manifest.toml`. /// The offered folder has no `manifest.toml`.
pub const NO_MANIFEST: usize = 10; pub const NO_MANIFEST: usize = 10;
/// Word 1: the capabilities it asks for that only system apps may have.
pub const SYSTEM_ONLY: usize = 11;
} }

View File

@@ -104,27 +104,9 @@ pub mod window {
pub const OK: usize = 0; pub const OK: usize = 0;
pub const PIXEL_BYTES: usize = 4; pub const PIXEL_BYTES: usize = 4;
/// Longest name the OS bar shows. /// Longest name the OS bar shows.
pub const NAME_BYTES: usize = 24; pub use crate::name::BYTES as NAME_BYTES;
/// A name in three words, NUL padded, cut at `NAME_BYTES`. pub use crate::name::{pack as pack_name, unpack as unpack_name};
pub fn pack_name(name: &[u8]) -> [usize; 3] {
let mut bytes = [0u8; NAME_BYTES];
let n = name.len().min(NAME_BYTES);
bytes[..n].copy_from_slice(&name[..n]);
core::array::from_fn(|i| {
u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap_or([0; 8])) as usize
})
}
/// The name in `words`, and how long it is.
pub fn unpack_name(words: [usize; 3]) -> ([u8; NAME_BYTES], usize) {
let mut bytes = [0u8; NAME_BYTES];
for (chunk, w) in bytes.chunks_mut(8).zip(words) {
chunk.copy_from_slice(&(w as u64).to_le_bytes());
}
let len = bytes.iter().position(|b| *b == 0).unwrap_or(NAME_BYTES);
(bytes, len)
}
/// A rectangle in two words: `x | y << 16` and `w | h << 16`. /// A rectangle in two words: `x | y << 16` and `w | h << 16`.
pub fn pack_rect(x: ScreenCoord, y: ScreenCoord, w: Dimension, h: Dimension) -> [usize; 2] { pub fn pack_rect(x: ScreenCoord, y: ScreenCoord, w: Dimension, h: Dimension) -> [usize; 2] {

View File

@@ -15,6 +15,7 @@ pub mod input;
pub mod log; pub mod log;
pub mod macros; pub mod macros;
pub mod mapping; pub mod mapping;
pub mod name;
pub mod permission; pub mod permission;
#[cfg(kani)] #[cfg(kani)]
pub mod proofs; pub mod proofs;

23
crates/types/src/name.rs Normal file
View File

@@ -0,0 +1,23 @@
//! A short name (a user's, an app's) in three message words, NUL padded.
pub const BYTES: usize = 24;
/// `name`, cut at `BYTES`.
pub fn pack(name: &[u8]) -> [usize; 3] {
let mut bytes = [0u8; BYTES];
let n = name.len().min(BYTES);
bytes[..n].copy_from_slice(&name[..n]);
core::array::from_fn(|i| {
u64::from_le_bytes(bytes[i * 8..i * 8 + 8].try_into().unwrap_or([0; 8])) as usize
})
}
/// The name in `words`, and how long it is.
pub fn unpack(words: [usize; 3]) -> ([u8; BYTES], usize) {
let mut bytes = [0u8; BYTES];
for (chunk, w) in bytes.chunks_mut(8).zip(words) {
chunk.copy_from_slice(&(w as u64).to_le_bytes());
}
let len = bytes.iter().position(|b| *b == 0).unwrap_or(BYTES);
(bytes, len)
}

View File

@@ -20,6 +20,15 @@ pub const NONE: usize = usize::MAX;
/// Sent one way (no reply: the sender is about to be stopped) through the /// Sent one way (no reply: the sender is about to be stopped) through the
/// endpoint copy init gives a session's shell: the user signs out. /// endpoint copy init gives a session's shell: the user signs out.
pub const SIGN_OUT: usize = 1; pub const SIGN_OUT: usize = 1;
/// Through the same copy: who is signed in. Reply `[role, name...]`, the
/// name in three words (`types::name`); role as in `role`.
pub const WHO: usize = 2;
pub mod role {
pub const OWNER: usize = 1;
pub const STANDARD: usize = 2;
pub const GUEST: usize = 3;
}
/// The badge on that copy; spawn clients' badges are small indices. /// The badge on that copy; spawn clients' badges are small indices.
pub const SESSION_BADGE: usize = 0x5E55; pub const SESSION_BADGE: usize = 0x5E55;

View File

@@ -10,6 +10,8 @@ const STORAGE_SHIFT: u32 = 16;
const ARGS_BIT: usize = 1 << 24; const ARGS_BIT: usize = 1 << 24;
const DISPLAY_SHIFT: u32 = 32; const DISPLAY_SHIFT: u32 = 32;
const DATA_SHIFT: u32 = 40; const DATA_SHIFT: u32 = 40;
const PERMISSIONS_SHIFT: u32 = 48;
const SESSION_SHIFT: u32 = 56;
/// Where the arguments page sits: the program's arguments, NUL separated, /// Where the arguments page sits: the program's arguments, NUL separated,
/// the first its name. Below the stack, which ends at the image. /// the first its name. Below the stack, which ends at the image.
@@ -27,6 +29,10 @@ pub struct StartArg {
pub display: Option<CapSlot>, pub display: Option<CapSlot>,
/// The app's own data folder, read-write (its `storage` capability). /// The app's own data folder, read-write (its `storage` capability).
pub data: Option<CapSlot>, pub data: Option<CapSlot>,
/// Managing other apps' permissions (the `permissions` capability).
pub permissions: Option<CapSlot>,
/// Who is signed in, and signing out (the `session` capability).
pub session: Option<CapSlot>,
} }
fn field(arg: usize, shift: u32) -> Option<CapSlot> { fn field(arg: usize, shift: u32) -> Option<CapSlot> {
@@ -46,6 +52,8 @@ impl StartArg {
| if self.args { ARGS_BIT } else { 0 } | if self.args { ARGS_BIT } else { 0 }
| put(self.display, DISPLAY_SHIFT) | put(self.display, DISPLAY_SHIFT)
| put(self.data, DATA_SHIFT) | put(self.data, DATA_SHIFT)
| put(self.permissions, PERMISSIONS_SHIFT)
| put(self.session, SESSION_SHIFT)
} }
pub fn decode(arg: usize) -> Self { pub fn decode(arg: usize) -> Self {
@@ -56,6 +64,8 @@ impl StartArg {
args: arg & ARGS_BIT != 0, args: arg & ARGS_BIT != 0,
display: field(arg, DISPLAY_SHIFT), display: field(arg, DISPLAY_SHIFT),
data: field(arg, DATA_SHIFT), data: field(arg, DATA_SHIFT),
permissions: field(arg, PERMISSIONS_SHIFT),
session: field(arg, SESSION_SHIFT),
} }
} }
} }
@@ -73,6 +83,8 @@ mod tests {
args: true, args: true,
display: CapSlot::new(7), display: CapSlot::new(7),
data: CapSlot::new(62), data: CapSlot::new(62),
permissions: CapSlot::new(9),
session: CapSlot::new(63),
}; };
assert_eq!(StartArg::decode(a.encode()), a); assert_eq!(StartArg::decode(a.encode()), a);
assert_eq!(StartArg::decode(0), StartArg::default()); assert_eq!(StartArg::decode(0), StartArg::default());

View File

@@ -87,6 +87,13 @@ optional one is simply not granted.
|---|---| |---|---|
| `display` | a window of its own, named from its manifest: its pixels, and the keyboard while it is in front | | `display` | a window of its own, named from its manifest: its pixels, and the keyboard while it is in front |
| `storage` | the user's `data/<id>/`, read-write (its own folder only) | | `storage` | the user's `data/<id>/`, read-write (its own folder only) |
| `permissions` | a management copy of the permission manager's endpoint (below) |
| `session` | init's session copy: who is signed in (`WHO`), and signing out |
`permissions` and `session` are **system only**: only an app on the system
image (with a factory decision) has them. Installing an app that asks for
either, required or optional, is refused (`SYSTEM_ONLY`). Settings
(`kos.settings`) is the app that holds them.
## Decisions ## Decisions
@@ -100,7 +107,9 @@ granted = ["display"]
Written by the permission manager when the user decides; apps on the Written by the permission manager when the user decides; apps on the
system image ship with their decisions already recorded under system image ship with their decisions already recorded under
`factory/permissions/` (pre-approved by whoever built the image), and init `factory/permissions/` (pre-approved by whoever built the image), and init
seeds them into each new user's `permissions/`. Changing one later is that seeds them into each new user's `permissions/`; a system app added to the
image later is seeded into existing users' at their next sign-in (decisions
already there are left alone). Changing one later is that
user's call, through the permission manager. user's call, through the permission manager.
## Permission manager ## Permission manager
@@ -141,6 +150,14 @@ permission manager:
5. Replies with a handle (the process capability, call-only) the launcher 5. Replies with a handle (the process capability, call-only) the launcher
can wait on and kill. can wait on and kill.
### Managing
`APPS` lists every installed app, a line each: `id`, `name`, then the
required, optional and granted capabilities as bit masks, tab separated.
An app granted `permissions` gets a copy of the permission manager's
endpoint badged `MANAGE_BADGE`: it may only `LIST`, `APPS`, `ALLOW` and
`REVOKE`, never install or launch. The copy is revoked when the app dies.
### Revoke ### Revoke
The user withdraws a capability: the decision changes and the next launch The user withdraws a capability: the decision changes and the next launch

View File

@@ -120,7 +120,10 @@ Signing out (`types::spawn::SIGN_OUT`, sent one way through the copy of
init's endpoint the shell holds) stops everything the session ran (the init's endpoint the shell holds) stops everything the session ran (the
shell, the permission manager, every app), locks storage (`LOCK`, init only: every grant shell, the permission manager, every app), locks storage (`LOCK`, init only: every grant
taken back, every file closed, the key forgotten), removes a guest's taken back, every file closed, the key forgotten), removes a guest's
files, and runs the gatekeeper again. files, and runs the gatekeeper again. `WHO` on the same copy answers the
signed-in user's role (`types::spawn::role`: owner, standard, guest) and
name (`types::name`, packed into the reply's words). The permission manager
holds a copy it may only pass on, call-only, to an app granted `session`.
Init restarts what dies (`init/src/supervise.rs`): every server, and the Init restarts what dies (`init/src/supervise.rs`): every server, and the
session's programs, except the gatekeeper and storage. A restarted server session's programs, except the gatekeeper and storage. A restarted server

View File

@@ -55,6 +55,7 @@ app ls
app sleep app sleep
app snake app snake
app tally app tally
app settings
staged counter inbox/counter staged counter inbox/counter
# A user's first files. # A user's first files.
cp -r disk/home/. "${ROOT}/factory/home/" cp -r disk/home/. "${ROOT}/factory/home/"