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",
]
[[package]]
name = "settings"
version = "0.1.0"
dependencies = [
"k-ui",
"libkos",
"manifest",
"types",
]
[[package]]
name = "shell"
version = "0.1.0"

View File

@@ -5,6 +5,7 @@ members = [
"crates/k-ui",
"crates/apps/flaky",
"crates/apps/tally",
"crates/apps/settings",
"crates/apps/hello",
"crates/apps/shell",
"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 \
-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 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
# 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 => println!("shell: {}: bad manifest", 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),
_ => 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(())
}
/// 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.
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")?;
@@ -121,6 +134,11 @@ pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &m
for (area, factory) in AREAS {
let path = base.join(area.as_bytes()).ok_or("path too long")?;
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;
}
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")?;
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);
// 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 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);
given.ok_or("cannot give signing out")?;
}

View File

@@ -2,6 +2,16 @@
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.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Device {
@@ -49,8 +59,8 @@ pub struct Program {
pub spawns: Option<Limits>,
/// May halt the machine.
pub system_control: bool,
/// May sign the user out (`types::spawn::SIGN_OUT`), in the next slot.
pub signs_out: bool,
/// The session (`types::spawn::SIGN_OUT`, `WHO`), in the next slot.
pub session: Session,
/// Takes Ctrl+C: a terminal copy with `types::terminal::CONTROL`, last.
pub takes_ctrl_c: bool,
/// 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,
spawns: None,
system_control: false,
signs_out: false,
session: Session::No,
takes_ctrl_c: false,
restart: true,
restarts_clients: false,
@@ -141,6 +151,7 @@ pub const SESSION: &[Program] = &[Program {
spawns: Some(Limits { pages: 2048, endpoints: 8, notifications: 8, regions: 8 }),
// The shell holds handles to the apps it ran, which die with it.
restarts_clients: true,
session: Session::PassOn,
// Installing waits for the user's consent.
pinged: false,
..server("permission", b"bin/permission", Limits { pages: 96, ..SMALL })
@@ -167,7 +178,7 @@ pub const DESKTOP: &[Program] = &[Program {
serves: false,
uses: &["terminal", "permission"],
storage: &[users(b"home", true)],
signs_out: true,
session: Session::Own,
takes_ctrl_c: true,
..server("shell", b"bin/shell", SMALL)
}];

View File

@@ -22,12 +22,25 @@ const LONGEST_NAME: usize = 16;
pub(crate) struct User {
pub(crate) folder: UserFolder,
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.
struct Handover {
flags: usize,
id: u64,
role: usize,
key: [usize; 4],
seed: [usize; 4],
name: [u8; LONGEST_NAME],
@@ -44,10 +57,10 @@ impl Drop for Handover {
/// Waits for the gatekeeper behind `ep` to let someone in.
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");
};
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.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")?;
@@ -111,7 +124,12 @@ pub(crate) fn sign_in(l: &mut Launcher) -> Result<(), &'static str> {
if let Some(display) = l.endpoint("display") {
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);
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>) {
if badge == types::spawn::SESSION_BADGE {
if words[0] == types::spawn::SIGN_OUT {
return match words[0] {
types::spawn::SIGN_OUT => {
crate::session::sign_out(self);
([MALFORMED, 0, 0, 0], None)
}
return ([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) {
(Some(r), Some(binary)) => {

View File

@@ -44,10 +44,43 @@ pub struct Button<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> {
Flex(Flex<E>),
Text(Text),
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> {
@@ -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)
}
/// 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 TextBuilder(Text, 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> {
fn from(b: FlexBuilder<E>) -> Self {
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))
}
/// 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.
pub fn natural<E>(e: &Element<E>, m: &impl Measure, t: &Theme) -> (f32, f32) {
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);
((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) => {
let pad = 2.0 * t.space(f.padding);
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::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 (w, h) = across(f.axis, main, cross);
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 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 theme::{ButtonVariant, Spacing, TextColour, TextStyle};
pub use ui::App;

View File

@@ -106,11 +106,39 @@ impl Painter<'_, '_> {
Kind::Text(t) => self.text_in(&t.content, t.style, r, false, theme.text_colour(t.colour)),
Kind::Button(b) => {
let (fill, label) = theme.button(b.variant);
if let (Some(fill), Some(path)) = (fill, rounded(r, theme.corner_radius)) {
self.pm.fill_path(&path, &paint(fill), FillRule::Winding, Transform::identity(), None);
if let Some(fill) = fill {
self.fill(r, theme.corner_radius, fill);
}
self.text_in(&b.label, TextStyle::Label, r, true, label);
if self.focused == Some(me) {
}
Kind::Item(i) => {
self.fill(r, theme.corner_radius, theme.surface);
let inner = self.inset(r);
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() };
@@ -118,8 +146,19 @@ impl Painter<'_, '_> {
}
}
}
/// `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) }
}
}
/// Draws `root`, laid out as `rects`, with `focused` (a pre-order index)

View File

@@ -1,6 +1,7 @@
//! 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
//! 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::input::{InputEvent, Key as Pressed};
@@ -11,18 +12,19 @@ use types::display::ScreenCoord;
use crate::text::Fonts;
use crate::theme::Theme;
use crate::ui::{App, Key, Ui};
use crate::ui::{App, Key, Outcome, Ui};
fn key(k: Pressed) -> Option<Key> {
match k {
Pressed::Tab | Pressed::Down | Pressed::Right => Some(Key::Next),
Pressed::Up | Pressed::Left => Some(Key::Previous),
Pressed::Enter | Pressed::Space => Some(Key::Press),
Pressed::Escape => Some(Key::Back),
_ => None,
}
}
/// Runs `A` until Escape (or until it is stopped).
/// Runs `A` until it is left (or stopped).
pub fn run<A: App>() -> ! {
let window = libkos::start::display();
let Some(mut canvas) = window.and_then(Canvas::attach) else {
@@ -41,21 +43,22 @@ pub fn run<A: App>() -> ! {
let mut dirty = true;
loop {
if dirty {
draw(&ui, &mut canvas, &fonts);
draw(&mut ui, &mut canvas, &fonts);
dirty = false;
}
let _ = syscall::wait(notify);
while let Some(event) = display::poll_input(canvas.window()) {
match event {
InputEvent::KeyDown(Pressed::Escape) => syscall::exit(),
InputEvent::KeyDown(k) => dirty |= key(k).is_some_and(|k| ui.key(k)),
InputEvent::KeyUp(_) => {}
let InputEvent::KeyDown(k) = event else { continue };
match key(k).map(|k| ui.key(k)) {
Some(Outcome::Leave) => syscall::exit(),
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());
if let Some(mut pm) = PixmapMut::from_bytes(canvas.pixels_mut(), w.raw() as u32, h.raw() as u32) {
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::text::Fonts;
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.
struct Fixed;
@@ -80,17 +81,17 @@ impl App for Counter {
#[test]
fn keys_move_focus_and_press_the_focused_button() {
let mut ui = Ui::<Counter>::new(T);
assert!(ui.key(Key::Press), "Add has focus first");
assert!(ui.key(Key::Press));
assert_eq!(ui.key(Key::Press), Outcome::Changed, "Add has focus first");
assert_eq!(ui.key(Key::Press), Outcome::Changed);
assert_eq!(ui.app.0, 2);
assert!(ui.key(Key::Next));
assert!(ui.key(Key::Press), "Reset");
assert_eq!(ui.key(Key::Next), Outcome::Changed);
assert_eq!(ui.key(Key::Press), Outcome::Changed, "Reset");
assert_eq!(ui.app.0, 0);
// 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!(ui.key(Key::Previous));
assert!(ui.key(Key::Press));
assert_eq!(ui.key(Key::Previous), Outcome::Changed);
assert_eq!(ui.key(Key::Press), Outcome::Changed);
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]
fn the_view_is_drawn_in_the_theme_with_a_focus_ring() {
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();
ui.draw(&mut pm.as_mut(), &fonts);
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));
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.
//! Keys move focus (Tab, the arrows) and press (Enter, Space); a press is
//! the button's event, handed to the app. Pure, apart from the pixels it
//! An app and what K-UI keeps about it: which pressable thing has focus,
//! and how far the view is scrolled to keep it in sight. Keys move focus
//! (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.
use alloc::vec::Vec;
@@ -21,6 +22,16 @@ pub trait App {
fn update(&mut self, event: Self::Event);
/// What the state looks like: a pure function of it.
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.
@@ -29,6 +40,16 @@ pub enum Key {
Next,
Previous,
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> {
@@ -36,12 +57,14 @@ pub struct Ui<A: App> {
/// Which pressable element has focus: its place among them, in
/// pre-order.
focus: usize,
/// How far down the view is scrolled, in pixels.
scroll: f32,
theme: &'static Theme,
}
/// The pre-order indices of every pressable element in `e`.
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);
}
*index += 1;
@@ -57,15 +80,15 @@ fn event_at<E: Clone>(e: &Element<E>, index: &mut usize, target: usize) -> Optio
let me = *index;
*index += 1;
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)),
k if me == target => k.event().cloned(),
_ => None,
}
}
impl<A: App> Ui<A> {
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> {
@@ -74,8 +97,21 @@ impl<A: App> Ui<A> {
out
}
/// Handles a key; whether anything changed.
pub fn key(&mut self, key: Key) -> bool {
/// Handles a key.
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 targets = self.pressables(&view);
if targets.is_empty() {
@@ -83,25 +119,55 @@ impl<A: App> Ui<A> {
}
let n = targets.len();
match key {
Key::Back => return false,
Key::Next => self.focus = (self.focus + 1) % n,
Key::Previous => self.focus = (self.focus + n - 1) % n,
Key::Press => {
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);
if self.app.screen() != screen {
(self.focus, self.scroll) = (0, 0.0);
} 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
}
/// Lays the view out over the whole of `pm` and draws it.
pub fn draw(&self, pm: &mut PixmapMut, fonts: &Fonts) {
/// Lays the view out over the whole of `pm` and draws it, scrolled so
/// the focused element is in sight.
pub fn draw(&mut self, pm: &mut PixmapMut, fonts: &Fonts) {
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 rects = layout::layout(&view, area, &Measurer { fonts, theme: self.theme }, self.theme);
let (w, h) = (pm.width() as f32, pm.height() as f32);
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();
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);
}
}
/// 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)
}
/// 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
/// `id` (stopping it if it runs with it).
pub fn revoke(&self, id: &str, capability: usize) -> Result<()> {
@@ -114,3 +123,37 @@ impl Apps {
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;
mod rt;
pub mod spawn;
pub mod session;
pub mod start;
pub mod terminal;
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
}
/// 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.
pub fn display() -> Option<CapSlot> {
StartArg::decode(arg()).display

View File

@@ -59,6 +59,9 @@ catalog! {
Sync = "sync",
Accessibility = "accessibility",
Print = "print",
// System-only (see `Capabilities::SYSTEM_ONLY`).
Permissions = "permissions",
Session = "session",
}
impl Capability {
@@ -73,6 +76,12 @@ pub struct Capabilities(u32);
impl Capabilities {
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.
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 SYSTEM: CapSlot = CapSlot::const_new(4);
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 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 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");
server_framework::serve(&mut srv, EP)
}

View File

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

View File

@@ -46,6 +46,9 @@ struct Running {
data: Option<CapSlot>,
/// Its window, closed when it dies.
window: Option<CapSlot>,
/// The management copy of this server's endpoint (`permissions`),
/// revoked when it dies.
manage: Option<CapSlot>,
}
pub struct PermissionServer {
@@ -54,6 +57,11 @@ pub struct PermissionServer {
/// Init's spawn endpoint.
init: 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.
deaths: CapSlot,
staged: Staged,
@@ -70,6 +78,7 @@ fn refused(r: Refusal) -> [usize; 4] {
Refusal::Declined => (reply::DECLINED, 0),
Refusal::Missing(c) => (reply::MISSING, c.bits() as usize),
Refusal::NotRequested => (reply::NOT_REQUESTED, 0),
Refusal::SystemOnly(c) => (reply::SYSTEM_ONLY, c.bits() as usize),
Refusal::Store => (reply::FAILED, 0),
};
[code, detail, 0, 0]
@@ -116,14 +125,21 @@ fn parts(page: &[u8]) -> impl Iterator<Item = &str> {
}
impl PermissionServer {
pub fn new(store: DiskStore, consent: TerminalConsent, init: CapSlot, display: Option<CapSlot>, deaths: CapSlot) -> Self {
Self { store, consent, init, display, deaths, staged: Staged::default(), running: [const { None }; MAX_RUNNING] }
pub fn new(store: DiskStore, consent: TerminalConsent, ports: Ports, deaths: CapSlot) -> Self {
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.
fn available(&self) -> Capabilities {
let base = Capabilities::NONE.with(Capability::Storage);
if self.display.is_some() { base.with(Capability::Display) } else { base }
let mut c = Capabilities::NONE.with(Capability::Storage).with(Capability::Permissions);
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] {
@@ -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),
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 arg = StartArg {
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)),
args: child.set_args(args),
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);
if !watched || !child.start_with(arg) {
@@ -203,10 +232,11 @@ impl PermissionServer {
let _ = libkos::fs::release(d);
}
close_window(window);
revoke(manage);
syscall::drop_cap(child.process);
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))
}
@@ -248,6 +278,20 @@ impl PermissionServer {
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`.
fn change(&mut self, page: CapSlot, index: usize, allow: bool) -> [usize; 4] {
let page = read_page(page);
@@ -282,6 +326,7 @@ impl PermissionServer {
let _ = libkos::fs::release(d);
}
close_window(r.window);
revoke(r.manage);
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>) {
if !allowed(badge, words[0]) {
if let Some(c) = cap {
syscall::drop_cap(c);
}
return ([MALFORMED, 0, 0, 0], None);
}
match (words[0], cap) {
(wire::PASS_OUTPUT | wire::PASS_FOLDER, cap) => (self.stage(badge, words[0], cap), None),
(wire::LAUNCH, Some(page)) => self.launch(badge, page),
(wire::INSTALL, source) => (self.install(source), 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::ALLOW, Some(page)) => (self.change(page, words[1], true), None),
(_, cap) => {
@@ -349,3 +401,26 @@ fn close_window(window: Option<CapSlot>) {
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;
#[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);
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.
pub const REVOKE: usize = 6;
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.
pub const PAGE_SIZE: usize = 4096;
@@ -46,4 +55,6 @@ pub mod reply {
pub const FAILED: usize = 9;
/// The offered folder has no `manifest.toml`.
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 PIXEL_BYTES: usize = 4;
/// 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 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)
}
pub use crate::name::{pack as pack_name, unpack as unpack_name};
/// 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] {

View File

@@ -15,6 +15,7 @@ pub mod input;
pub mod log;
pub mod macros;
pub mod mapping;
pub mod name;
pub mod permission;
#[cfg(kani)]
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
/// endpoint copy init gives a session's shell: the user signs out.
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.
pub const SESSION_BADGE: usize = 0x5E55;

View File

@@ -10,6 +10,8 @@ const STORAGE_SHIFT: u32 = 16;
const ARGS_BIT: usize = 1 << 24;
const DISPLAY_SHIFT: u32 = 32;
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,
/// the first its name. Below the stack, which ends at the image.
@@ -27,6 +29,10 @@ pub struct StartArg {
pub display: Option<CapSlot>,
/// The app's own data folder, read-write (its `storage` capability).
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> {
@@ -46,6 +52,8 @@ impl StartArg {
| if self.args { ARGS_BIT } else { 0 }
| put(self.display, DISPLAY_SHIFT)
| put(self.data, DATA_SHIFT)
| put(self.permissions, PERMISSIONS_SHIFT)
| put(self.session, SESSION_SHIFT)
}
pub fn decode(arg: usize) -> Self {
@@ -56,6 +64,8 @@ impl StartArg {
args: arg & ARGS_BIT != 0,
display: field(arg, DISPLAY_SHIFT),
data: field(arg, DATA_SHIFT),
permissions: field(arg, PERMISSIONS_SHIFT),
session: field(arg, SESSION_SHIFT),
}
}
}
@@ -73,6 +83,8 @@ mod tests {
args: true,
display: CapSlot::new(7),
data: CapSlot::new(62),
permissions: CapSlot::new(9),
session: CapSlot::new(63),
};
assert_eq!(StartArg::decode(a.encode()), a);
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 |
| `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
@@ -100,7 +107,9 @@ granted = ["display"]
Written by the permission manager when the user decides; apps on the
system image ship with their decisions already recorded under
`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.
## Permission manager
@@ -141,6 +150,14 @@ permission manager:
5. Replies with a handle (the process capability, call-only) the launcher
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
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
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
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
session's programs, except the gatekeeper and storage. A restarted server

View File

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