feat(desktop): home app (system-only launch cap, LAUNCH_BADGE: apps+launch only), pm starts it with session (relaunch <=5); display keys F1/Alt+Tab next, F2 home, F4 close app (ROLE from launcher, terminal exempt), close returns to previous window; closed window -> app exits; shell 'name &'; launch by id; pm stack 8, storage 16 clients

This commit is contained in:
2026-10-28 15:50:05 +01:00
parent c3eb70191b
commit 18e8b3ecb8
29 changed files with 520 additions and 68 deletions

View File

@@ -94,6 +94,16 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
&mut self.host
}
#[cfg(test)]
pub fn windows_front_for_test(&self) -> Option<usize> {
self.windows.front()
}
#[cfg(test)]
pub fn slot_for_test(&self, badge: usize) -> Option<usize> {
self.windows.slot(badge)
}
/// A window's size: the screen below the bar.
fn window_size(&self) -> (Dimension, Dimension) {
(self.fb.width(), Dimension::new(self.fb.height().raw().saturating_sub(BAR_HEIGHT)))
@@ -193,6 +203,28 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
raster::draw_text(&mut self.fb, ScreenCoord::new(at), ScreenCoord::new(0), bar_fg, bar_bg, user);
}
/// What a window key does.
fn window_key(&mut self, key: usize) {
let changed = match key {
w::SWITCH_KEY => self.windows.cycle().is_some(),
w::HOME_KEY => self.windows.with_role(w::role::HOME).is_some_and(|i| self.windows.raise(i)),
_ => match self.windows.front().filter(|i| self.windows.role(*i) == Some(w::role::APP)) {
Some(index) => {
// Woken, it finds its window gone, and is done.
if let Some(notify) = self.windows.notify(index) {
self.host.signal(notify);
}
self.close(index);
false
}
None => false,
},
};
if changed {
self.repaint();
}
}
/// Every window is told to ask for the theme again; the bar wears it.
fn theme_changed(&mut self) {
let mut told = [None; MAX_WINDOWS];
@@ -220,6 +252,7 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
None => ([MALFORMED, 0, 0, 0], None),
}
}
(w::ROLE, _) if matches!(words[1], w::role::APP | w::role::HOME) && self.windows.set_role(badge, words[1]) => ([w::OK, 0, 0, 0], None),
(w::ACCENT, _) if words[1] <= 0xff_ffff && self.windows.set_accent(badge, words[1] as u32) => ([w::OK, 0, 0, 0], None),
(w::PRESENT, _) => {
let (x, y, width, height) = w::unpack_rect([words[1], words[2]]);
@@ -248,9 +281,11 @@ impl<F: Framebuffer, H: Host> DisplayServer<F, H> {
}
}
fn is_switch_key(event: [usize; 4]) -> bool {
/// The display's own key in `event`, if it is one (pressed or let go).
fn display_key(event: [usize; 4]) -> Option<usize> {
use types::input::event::{FUNCTION_KEY, KEY_DOWN, KEY_UP};
matches!(event[0], KEY_DOWN | KEY_UP) && event[1] == FUNCTION_KEY && event[2] == w::SWITCH_KEY
let ours = matches!(event[0], KEY_DOWN | KEY_UP) && event[1] == FUNCTION_KEY;
(ours && matches!(event[2], w::SWITCH_KEY | w::HOME_KEY | w::CLOSE_KEY)).then_some(event[2])
}
impl<F: Framebuffer, H: Host> Server for DisplayServer<F, H> {
@@ -306,14 +341,14 @@ impl<F: Framebuffer, H: Host> Server for DisplayServer<F, H> {
}
}
/// The input server has events: the switch key is the display's, the
/// rest go to the window in front.
/// The input server has events: the window keys are the display's,
/// the rest go to the window in front.
fn signal(&mut self, _bits: usize) {
let mut told = None;
while let Some(event) = self.host.input() {
if is_switch_key(event) {
if event[0] == types::input::event::KEY_DOWN && self.windows.cycle().is_some() {
self.repaint();
if let Some(key) = display_key(event) {
if event[0] == types::input::event::KEY_DOWN {
self.window_key(key);
}
continue;
}

View File

@@ -259,3 +259,42 @@ fn only_the_launcher_sets_an_accent_and_it_is_in_the_theme() {
assert_eq!(accent(&mut s, other), theme::Theme::WHIMSY_DARK.accent);
assert_eq!(accent(&mut s, app), theme::with_accent(theme::Theme::WHIMSY_DARK, theme::Rgba::rgb(pink as u32)).accent);
}
#[test]
fn window_keys_raise_home_and_close_only_apps() {
let key = |s: &mut Srv, k: usize| {
s.host_mut_for_test().events = vec![[1, 10, k, 0], [2, 10, k, 0]];
s.signal(0);
};
let mut s = server();
let terminal = open(&mut s, 1, b"Terminal");
let home = open(&mut s, 2, b"Home");
let app = open(&mut s, 2, b"Tally");
assert_eq!(s.handle_call(home, [w::ROLE, w::role::HOME, 0, 0], None).0[0], w::OK);
assert_eq!(s.handle_call(app, [w::ROLE, w::role::APP, 0, 0], None).0[0], w::OK);
assert_eq!(s.handle_call(app, [w::ROLE, w::role::HOME, 0, 0], None).0[0], MALFORMED, "once");
s.handle_call(terminal, [w::BUFFER, 0, 0, 0], None);
assert_eq!(s.handle_call(terminal, [w::ROLE, w::role::APP, 0, 0], None).0[0], MALFORMED, "not once it runs");
let notify = CapSlot::new(9).unwrap();
s.handle_call(app, [w::SUBSCRIBE, 0, 0, 0], Some(notify));
key(&mut s, w::HOME_KEY);
assert_eq!(s.windows_front_for_test(), s.slot_for_test(home));
key(&mut s, w::CLOSE_KEY);
assert!(s.slot_for_test(home).is_some(), "Home is not closed");
// The app in front: closed, and told.
s.handle_call(app, [w::POLL, 0, 0, 0], None);
key(&mut s, w::SWITCH_KEY);
assert_eq!(s.windows_front_for_test(), s.slot_for_test(app));
key(&mut s, w::CLOSE_KEY);
assert_eq!(s.slot_for_test(app), None);
assert!(s.host().signalled.contains(&notify));
assert_eq!(s.handle_call(app, [w::POLL, 0, 0, 0], None).0[0], MALFORMED, "its window is gone");
// The terminal in front: kept.
key(&mut s, w::SWITCH_KEY);
while s.windows_front_for_test() != s.slot_for_test(terminal) {
key(&mut s, w::SWITCH_KEY);
}
key(&mut s, w::CLOSE_KEY);
assert!(s.slot_for_test(terminal).is_some());
}

View File

@@ -67,3 +67,19 @@ fn a_launchers_windows_are_found_by_its_badge() {
let (b, _) = w.open(5, b"two").unwrap();
assert_eq!(w.opened_by(5).collect::<Vec<_>>(), [a, b]);
}
#[test]
fn closing_the_front_window_brings_back_the_one_before_it() {
let mut w: Windows<u8> = Windows::new();
let (t, _) = w.open(1, b"Terminal").unwrap();
let (home, _) = w.open(2, b"Home").unwrap();
let (app, _) = w.open(2, b"Tally").unwrap();
assert!(w.close(app));
assert_eq!(w.front(), Some(home), "not the next slot round");
w.raise(t);
let (app2, _) = w.open(2, b"Snake").unwrap();
w.raise(home);
w.raise(app2);
w.close(app2);
assert_eq!(w.front(), Some(home));
}

View File

@@ -1,7 +1,8 @@
//! Which windows there are and which is in front (see `types::display::
//! window`). Every window fills the screen below the OS bar; one is in
//! front at a time, and only it is shown and gets input. A new window comes
//! to the front; a key cycles through them. Pure: the server keeps the
//! to the front; a key cycles through them. Closing the one in front brings
//! back the one in front before it. Pure: the server keeps the
//! pixels.
use types::display::window::{BADGE, NAME_BYTES};
@@ -54,6 +55,10 @@ struct Window<N> {
accent: Option<u32>,
/// Its buffer was asked for: the app runs, so the accent is settled.
started: bool,
/// What it is (`types::display::window::role`), if it was said.
role: Option<usize>,
/// When it last came to the front (`Windows::raises`).
raised: u64,
}
pub struct Windows<N: Copy> {
@@ -61,11 +66,13 @@ pub struct Windows<N: Copy> {
front: Option<usize>,
/// Makes each window's badge new, so a closed window's never returns.
opened: usize,
/// Times a window came to the front: what was in front most lately.
raises: u64,
}
impl<N: Copy> Windows<N> {
pub const fn new() -> Self {
Self { slots: [const { None }; MAX_WINDOWS], front: None, opened: 0 }
Self { slots: [const { None }; MAX_WINDOWS], front: None, opened: 0, raises: 0 }
}
/// A window for `name`, opened through `opener`'s capability, in
@@ -77,8 +84,8 @@ impl<N: Copy> Windows<N> {
let mut n = [0u8; NAME_BYTES];
let name_len = name.len().min(NAME_BYTES);
n[..name_len].copy_from_slice(&name[..name_len]);
self.slots[index] = Some(Window { badge, opener, name: n, name_len, notify: None, queue: Queue::new(), accent: None, started: false });
self.front = Some(index);
self.slots[index] = Some(Window { badge, opener, name: n, name_len, notify: None, queue: Queue::new(), accent: None, started: false, role: None, raised: 0 });
self.bring_forward(index);
Some((index, badge))
}
@@ -95,8 +102,7 @@ impl<N: Copy> Windows<N> {
if self.front != Some(index) {
return false;
}
self.front = None;
self.front = self.after(index);
self.front = self.slots.iter().enumerate().filter_map(|(i, w)| w.as_ref().map(|w| (w.raised, i))).max().map(|(_, i)| i);
true
}
@@ -114,11 +120,19 @@ impl<N: Copy> Windows<N> {
pub fn cycle(&mut self) -> Option<usize> {
let next = self.after(self.front?)?;
(Some(next) != self.front).then(|| {
self.front = Some(next);
self.bring_forward(next);
next
})
}
fn bring_forward(&mut self, index: usize) {
self.raises += 1;
if let Some(w) = self.slots[index].as_mut() {
w.raised = self.raises;
}
self.front = Some(index);
}
pub fn front(&self) -> Option<usize> {
self.front
}
@@ -140,6 +154,40 @@ impl<N: Copy> Windows<N> {
}
}
/// Says what the window `badge` is: once, before it starts.
pub fn set_role(&mut self, badge: usize, role: usize) -> bool {
let Some(w) = self.slot(badge).and_then(|i| self.slots[i].as_mut()) else { return false };
let ok = !w.started && w.role.is_none();
if ok {
w.role = Some(role);
}
ok
}
pub fn role(&self, index: usize) -> Option<usize> {
self.slots.get(index)?.as_ref()?.role
}
/// The first open window that is `role`.
pub fn with_role(&self, role: usize) -> Option<usize> {
self.slots.iter().position(|w| w.as_ref().is_some_and(|w| w.role == Some(role)))
}
/// Who is told of the window in `index`'s input.
pub fn notify(&self, index: usize) -> Option<N> {
self.slots.get(index)?.as_ref()?.notify
}
/// Brings the window in `index` to the front; whether it changed.
pub fn raise(&mut self, index: usize) -> bool {
let open = self.slots.get(index).is_some_and(Option::is_some);
let changed = open && self.front != Some(index);
if changed {
self.bring_forward(index);
}
changed
}
pub fn accent(&self, badge: usize) -> Option<u32> {
self.slots[self.slot(badge)?].as_ref()?.accent
}

View File

@@ -49,6 +49,7 @@ pub fn run() -> ! {
let consent = consent::TerminalConsent { terminal: TERMINAL };
let ports = server::Ports { init: INIT, display: Some(DISPLAY), session: Some(SESSION), endpoint: EP };
let mut srv = server::PermissionServer::new(store, consent, ports, deaths);
srv.start_home();
libkos::println!("permission: serving");
server_framework::serve(&mut srv, EP)
}

View File

@@ -15,6 +15,7 @@ use types::bounded::CapSlot;
use types::budget::Limits;
use types::mapping::PagePerms;
use types::permission::{Permission, PermissionSet};
use types::display::window::role as display_role;
use types::start_arg::StartArg;
use crate::consent::{CapabilityNames, TerminalConsent, version};
@@ -27,6 +28,12 @@ const PROGRAM: Limits = Limits { pages: 0, endpoints: 1, notifications: 1, regio
/// Apps running at once; a death is a bit each on the notification.
const MAX_RUNNING: usize = 8;
const MAX_ARGS: usize = 16;
/// The session's desktop, started with it and again if it dies.
const HOME: &str = "kos.home";
/// Home's starts in a session at most: one that keeps dying is left down.
const MAX_HOMES: u8 = 5;
/// A launch that no launcher asked for (Home's): nothing staged is its.
const NO_LAUNCHER: usize = usize::MAX;
/// Where a client's request page is mapped during its call.
const PAGE_VA: VirtualAddress = VirtualAddress::from_raw(0x01A0_0000);
@@ -66,6 +73,8 @@ pub struct PermissionServer {
deaths: CapSlot,
staged: Staged,
running: [Option<Running>; MAX_RUNNING],
/// Home's starts this session.
homes: u8,
}
fn refused(r: Refusal) -> [usize; 4] {
@@ -127,12 +136,12 @@ fn parts(page: &[u8]) -> impl Iterator<Item = &str> {
impl PermissionServer {
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] }
Self { store, consent, init, display, session, endpoint, deaths, staged: Staged::default(), running: [const { None }; MAX_RUNNING], homes: 0 }
}
/// What this device has a server for.
fn available(&self) -> Capabilities {
let mut c = Capabilities::NONE.with(Capability::Storage).with(Capability::Permissions);
let mut c = Capabilities::NONE.with(Capability::Storage).with(Capability::Permissions).with(Capability::Launch);
if self.display.is_some() {
c = c.with(Capability::Display);
}
@@ -177,7 +186,27 @@ impl PermissionServer {
*slot = p;
n += 1;
}
let args = &args[..n];
self.run(badge, &args[..n])
}
/// Starts Home, the session's desktop, unless it runs already or has
/// died too often.
pub fn start_home(&mut self) {
let running = self.running.iter().flatten().any(|r| r.id.as_str() == HOME);
if running || self.homes >= MAX_HOMES {
return;
}
self.homes += 1;
match self.run(NO_LAUNCHER, &[HOME]) {
Ok((process, _)) => {
syscall::drop_cap(process);
}
Err(words) => libkos::println!("permission: home did not start (refused: {})", words[0]),
}
}
/// Runs `args[0]` with `args` for the launcher behind `badge`.
fn run(&mut self, badge: usize, args: &[&str]) -> Result<(CapSlot, Capabilities), [usize; 4]> {
let command = *args.first().ok_or(code(reply::NOT_INSTALLED))?;
let available = self.available();
let plan = policy::plan_launch(&mut self.store, command, available).map_err(refused)?;
@@ -201,18 +230,27 @@ 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,
};
// Its brand colour too, which the display adapts to the theme.
if let (Some(w), Some(accent)) = (window, plan.app.accent) {
libkos::display::set_accent(w, accent.0);
}
// 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()
// Its brand colour too, which the display adapts to the theme, and
// what it is: Home, or an app the user may close.
if let Some(w) = window {
if let Some(accent) = plan.app.accent {
libkos::display::set_accent(w, accent.0);
}
let role = if plan.app.id.as_str() == HOME { display_role::HOME } else { display_role::APP };
libkos::display::set_role(w, role);
}
// Managing others, or launching them, through a copy of this
// endpoint that can do no more (see `MANAGE_BADGE`, `LAUNCH_BADGE`).
let bits = [(Capability::Permissions, wire::MANAGE_BADGE), (Capability::Launch, wire::LAUNCH_BADGE)]
.iter()
.filter(|(c, _)| plan.granted.contains(*c))
.fold(0, |b, (_, bit)| b | bit);
let manage = match bits {
0 => None,
bits => {
let perms = PermissionSet::empty().with(Permission::Execute).with(Permission::Delegate);
syscall::mint(self.endpoint, types::badge::Badge::new(bits | 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);
@@ -319,8 +357,9 @@ impl PermissionServer {
code(reply::OK)
}
/// Apps behind `bits` died: their data grants go back.
/// Apps behind `bits` died: their data grants go back. Home comes back.
fn reap(&mut self, bits: usize) {
let home_died = self.running.iter().enumerate().any(|(i, r)| bits & (1 << i) != 0 && r.as_ref().is_some_and(|r| r.id.as_str() == HOME));
for (i, slot) in self.running.iter_mut().enumerate() {
if bits & (1 << i) == 0 {
continue;
@@ -334,6 +373,9 @@ impl PermissionServer {
syscall::drop_cap(r.process);
}
}
if home_died {
self.start_home();
}
}
}
@@ -407,9 +449,14 @@ fn close_window(window: Option<CapSlot>) {
}
/// Whether `op` may come through the copy of this endpoint badged `badge`:
/// a management copy (`permissions`) decides, never installs or launches.
/// a management copy (`permissions`) decides, never installs or launches;
/// a launcher's (`launch`) lists and launches, passing nothing along.
pub fn allowed(badge: usize, op: usize) -> bool {
badge & wire::MANAGE_BADGE == 0 || matches!(op, wire::LIST | wire::APPS | wire::REVOKE | wire::ALLOW)
let (manage, launch) = (badge & wire::MANAGE_BADGE != 0, badge & wire::LAUNCH_BADGE != 0);
if !manage && !launch {
return true;
}
(manage && matches!(op, wire::LIST | wire::APPS | wire::REVOKE | wire::ALLOW)) || (launch && matches!(op, wire::APPS | wire::LAUNCH))
}
fn revoke(cap: Option<CapSlot>) {

View File

@@ -202,7 +202,8 @@ impl Store for DiskStore {
n += 1;
}
});
ids.into_iter().flatten().find(|id| self.manifest(id).is_some_and(|m| m.command.as_str() == command))
// By its id, or the command the shell runs it as.
ids.into_iter().flatten().find(|id| id.as_str() == command || self.manifest(id).is_some_and(|m| m.command.as_str() == command))
}
fn decision(&mut self, id: &AppId) -> Option<Decision> {

View File

@@ -11,5 +11,18 @@ fn a_management_copy_only_decides() {
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");
assert!(allowed(5, LAUNCH) && allowed(5, INSTALL), "the shell's copy does everything");
}
#[test]
fn a_home_copy_only_lists_and_launches() {
use crate::server::allowed;
use types::apps::*;
let home = LAUNCH_BADGE | 2;
assert!(allowed(home, APPS) && allowed(home, LAUNCH));
for op in [PASS_OUTPUT, PASS_FOLDER, INSTALL, LIST, REVOKE, ALLOW] {
assert!(!allowed(home, op), "op {op}");
}
let both = LAUNCH_BADGE | MANAGE_BADGE | 2;
assert!(allowed(both, LAUNCH) && allowed(both, REVOKE) && !allowed(both, INSTALL));
}

View File

@@ -9,7 +9,8 @@ use crate::scope::Scope;
mod session;
const MAX_CLIENTS: usize = 8;
/// Connections at once: every program reading files, apps among them.
const MAX_CLIENTS: usize = 16;
/// Handles at or past this are never valid (the file systems hand out
/// fewer), so ownership fits a small table.
const MAX_HANDLES: usize = 16;