mirror of
https://github.com/HeyPuter/gex.git
synced 2026-11-13 20:12:43 -10:00
integ: merge 2dbb6079 merge/demo-desktop (C: desktop demo + procfs/pty/cursor/dpr/hostx fixes)
This commit is contained in:
commit
3a61c3d8f9
39 files changed
+3564
-170
No files matched your search
@@ -4,6 +4,7 @@ tests/corpus/bin/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/dist-sp/
|
||||
web/dist-demo/
|
||||
web/public/gex/
|
||||
web/public/rootfs/
|
||||
web/public/closure/
|
||||
@@ -18,3 +19,5 @@ logs/
|
||||
core.[0-9]*
|
||||
hs_err_pid*.log
|
||||
TestResults.qpa
|
||||
web/public/pkgs/
|
||||
web/public/gex-apt/
|
||||
@@ -62,6 +62,81 @@ pub struct Remote {
|
||||
pub wait_ready: extern "C" fn(ppid: i32, want: i32) -> i32,
|
||||
pub request_halt: extern "C" fn(code: i32),
|
||||
pub halt_code: extern "C" fn() -> i32,
|
||||
/// What `/proc/<pid>` says about a process: see [`ProcTable::set_info`].
|
||||
pub set_info: extern "C" fn(pid: i32, at: crate::PAddr, len: u32),
|
||||
/// Every process, encoded by [`encode_entries`], into `cap` bytes at `at`;
|
||||
/// returns the full length (larger than `cap`: ask again with more room).
|
||||
pub snapshot: extern "C" fn(at: crate::PAddr, cap: u32) -> i32,
|
||||
}
|
||||
|
||||
/// One process as another process's `/proc` sees it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcEntry {
|
||||
pub pid: i32,
|
||||
pub ppid: i32,
|
||||
pub pgid: i32,
|
||||
pub sid: i32,
|
||||
/// Exited and not yet collected by its parent.
|
||||
pub zombie: bool,
|
||||
/// What the process published: its name, a newline, then its command
|
||||
/// line (NUL-separated arguments). Empty until it has said.
|
||||
pub info: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ProcEntry {
|
||||
/// The name `/proc/<pid>/comm` and `stat` give (at most 15 bytes).
|
||||
pub fn comm(&self) -> String {
|
||||
let end = self.info.iter().position(|&b| b == b'\n').unwrap_or(self.info.len());
|
||||
let name = String::from_utf8_lossy(&self.info[..end]).into_owned();
|
||||
if name.is_empty() {
|
||||
return "gex".into();
|
||||
}
|
||||
name.chars().take(15).collect()
|
||||
}
|
||||
/// `/proc/<pid>/cmdline`.
|
||||
pub fn cmdline(&self) -> &[u8] {
|
||||
match self.info.iter().position(|&b| b == b'\n') {
|
||||
Some(i) => &self.info[i + 1..],
|
||||
None => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire form of a process list: per entry five `i32`s (pid, ppid, pgid,
|
||||
/// sid, zombie), the info's length, the info, padded to four bytes.
|
||||
pub fn encode_entries(entries: &[ProcEntry]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for e in entries {
|
||||
for v in [e.pid, e.ppid, e.pgid, e.sid, i32::from(e.zombie), e.info.len() as i32] {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&e.info);
|
||||
while out.len() % 4 != 0 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode_entries(mut b: &[u8]) -> Vec<ProcEntry> {
|
||||
let mut out = Vec::new();
|
||||
let word = |b: &[u8], i: usize| i32::from_le_bytes([b[i * 4], b[i * 4 + 1], b[i * 4 + 2], b[i * 4 + 3]]);
|
||||
while b.len() >= 24 {
|
||||
let n = word(b, 5).max(0) as usize;
|
||||
if b.len() < 24 + n {
|
||||
break;
|
||||
}
|
||||
out.push(ProcEntry {
|
||||
pid: word(b, 0),
|
||||
ppid: word(b, 1),
|
||||
pgid: word(b, 2),
|
||||
sid: word(b, 3),
|
||||
zombie: word(b, 4) != 0,
|
||||
info: b[24..24 + n].to_vec(),
|
||||
});
|
||||
b = &b[(24 + n + 3) & !3..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One process, as the table sees it.
|
||||
@@ -87,6 +162,8 @@ struct Proc {
|
||||
/// raised), and the pid that sent each. Under the table's lock.
|
||||
killed: u64,
|
||||
senders: [i32; 64],
|
||||
/// See [`ProcTable::set_info`].
|
||||
info: Vec<u8>,
|
||||
}
|
||||
|
||||
struct Table {
|
||||
@@ -143,6 +220,7 @@ impl ProcTable {
|
||||
pending: Arc::new(AtomicU64::new(0)),
|
||||
killed: 0,
|
||||
senders: [0; 64],
|
||||
info: Vec::new(),
|
||||
}],
|
||||
}),
|
||||
}
|
||||
@@ -175,10 +253,56 @@ impl ProcTable {
|
||||
pending: Arc::new(AtomicU64::new(0)),
|
||||
killed: 0,
|
||||
senders: [0; 64],
|
||||
info: Vec::new(),
|
||||
});
|
||||
pid
|
||||
}
|
||||
|
||||
/// What `/proc/<pid>` shows for `pid` in every other process: its name
|
||||
/// and command line (see [`ProcEntry`]), said by the process itself when
|
||||
/// it starts and again when it execs.
|
||||
pub fn set_info(&self, pid: i32, info: &[u8]) {
|
||||
let info = &info[..info.len().min(4096)];
|
||||
if let Some(r) = self.remote {
|
||||
return (r.set_info)(pid, info.as_ptr() as usize as crate::PAddr, info.len() as u32);
|
||||
}
|
||||
if let Some(p) = self.lock().procs.iter_mut().find(|p| p.pid == pid) {
|
||||
p.info = info.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process in the table, for `/proc`'s listing.
|
||||
pub fn entries(&self) -> Vec<ProcEntry> {
|
||||
if let Some(r) = self.remote {
|
||||
let mut cap = 16 * 1024usize;
|
||||
for _ in 0..4 {
|
||||
let mut buf = vec![0u8; cap];
|
||||
let n = (r.snapshot)(buf.as_mut_ptr() as usize as crate::PAddr, cap as u32);
|
||||
if n < 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
if n as usize <= cap {
|
||||
buf.truncate(n as usize);
|
||||
return decode_entries(&buf);
|
||||
}
|
||||
cap = n as usize;
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
self.lock()
|
||||
.procs
|
||||
.iter()
|
||||
.map(|p| ProcEntry {
|
||||
pid: p.pid,
|
||||
ppid: p.ppid,
|
||||
pgid: p.pgid,
|
||||
sid: p.sid,
|
||||
zombie: p.child.status.lock().map(|s| s.is_some()).unwrap_or(false),
|
||||
info: p.info.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The handle a host needs to publish `pid`'s exit status.
|
||||
pub fn handle(&self, pid: i32) -> Option<ChildHandle> {
|
||||
self.lock()
|
||||
@@ -534,6 +658,15 @@ impl ProcTable {
|
||||
self.lock().procs.iter().any(|p| p.pid == pid)
|
||||
}
|
||||
|
||||
/// Whether `pid` is in the table and has not exited: [`ProcTable::exists`]
|
||||
/// without the zombies, which are in the table until reaped.
|
||||
pub fn alive(&self, pid: i32) -> bool {
|
||||
self.lock()
|
||||
.procs
|
||||
.iter()
|
||||
.any(|p| p.pid == pid && p.child.status.lock().map(|s| s.is_none()).unwrap_or(true))
|
||||
}
|
||||
|
||||
/// Whether a `wait4` by `ppid` for `want` would answer now: a child with a
|
||||
/// status ready, or no matching child at all (`ECHILD`). Separate from
|
||||
/// `collect`, which *takes* the child -- asking must not reap.
|
||||
@@ -597,6 +730,21 @@ fn errno_or(v: i32) -> Result<i32, Errno> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_table_lists_processes_with_what_they_said() {
|
||||
let t = ProcTable::new();
|
||||
let a = t.spawn(INIT_PID);
|
||||
t.set_info(a, b"sh\n/bin/sh\0-c\0x\0");
|
||||
let e = t.entries();
|
||||
assert_eq!(e.len(), 2);
|
||||
let mine = e.iter().find(|e| e.pid == a).unwrap();
|
||||
assert_eq!(mine.comm(), "sh");
|
||||
assert_eq!(mine.cmdline(), b"/bin/sh\0-c\0x\0");
|
||||
assert_eq!(decode_entries(&encode_entries(&e)), e);
|
||||
t.exit(a, 0);
|
||||
assert!(t.entries().iter().find(|e| e.pid == a).unwrap().zombie);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_inherits_its_parents_group_and_session() {
|
||||
let t = ProcTable::new();
|
||||
|
||||
@@ -123,8 +123,11 @@ impl Pty {
|
||||
pub fn master_read(&self, buf: &mut [u8]) -> SysResult {
|
||||
let mut s = self.lock();
|
||||
if s.to_master.is_empty() {
|
||||
// Every slave closed: a hangup, which Linux reports to the master
|
||||
// as EIO rather than end-of-file (`os.read` on a pty.fork child
|
||||
// that has exited, xterm noticing its shell is gone).
|
||||
return if s.slave_ever && s.slave_open == 0 {
|
||||
Ok(0)
|
||||
Err(Errno::EIO)
|
||||
} else {
|
||||
Err(Errno::EAGAIN)
|
||||
};
|
||||
@@ -211,6 +214,46 @@ fn drain(q: &mut VecDeque<u8>, buf: &mut [u8]) -> u64 {
|
||||
n as u64
|
||||
}
|
||||
|
||||
/// `st_rdev` of `/dev/pts/N` (major 136), in Linux's encoding.
|
||||
pub const fn pts_rdev(n: u32) -> u64 {
|
||||
let (major, minor) = (136u64, n as u64);
|
||||
(minor & 0xff) | (major << 8) | ((minor & !0xff) << 12)
|
||||
}
|
||||
|
||||
/// `st_rdev` of `/dev/ptmx` (5:2).
|
||||
pub const PTMX_RDEV: u64 = (5 << 8) | 2;
|
||||
|
||||
/// `st_ino` of `/dev/pts/N`: the same from `stat` on the name and `fstat` on
|
||||
/// a descriptor, which is how `ttyname` recognises the one as the other.
|
||||
pub const fn pts_ino(n: u32) -> u64 {
|
||||
0x7074_0000 + n as u64
|
||||
}
|
||||
|
||||
/// `st_ino` of `/dev/ptmx`.
|
||||
pub const PTMX_INO: u64 = 0x7074_ffff;
|
||||
|
||||
/// What `/proc/<pid>/fd/N` says for a pty end gex names `pty:[id:m|s]`
|
||||
/// internally: `/dev/ptmx` for a master, `/dev/pts/N` for a slave (the id is
|
||||
/// the pts number). Anything else as it is.
|
||||
pub fn link_name(path: &str) -> String {
|
||||
let parsed = path
|
||||
.strip_prefix("pty:[")
|
||||
.and_then(|v| v.strip_suffix(']'))
|
||||
.and_then(|v| v.split_once(':'))
|
||||
.and_then(|(id, side)| Some((id.parse::<u32>().ok()?, side)));
|
||||
match parsed {
|
||||
Some((_, "m")) => "/dev/ptmx".to_string(),
|
||||
Some((n, "s")) => format!("/dev/pts/{n}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `/proc/<pid>/stat`'s `tty_nr` for a controlling terminal that is
|
||||
/// `/dev/pts/N`: the device number, as an int.
|
||||
pub const fn tty_nr(n: u32) -> i32 {
|
||||
pts_rdev(n) as i32
|
||||
}
|
||||
|
||||
/// A pty pair that lives in another wasm module, reached by number and a table
|
||||
/// of `extern "C"` calls -- the [`crate::pipe::Remote`] shape, with the terminal
|
||||
/// added. `id` names the pair; `master` picks the end. Data calls copy through
|
||||
@@ -298,15 +341,19 @@ impl crate::vfs::FileHandle for RemotePty {
|
||||
}
|
||||
}
|
||||
|
||||
/// The node `/dev/pts/N` (or `/dev/ptmx`) as `stat` on its name gives
|
||||
/// it: the same inode and device number, or `ttyname` finds no name.
|
||||
fn metadata(&self) -> Result<crate::vfs::Metadata, Errno> {
|
||||
let n = (self.remote.ptn)(self.id).max(0) as u32;
|
||||
let (ino, rdev, mode) = if self.master { (PTMX_INO, PTMX_RDEV, 0o666) } else { (pts_ino(n), pts_rdev(n), 0o620) };
|
||||
Ok(crate::vfs::Metadata {
|
||||
file_type: crate::vfs::FileType::CharDevice,
|
||||
size: 0,
|
||||
mode: 0o620,
|
||||
ino: 0,
|
||||
mode,
|
||||
ino,
|
||||
mtime: 0,
|
||||
nlink: 1,
|
||||
rdev: 0,
|
||||
rdev,
|
||||
times: Default::default(),
|
||||
})
|
||||
}
|
||||
@@ -353,6 +400,13 @@ impl crate::vfs::FileHandle for RemotePty {
|
||||
}
|
||||
|
||||
fn ioctl(&self, m: &mut crate::GuestMem<'_>, request: u64, arg: u64) -> Option<SysResult> {
|
||||
// The descriptor's own ioctls are the kernel's to answer, as they are
|
||||
// for any file. Answering them here with ENOTTY failed libuv's
|
||||
// `uv__nonblock` (FIONBIO) on a pty: nvim's input stream never opened,
|
||||
// so it drew in an xterm and took no keys.
|
||||
if descriptor_level(request) {
|
||||
return None;
|
||||
}
|
||||
Some(self.do_ioctl(m, request, arg))
|
||||
}
|
||||
|
||||
@@ -366,6 +420,50 @@ impl crate::vfs::FileHandle for RemotePty {
|
||||
}
|
||||
}
|
||||
|
||||
/// FIONBIO, FIONCLEX, FIOCLEX, FIOASYNC: properties of the descriptor, not
|
||||
/// the device.
|
||||
fn descriptor_level(request: u64) -> bool {
|
||||
matches!(request, 0x5421 | 0x5450 | 0x5451 | 0x5452)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The master sees the slave side going away as EIO, as on Linux: once
|
||||
/// its output is read, not before, and not before a slave was ever open.
|
||||
#[test]
|
||||
fn the_master_reads_eio_once_every_slave_has_closed() {
|
||||
let p = super::Pty::new(0);
|
||||
let mut buf = [0u8; 8];
|
||||
assert_eq!(p.master_read(&mut buf), Err(crate::errno::Errno::EAGAIN), "no slave yet: wait");
|
||||
p.open_slave();
|
||||
p.slave_write(b"bye");
|
||||
p.close_slave();
|
||||
assert_eq!(p.master_read(&mut buf), Ok(3), "what it wrote first");
|
||||
assert_eq!(p.master_read(&mut buf), Err(crate::errno::Errno::EIO));
|
||||
assert!(p.master_readable(), "a hangup is readable, so poll wakes the reader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pty_end_has_the_name_a_program_can_stat() {
|
||||
assert_eq!(super::link_name("pty:[4:s]"), "/dev/pts/4");
|
||||
assert_eq!(super::link_name("pty:[4:m]"), "/dev/ptmx");
|
||||
assert_eq!(super::link_name("pipe:[2]"), "pipe:[2]");
|
||||
assert_eq!(super::pts_rdev(4), 0x8804);
|
||||
assert_eq!(super::pts_rdev(300), (300 & 0xff) | (136 << 8) | ((300 & !0xff) << 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_descriptor_ioctls_are_left_to_the_kernel() {
|
||||
for req in [0x5421, 0x5450, 0x5451, 0x5452] {
|
||||
assert!(super::descriptor_level(req), "{req:#x}");
|
||||
}
|
||||
// TCGETS, TIOCGWINSZ, TIOCGPTN are the terminal's.
|
||||
for req in [0x5401, 0x5413, 0x8004_5430] {
|
||||
assert!(!super::descriptor_level(req), "{req:#x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ioctl numbers, the terminal subset a pty answers. The `_IOC`-encoded ones
|
||||
// (pty and termios2) carry direction and struct size in the number.
|
||||
mod ioc {
|
||||
|
||||
@@ -6264,6 +6264,22 @@ impl Kernel {
|
||||
Ok(fd)
|
||||
}
|
||||
|
||||
/// The epoll set an open file is, if it is one.
|
||||
pub fn epoll_of_file(&self, file: &OpenFile) -> Option<Arc<unix::EpollSet>> {
|
||||
let key = Arc::as_ptr(&file.handle) as *const () as usize;
|
||||
self.epolls.get(&key).cloned()
|
||||
}
|
||||
|
||||
/// An open epoll set holding `watches`, registered here: what a fork's
|
||||
/// child rebuilds its parent's set as.
|
||||
pub fn adopt_epoll(&mut self, watches: Vec<unix::Watch>, flags: u64) -> Arc<OpenFile> {
|
||||
let set = Arc::new(unix::EpollSet::with_watches(watches));
|
||||
let handle: Arc<dyn crate::vfs::FileHandle> = set.clone();
|
||||
let key = Arc::as_ptr(&handle) as *const () as usize;
|
||||
self.epolls.insert(key, set);
|
||||
OpenFile::new(handle, "anon_inode:[eventpoll]".into(), flags, true, true)
|
||||
}
|
||||
|
||||
/// The epoll set behind a descriptor.
|
||||
///
|
||||
/// Kept in a side table for the same reason sockets are: an
|
||||
@@ -7477,6 +7493,20 @@ pub struct ProcState {
|
||||
pub maps_generation: u64,
|
||||
/// Which version of the descriptor table `fds` was listed from.
|
||||
pub fds_generation: u64,
|
||||
/// The process table, for `/proc`'s view of the other processes.
|
||||
pub procs: Option<Arc<ProcTable>>,
|
||||
/// The image whose name and command line the table was last told.
|
||||
pub info_exe: Option<String>,
|
||||
/// The system's pseudoterminals, for `stat`'s `tty_nr`.
|
||||
pub ptys: Option<crate::pty::Remote>,
|
||||
}
|
||||
|
||||
/// The session's controlling terminal: its pts number (the pair's id) and
|
||||
/// foreground group.
|
||||
fn tty_of(ptys: Option<crate::pty::Remote>, sid: i32) -> Option<(u32, i32)> {
|
||||
let r = ptys?;
|
||||
let id = (r.for_sid)(sid);
|
||||
(id >= 0).then(|| (id as u32, (r.fg)(id)))
|
||||
}
|
||||
|
||||
/// A `/proc` source backed by a snapshot the kernel refreshes.
|
||||
@@ -7506,18 +7536,17 @@ impl ProcSource for KernelProcSource {
|
||||
}
|
||||
fn stat(&self) -> String {
|
||||
let s = self.0.lock().unwrap();
|
||||
// pid, comm, state, ppid, pgrp, session, then the rest, which nothing
|
||||
// seen so far reads. `ps` and every "who is my parent" one-liner cut
|
||||
// fields out of this by number, so the first six have to be right
|
||||
// even when the remainder is zeroes.
|
||||
format!(
|
||||
"{} ({}) R {} {} {} 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0 0 0 0\n",
|
||||
s.pid,
|
||||
s.exe.rsplit('/').next().unwrap_or("gex"),
|
||||
s.ppid,
|
||||
s.pgid,
|
||||
s.sid
|
||||
)
|
||||
let comm: String = s.exe.rsplit('/').next().unwrap_or("gex").chars().take(15).collect();
|
||||
let tty = tty_of(s.ptys, s.sid);
|
||||
crate::vfs::procfs::stat_line(s.pid, &comm, 'R', s.ppid, s.pgid, s.sid, tty)
|
||||
}
|
||||
fn others(&self) -> Vec<crate::proc::ProcEntry> {
|
||||
let procs = self.0.lock().unwrap().procs.clone();
|
||||
procs.map(|p| p.entries()).unwrap_or_default()
|
||||
}
|
||||
fn tty_of(&self, sid: i32) -> Option<(u32, i32)> {
|
||||
let ptys = self.0.lock().unwrap().ptys;
|
||||
tty_of(ptys, sid)
|
||||
}
|
||||
fn status(&self) -> String {
|
||||
let s = self.0.lock().unwrap();
|
||||
@@ -7539,6 +7568,10 @@ impl Kernel {
|
||||
s.ppid = self.procs.ppid(self.pid);
|
||||
s.pgid = self.procs.pgid(self.pid, 0).unwrap_or(self.pid);
|
||||
s.sid = self.procs.sid(self.pid, 0).unwrap_or(self.pid);
|
||||
s.ptys = self.ptys;
|
||||
if s.procs.as_ref().is_none_or(|p| !Arc::ptr_eq(p, &self.procs)) {
|
||||
s.procs = Some(Arc::clone(&self.procs));
|
||||
}
|
||||
// Only when the map has actually changed. Formatting the VMA tree is
|
||||
// the single most expensive thing this function does -- half of all
|
||||
// execution in a browser run of an X server, which polls in a tight
|
||||
@@ -7554,13 +7587,18 @@ impl Kernel {
|
||||
// are already there.
|
||||
if s.exe != self.exe_path {
|
||||
s.exe = self.exe_path.clone();
|
||||
// An exec: a new name and command line, for this process's /proc
|
||||
// and for every other's.
|
||||
s.cmdline.clear();
|
||||
}
|
||||
if s.cwd != self.vfs.cwd {
|
||||
s.cwd.clone_from(&self.vfs.cwd);
|
||||
}
|
||||
let fds_generation = self.fds.generation();
|
||||
if s.fds_generation != fds_generation || s.fds.is_empty() {
|
||||
s.fds = self.fds.list();
|
||||
// A pty end under the name a program can open and `stat`, which is
|
||||
// how `ttyname` and `ps` find it.
|
||||
s.fds = self.fds.list().into_iter().map(|(fd, p)| (fd, crate::pty::link_name(&p))).collect();
|
||||
s.fds_generation = fds_generation;
|
||||
}
|
||||
// argv and envp never change, so they are built once.
|
||||
@@ -7576,6 +7614,17 @@ impl Kernel {
|
||||
.flat_map(|a| a.bytes().chain(std::iter::once(0)))
|
||||
.collect();
|
||||
}
|
||||
// Said to the process table once per image, for the other processes'
|
||||
// `/proc/<pid>` (`ps`, `htop`): the name, then the command line.
|
||||
if s.info_exe.as_deref() != Some(s.exe.as_str()) {
|
||||
let name = s.exe.rsplit('/').next().unwrap_or("gex");
|
||||
let mut info = Vec::with_capacity(name.len() + 1 + s.cmdline.len());
|
||||
info.extend_from_slice(name.as_bytes());
|
||||
info.push(b'\n');
|
||||
info.extend_from_slice(&s.cmdline);
|
||||
self.procs.set_info(self.pid, &info);
|
||||
s.info_exe = Some(s.exe.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1035,6 +1035,38 @@ impl EpollSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// A set already holding `watches`: a fork's child's copy of its
|
||||
/// parent's (see [`EpollSet::describe`]).
|
||||
pub fn with_watches(watches: Vec<Watch>) -> EpollSet {
|
||||
EpollSet { watched: Mutex::new(watches) }
|
||||
}
|
||||
|
||||
/// The registrations as text, `fd,events,data` joined by `;`, for a set
|
||||
/// that travels to another process by name.
|
||||
pub fn describe(&self) -> String {
|
||||
self.watches()
|
||||
.iter()
|
||||
.map(|w| format!("{},{},{}", w.fd, w.events, w.data))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
/// The other half of [`EpollSet::describe`]. `None` for text it did not write.
|
||||
pub fn parse(text: &str) -> Option<Vec<Watch>> {
|
||||
if text.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
text.split(';')
|
||||
.map(|w| {
|
||||
let mut it = w.split(',');
|
||||
let fd = it.next()?.parse().ok()?;
|
||||
let events = it.next()?.parse().ok()?;
|
||||
let data = it.next()?.parse().ok()?;
|
||||
Some(Watch { fd, events, data })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn watches(&self) -> Vec<Watch> {
|
||||
self.watched
|
||||
.lock()
|
||||
@@ -1066,6 +1098,21 @@ impl FileHandle for EpollSet {
|
||||
mod epoll_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_set_travels_by_its_description() {
|
||||
let e = EpollSet::new();
|
||||
e.ctl(epoll_op::ADD, 3, 1, 0xaa).unwrap();
|
||||
e.ctl(epoll_op::ADD, 9, 5, u64::MAX).unwrap();
|
||||
let text = e.describe();
|
||||
let copy = EpollSet::with_watches(EpollSet::parse(&text).unwrap());
|
||||
let w = copy.watches();
|
||||
assert_eq!(w.len(), 2);
|
||||
assert_eq!((w[0].fd, w[0].events, w[0].data), (3, 1, 0xaa));
|
||||
assert_eq!((w[1].fd, w[1].events, w[1].data), (9, 5, u64::MAX));
|
||||
assert!(EpollSet::parse("").unwrap().is_empty());
|
||||
assert!(EpollSet::parse("x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_modify_and_delete() {
|
||||
let e = EpollSet::new();
|
||||
|
||||
@@ -95,11 +95,43 @@ pub struct DevFs {
|
||||
/// Nodes the host added whose every open is a handle of its own (an
|
||||
/// audio device: each open is a stream with its own format and buffer).
|
||||
per_open: Vec<(String, Arc<dyn Fn() -> Arc<dyn FileHandle> + Send + Sync>)>,
|
||||
/// The pts numbers in use, when the system has pseudoterminals: `/dev/pts`
|
||||
/// lists them and `stat` answers for each (and for `/dev/ptmx`). Opening
|
||||
/// one is the kernel's (`open_pty`), not this filesystem's.
|
||||
pts: Option<Arc<dyn Fn() -> Vec<u32> + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl DevFs {
|
||||
pub fn new() -> DevFs {
|
||||
DevFs { extra: Vec::new(), per_open: Vec::new() }
|
||||
DevFs { extra: Vec::new(), per_open: Vec::new(), pts: None }
|
||||
}
|
||||
|
||||
/// `/dev/ptmx` and `/dev/pts`, with `list` saying which pts numbers exist.
|
||||
pub fn add_pts(&mut self, list: Arc<dyn Fn() -> Vec<u32> + Send + Sync>) {
|
||||
self.pts = Some(list);
|
||||
}
|
||||
|
||||
fn pts_node(&self, path: &str) -> Option<Metadata> {
|
||||
let list = self.pts.as_ref()?;
|
||||
let (ino, rdev, mode) = if path == "ptmx" {
|
||||
(crate::pty::PTMX_INO, crate::pty::PTMX_RDEV, 0o666)
|
||||
} else {
|
||||
let n: u32 = path.strip_prefix("pts/")?.parse().ok()?;
|
||||
if !list().contains(&n) {
|
||||
return None;
|
||||
}
|
||||
(crate::pty::pts_ino(n), crate::pty::pts_rdev(n), 0o620)
|
||||
};
|
||||
Some(Metadata {
|
||||
file_type: FileType::CharDevice,
|
||||
size: 0,
|
||||
mode,
|
||||
ino,
|
||||
mtime: 0,
|
||||
nlink: 1,
|
||||
rdev,
|
||||
times: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a device the host provides. The handle is shared, not cloned per
|
||||
@@ -154,8 +186,12 @@ impl FileSystem for DevFs {
|
||||
if path.is_empty() {
|
||||
return Ok(Arc::new(DevDir {
|
||||
extra: self.extra.iter().map(|(n, _)| n.clone()).chain(self.per_open.iter().map(|(n, _)| n.clone())).collect(),
|
||||
pts: self.pts.is_some(),
|
||||
}));
|
||||
}
|
||||
if let (Some(list), "pts") = (self.pts.as_ref(), path) {
|
||||
return Ok(Arc::new(PtsDir { numbers: list() }));
|
||||
}
|
||||
Err(Errno::ENOENT)
|
||||
}
|
||||
|
||||
@@ -178,6 +214,12 @@ impl FileSystem for DevFs {
|
||||
times: Default::default(),
|
||||
});
|
||||
}
|
||||
if path == "pts" && self.pts.is_some() {
|
||||
return Ok(Metadata::dir());
|
||||
}
|
||||
if let Some(md) = self.pts_node(path) {
|
||||
return Ok(md);
|
||||
}
|
||||
if matches!(path, "stdin" | "stdout" | "stderr" | "fd") {
|
||||
return Ok(Metadata {
|
||||
file_type: FileType::Symlink,
|
||||
@@ -208,6 +250,33 @@ impl FileSystem for DevFs {
|
||||
struct DevDir {
|
||||
/// Names the host added, so `ls /dev` shows them.
|
||||
extra: Vec<String>,
|
||||
/// Whether `ptmx` and `pts` exist.
|
||||
pts: bool,
|
||||
}
|
||||
|
||||
/// `/dev/pts`: one character device per pseudoterminal in use.
|
||||
#[derive(Debug)]
|
||||
struct PtsDir {
|
||||
numbers: Vec<u32>,
|
||||
}
|
||||
|
||||
impl FileHandle for PtsDir {
|
||||
fn read_at(&self, _offset: u64, _buf: &mut [u8]) -> SysResult {
|
||||
Err(Errno::EISDIR)
|
||||
}
|
||||
fn metadata(&self) -> Result<Metadata, Errno> {
|
||||
Ok(Metadata::dir())
|
||||
}
|
||||
fn read_dir(&self) -> Result<Vec<DirEntry>, Errno> {
|
||||
let mut out = vec![
|
||||
DirEntry { name: ".".into(), file_type: FileType::Directory, ino: 1 },
|
||||
DirEntry { name: "..".into(), file_type: FileType::Directory, ino: 1 },
|
||||
];
|
||||
for &n in &self.numbers {
|
||||
out.push(DirEntry { name: n.to_string(), file_type: FileType::CharDevice, ino: crate::pty::pts_ino(n) });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl FileHandle for DevDir {
|
||||
@@ -243,6 +312,10 @@ impl FileHandle for DevDir {
|
||||
ino: 1,
|
||||
});
|
||||
}
|
||||
if self.pts {
|
||||
out.push(DirEntry { name: "ptmx".into(), file_type: FileType::CharDevice, ino: crate::pty::PTMX_INO });
|
||||
out.push(DirEntry { name: "pts".into(), file_type: FileType::Directory, ino: 1 });
|
||||
}
|
||||
for name in ["stdin", "stdout", "stderr", "fd"] {
|
||||
out.push(DirEntry {
|
||||
name: name.into(),
|
||||
@@ -330,6 +403,24 @@ mod tests {
|
||||
/// A device the host adds is opened like any other, and the same handle
|
||||
/// comes back every time -- which is what lets one keep state across the
|
||||
/// several times a program opens it.
|
||||
/// `ttyname` compares `fstat` of the descriptor with `stat` of the name
|
||||
/// `/proc/self/fd` gives: `/dev/pts/N` must exist, as the same node.
|
||||
#[test]
|
||||
fn pts_lists_the_pairs_in_use_with_their_device_numbers() {
|
||||
let mut fs = DevFs::new();
|
||||
assert_eq!(fs.metadata("pts").err(), Some(Errno::ENOENT), "no pseudoterminals, no /dev/pts");
|
||||
fs.add_pts(Arc::new(|| vec![0, 3]));
|
||||
assert_eq!(fs.metadata("pts").unwrap().file_type, FileType::Directory);
|
||||
let names: Vec<String> = fs.open("pts", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
|
||||
assert_eq!(names, [".", "..", "0", "3"]);
|
||||
let md = fs.metadata("pts/3").unwrap();
|
||||
assert_eq!((md.file_type, md.rdev, md.ino), (FileType::CharDevice, 0x8803, crate::pty::pts_ino(3)));
|
||||
assert_eq!(fs.metadata("pts/1").err(), Some(Errno::ENOENT));
|
||||
assert_eq!(fs.metadata("ptmx").unwrap().rdev, (5 << 8) | 2);
|
||||
let root: Vec<String> = fs.open("", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
|
||||
assert!(root.iter().any(|n| n == "pts") && root.iter().any(|n| n == "ptmx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_device_is_openable_and_shared() {
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -25,6 +25,47 @@ pub trait ProcSource: Send + Sync {
|
||||
fn fds(&self) -> Vec<(i32, String)>;
|
||||
fn stat(&self) -> String;
|
||||
fn status(&self) -> String;
|
||||
/// Every process on the system, this one included, for `/proc/<pid>`
|
||||
/// and the listing of `/proc` (what `ps` and `htop` read). Empty where
|
||||
/// the kernel knows only this process.
|
||||
fn others(&self) -> Vec<crate::proc::ProcEntry> {
|
||||
Vec::new()
|
||||
}
|
||||
/// A session's controlling terminal, as its pts number and the terminal's
|
||||
/// foreground process group (`stat`'s `tty_nr` and `tpgid`).
|
||||
fn tty_of(&self, _sid: i32) -> Option<(u32, i32)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/proc/<pid>/stat` line: all 52 fields, as Linux has them. `ps` and
|
||||
/// `htop` parse it strictly -- by position after the `)` -- and a short line
|
||||
/// is a process they skip (htop listed none) or a fatal error (`ps`: "fatal
|
||||
/// library error, reap").
|
||||
pub fn stat_line(pid: i32, comm: &str, state: char, ppid: i32, pgid: i32, sid: i32, tty: Option<(u32, i32)>) -> String {
|
||||
let (tty_nr, tpgid) = tty.map_or((0, -1), |(n, fg)| (crate::pty::tty_nr(n), if fg > 0 { fg } else { -1 }));
|
||||
// pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt
|
||||
// majflt cmajflt utime stime cutime cstime priority nice num_threads
|
||||
// itrealvalue starttime vsize rss rsslim startcode endcode startstack
|
||||
// kstkesp kstkeip signal blocked sigignore sigcatch wchan nswap cnswap
|
||||
// exit_signal processor rt_priority policy delayacct_blkio_ticks
|
||||
// guest_time cguest_time start_data end_data start_brk arg_start arg_end
|
||||
// env_start env_end exit_code
|
||||
format!(
|
||||
"{pid} ({comm}) {state} {ppid} {pgid} {sid} {tty_nr} {tpgid} 4194560 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0 \
|
||||
18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// `/proc/<pid>/status`, the fields programs look for.
|
||||
pub fn status_text(pid: i32, comm: &str, state: char, ppid: i32) -> String {
|
||||
let st = if state == 'Z' { "Z (zombie)" } else { "R (running)" };
|
||||
format!(
|
||||
"Name:\t{comm}\nUmask:\t0022\nState:\t{st}\nTgid:\t{pid}\nNgid:\t0\nPid:\t{pid}\n\
|
||||
PPid:\t{ppid}\nTracerPid:\t0\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nFDSize:\t64\n\
|
||||
Groups:\t\nVmRSS:\t 0 kB\nThreads:\t1\nvoluntary_ctxt_switches:\t0\n\
|
||||
nonvoluntary_ctxt_switches:\t0\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// A generated file: contents are captured when it is opened.
|
||||
@@ -85,17 +126,43 @@ impl ProcFs {
|
||||
}
|
||||
|
||||
/// Rewrite a `<pid>/…` path to `self/…` so both spellings share one
|
||||
/// implementation, as they do on Linux for the current process.
|
||||
/// implementation, as they do on Linux for the current process. A
|
||||
/// thread's directory (`<pid>/task/<tid>/…`) is its process's: every
|
||||
/// process here has the one thread `/proc` reports.
|
||||
fn canonical(&self, path: &str) -> String {
|
||||
let path = untask(path);
|
||||
let pid = self.source.pid().to_string();
|
||||
match path.strip_prefix(&pid) {
|
||||
Some(rest) if rest.is_empty() || rest.starts_with('/') => {
|
||||
format!("self{rest}")
|
||||
}
|
||||
_ => path.to_string(),
|
||||
_ => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Another process's `/proc/<pid>/…`: its entry and the rest of the path.
|
||||
fn other(&self, path: &str) -> Option<(crate::proc::ProcEntry, String)> {
|
||||
let (first, rest) = path.split_once('/').unwrap_or((path, ""));
|
||||
let pid: i32 = first.parse().ok()?;
|
||||
let e = self.source.others().into_iter().find(|e| e.pid == pid)?;
|
||||
Some((e, rest.to_string()))
|
||||
}
|
||||
|
||||
fn generate_other(&self, e: &crate::proc::ProcEntry, rest: &str) -> Option<Vec<u8>> {
|
||||
let comm = e.comm();
|
||||
let state = if e.zombie { 'Z' } else { 'S' };
|
||||
Some(match rest {
|
||||
"stat" => stat_line(e.pid, &comm, state, e.ppid, e.pgid, e.sid, self.source.tty_of(e.sid)).into_bytes(),
|
||||
"status" => status_text(e.pid, &comm, state, e.ppid).into_bytes(),
|
||||
"statm" => b"0 0 0 0 0 0 0\n".to_vec(),
|
||||
"cmdline" => e.cmdline().to_vec(),
|
||||
"comm" => format!("{comm}\n").into_bytes(),
|
||||
// Another process's memory and environment are not ours to show.
|
||||
"environ" | "maps" => Vec::new(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let s = &self.source;
|
||||
Some(match path {
|
||||
@@ -104,6 +171,11 @@ impl ProcFs {
|
||||
"self/environ" => s.environ(),
|
||||
"self/stat" => s.stat().into_bytes(),
|
||||
"self/status" => s.status().into_bytes(),
|
||||
"self/statm" => b"0 0 0 0 0 0 0\n".to_vec(),
|
||||
"loadavg" => {
|
||||
let n = s.others().len().max(1);
|
||||
format!("0.00 0.00 0.00 1/{n} {}\n", s.pid()).into_bytes()
|
||||
}
|
||||
"self/comm" => {
|
||||
let exe = s.exe();
|
||||
let name = exe.rsplit('/').next().unwrap_or("gex").to_string();
|
||||
@@ -160,10 +232,52 @@ impl FileSystem for ProcFs {
|
||||
if let Some(data) = self.generate(&path) {
|
||||
return Ok(Arc::new(Generated { data }));
|
||||
}
|
||||
if let Some((e, rest)) = self.other(&path) {
|
||||
if let Some(data) = self.generate_other(&e, &rest) {
|
||||
return Ok(Arc::new(Generated { data }));
|
||||
}
|
||||
let mut out = base_entries();
|
||||
let names: &[&str] = match rest.as_str() {
|
||||
"" => &["stat", "status", "statm", "cmdline", "comm", "environ", "maps", "task"],
|
||||
"task" => {
|
||||
out.push(DirEntry { name: e.pid.to_string(), file_type: FileType::Directory, ino: 1 });
|
||||
&[]
|
||||
}
|
||||
_ => return Err(Errno::ENOENT),
|
||||
};
|
||||
for name in names {
|
||||
let file_type = if *name == "task" { FileType::Directory } else { FileType::Regular };
|
||||
out.push(DirEntry { name: name.to_string(), file_type, ino: 1 });
|
||||
}
|
||||
return Ok(Arc::new(GenDir { entries: out }));
|
||||
}
|
||||
if path == "self/task" {
|
||||
let mut out = base_entries();
|
||||
out.push(DirEntry { name: self.source.pid().to_string(), file_type: FileType::Directory, ino: 1 });
|
||||
return Ok(Arc::new(GenDir { entries: out }));
|
||||
}
|
||||
let entries = match path.as_str() {
|
||||
"" => vec!["self", "meminfo", "cpuinfo", "version", "uptime", "stat", "sys"],
|
||||
"" => {
|
||||
// Every process, as Linux lists them: what `ps` and `htop`
|
||||
// scan for. This one too, under its number as well as `self`.
|
||||
let mut out = base_entries();
|
||||
let mut pids: Vec<i32> = self.source.others().iter().map(|e| e.pid).collect();
|
||||
if !pids.contains(&self.source.pid()) {
|
||||
pids.push(self.source.pid());
|
||||
}
|
||||
pids.sort_unstable();
|
||||
for pid in pids {
|
||||
out.push(DirEntry { name: pid.to_string(), file_type: FileType::Directory, ino: 1 });
|
||||
}
|
||||
for name in ["self", "meminfo", "cpuinfo", "version", "uptime", "stat", "loadavg", "sys"] {
|
||||
let file_type = if matches!(name, "self" | "sys") { FileType::Directory } else { FileType::Regular };
|
||||
out.push(DirEntry { name: name.to_string(), file_type, ino: 1 });
|
||||
}
|
||||
return Ok(Arc::new(GenDir { entries: out }));
|
||||
}
|
||||
"self" => vec![
|
||||
"maps", "cmdline", "environ", "stat", "status", "comm", "exe", "cwd", "fd",
|
||||
"maps", "cmdline", "environ", "stat", "status", "statm", "comm", "exe", "cwd", "fd",
|
||||
"task",
|
||||
],
|
||||
"self/fd" => {
|
||||
let mut out = base_entries();
|
||||
@@ -206,8 +320,24 @@ impl FileSystem for ProcFs {
|
||||
times: Default::default(),
|
||||
});
|
||||
}
|
||||
if let Some((e, rest)) = self.other(&path) {
|
||||
return match rest.as_str() {
|
||||
"" | "task" => Ok(Metadata::dir()),
|
||||
r if self.generate_other(&e, r).is_some() => Ok(Metadata {
|
||||
file_type: FileType::Regular,
|
||||
size: 0,
|
||||
mode: 0o444,
|
||||
ino: 1,
|
||||
mtime: 0,
|
||||
nlink: 1,
|
||||
rdev: 0,
|
||||
times: Default::default(),
|
||||
}),
|
||||
_ => Err(Errno::ENOENT),
|
||||
};
|
||||
}
|
||||
match path.as_str() {
|
||||
"" | "self" | "self/fd" | "sys" | "sys/kernel" | "sys/vm" => Ok(Metadata::dir()),
|
||||
"" | "self" | "self/fd" | "self/task" | "sys" | "sys/kernel" | "sys/vm" => Ok(Metadata::dir()),
|
||||
"self/exe" | "self/cwd" => Ok(Metadata {
|
||||
file_type: FileType::Symlink,
|
||||
size: 0,
|
||||
@@ -243,6 +373,21 @@ impl FileSystem for ProcFs {
|
||||
}
|
||||
}
|
||||
|
||||
/// `<pid>/task/<tid>/rest` -> `<pid>/rest`, and `<pid>/task/<tid>` ->
|
||||
/// `<pid>`: a thread's directory is its process's.
|
||||
fn untask(path: &str) -> String {
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
if parts.len() >= 3 && parts[1] == "task" && parts[2].parse::<i32>().is_ok() {
|
||||
let mut out = parts[0].to_string();
|
||||
for p in &parts[3..] {
|
||||
out.push('/');
|
||||
out.push_str(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn base_entries() -> Vec<DirEntry> {
|
||||
vec![
|
||||
DirEntry {
|
||||
@@ -291,6 +436,59 @@ mod tests {
|
||||
fn status(&self) -> String {
|
||||
"Name:\tprog\nPid:\t42\n".into()
|
||||
}
|
||||
fn others(&self) -> Vec<crate::proc::ProcEntry> {
|
||||
let e = |pid, ppid, info: &[u8]| crate::proc::ProcEntry {
|
||||
pid,
|
||||
ppid,
|
||||
pgid: pid,
|
||||
sid: 1,
|
||||
zombie: false,
|
||||
info: info.to_vec(),
|
||||
};
|
||||
vec![e(1, 0, b"bash\n/bin/bash\0/etc/session\0"), e(7, 1, b"xterm\nxterm\0-e\0bash\0"), e(42, 7, b"prog\n")]
|
||||
}
|
||||
}
|
||||
|
||||
fn read(f: &ProcFs, path: &str) -> String {
|
||||
let h = f.open(path, 0, 0).unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = h.read_at(0, &mut buf).unwrap() as usize;
|
||||
String::from_utf8_lossy(&buf[..n]).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_lists_every_process() {
|
||||
let names: Vec<String> = fs().open("", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
|
||||
for pid in ["1", "7", "42", "self", "loadavg"] {
|
||||
assert!(names.iter().any(|n| n == pid), "{pid} missing from {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_process_has_its_stat_and_cmdline() {
|
||||
let f = fs();
|
||||
let stat = read(&f, "7/stat");
|
||||
assert!(stat.starts_with("7 (xterm) S 1 7 1 "), "{stat}");
|
||||
// htop and procps parse by position after the ')': all 52 fields.
|
||||
assert_eq!(stat.split_whitespace().count(), 52, "{stat}");
|
||||
assert_eq!(read(&f, "7/cmdline"), "xterm\0-e\0bash\0");
|
||||
assert_eq!(read(&f, "1/comm"), "bash\n");
|
||||
assert!(read(&f, "1/status").contains("PPid:\t0\n"));
|
||||
// A thread's directory is its process's.
|
||||
assert_eq!(read(&f, "7/task/7/stat"), stat);
|
||||
let task: Vec<String> = f.open("7/task", 0, 0).unwrap().read_dir().unwrap().into_iter().map(|e| e.name).collect();
|
||||
assert!(task.contains(&"7".to_string()));
|
||||
assert!(f.metadata("7").unwrap().file_type == FileType::Directory);
|
||||
assert_eq!(f.open("99/stat", 0, 0).err().map(|_| ()), Some(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_process_keeps_its_own_view() {
|
||||
// Its own pid is `self`, served from its own state, not the table's.
|
||||
assert_eq!(read(&fs(), "42/cmdline"), "/bin/prog\0arg\0");
|
||||
assert_eq!(stat_line(1, "x", 'R', 0, 1, 1, None).split_whitespace().count(), 52);
|
||||
let f: Vec<String> = stat_line(5, "sh", 'S', 1, 5, 5, Some((3, 7))).split_whitespace().map(String::from).collect();
|
||||
assert_eq!((f[6].as_str(), f[7].as_str()), ("34819", "7"), "tty_nr is 136:3, tpgid the foreground group");
|
||||
}
|
||||
|
||||
fn fs() -> ProcFs {
|
||||
|
||||
@@ -183,6 +183,13 @@ pub struct SurfaceInfo {
|
||||
pub xid: u32,
|
||||
/// The pixels are not at `addr`: the host knows where they are.
|
||||
pub external: bool,
|
||||
/// `wl_surface.set_buffer_scale`: buffer pixels per surface unit (1 unless
|
||||
/// the client said otherwise).
|
||||
pub buffer_scale: i32,
|
||||
/// The output scale this client was told (`wl_output.scale`): device
|
||||
/// pixels per surface unit. The host draws a surface at
|
||||
/// `width * client_scale / buffer_scale` device pixels.
|
||||
pub client_scale: i32,
|
||||
}
|
||||
|
||||
/// Interfaces the registry advertises, with the version offered. A toolkit
|
||||
@@ -417,6 +424,10 @@ struct SurfaceState {
|
||||
/// `wp_viewport.set_destination`: the size the surface is shown at, when
|
||||
/// the client set one.
|
||||
viewport: Option<(i32, i32)>,
|
||||
/// `set_buffer_scale`, pending until commit, and the committed value
|
||||
/// (0 is 1).
|
||||
pending_scale: Option<i32>,
|
||||
buffer_scale: i32,
|
||||
}
|
||||
|
||||
/// Whether a surface's pixels are the host's. A free function because the
|
||||
@@ -443,8 +454,11 @@ pub struct Client {
|
||||
/// Event serials, handed out with sync/configure/frame callbacks, counting
|
||||
/// up.
|
||||
next_serial: u32,
|
||||
/// The size a toplevel is told it is.
|
||||
/// The size a toplevel is told it is, in device pixels.
|
||||
default_size: (i32, i32),
|
||||
/// Device pixels per surface unit: what `wl_output.scale` says. A
|
||||
/// toplevel's configure is in surface units, so `default_size / scale`.
|
||||
scale: i32,
|
||||
/// Positioner state, by object id: what a popup asked to be placed against.
|
||||
positioners: HashMap<u32, Positioner>,
|
||||
/// Surfaces whose scene state changed since the host last published it, so
|
||||
@@ -510,6 +524,7 @@ impl Client {
|
||||
frames: Vec::new(),
|
||||
next_serial: 1,
|
||||
default_size: (640, 480),
|
||||
scale: 1,
|
||||
positioners: HashMap::new(),
|
||||
dirty: false,
|
||||
failed: None,
|
||||
@@ -532,6 +547,13 @@ impl Client {
|
||||
self.default_size = (w, h);
|
||||
}
|
||||
|
||||
/// The output scale this client is told (`wl_output.scale`), for a host
|
||||
/// whose output is in device pixels on a high-density display. Set before
|
||||
/// the client binds the output.
|
||||
pub fn set_scale(&mut self, scale: i32) {
|
||||
self.scale = scale.clamp(1, 8);
|
||||
}
|
||||
|
||||
/// Feed bytes and descriptors that arrived together.
|
||||
pub fn receive(&mut self, bytes: &[u8], fds: &[u32], buffers: &dyn Buffers) {
|
||||
self.inbox.extend_from_slice(bytes);
|
||||
@@ -847,7 +869,7 @@ impl Client {
|
||||
// mode(flags=current|preferred, width, height, refresh mHz)
|
||||
let ev = Writer::new(id, 1).uint(0b11).int(w_px).int(h_px).int(60000);
|
||||
self.send(ev);
|
||||
let ev = Writer::new(id, 3).int(1);
|
||||
let ev = Writer::new(id, 3).int(self.scale);
|
||||
self.send(ev);
|
||||
let ev = Writer::new(id, 2);
|
||||
self.send(ev);
|
||||
@@ -1052,7 +1074,9 @@ impl Client {
|
||||
return false;
|
||||
};
|
||||
if let Some(s) = self.surfaces.get_mut(&object) {
|
||||
let rect = Damage { x, y, w, h };
|
||||
// Surface units, and the host's damage is in buffer pixels.
|
||||
let k = s.pending_scale.unwrap_or(s.buffer_scale).max(1);
|
||||
let rect = Damage { x: x * k, y: y * k, w: w * k, h: h * k };
|
||||
s.pending_damage = Some(match s.pending_damage {
|
||||
Some(mut held) => {
|
||||
held.merge(rect);
|
||||
@@ -1079,8 +1103,17 @@ impl Client {
|
||||
self.commit(object, buffers);
|
||||
true
|
||||
}
|
||||
// set_buffer_transform, set_buffer_scale, offset
|
||||
7 | 8 | 10 => true,
|
||||
// set_buffer_scale(scale): buffer pixels per surface unit, from
|
||||
// the next commit.
|
||||
8 => {
|
||||
let Some(k) = r.int() else { return false };
|
||||
if let Some(s) = self.surfaces.get_mut(&object) {
|
||||
s.pending_scale = Some(k.clamp(1, 8));
|
||||
}
|
||||
true
|
||||
}
|
||||
// set_buffer_transform, offset
|
||||
7 | 10 => true,
|
||||
// damage_buffer(x, y, width, height), in buffer coordinates. The
|
||||
// same thing as `damage` at scale 1 and no transform, which is
|
||||
// every surface here -- and it is the one toolkits actually use.
|
||||
@@ -1107,6 +1140,15 @@ impl Client {
|
||||
|
||||
/// Apply a surface's pending state, the only moment anything becomes visible.
|
||||
fn commit(&mut self, surface: u32, buffers: &dyn Buffers) {
|
||||
// The buffer scale is double-buffered state like the rest.
|
||||
if let Some(s) = self.surfaces.get_mut(&surface) {
|
||||
if let Some(k) = s.pending_scale.take() {
|
||||
if k != s.buffer_scale.max(1) {
|
||||
self.dirty = true;
|
||||
}
|
||||
s.buffer_scale = k;
|
||||
}
|
||||
}
|
||||
let Some(state) = self.surfaces.get(&surface).cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -1364,7 +1406,8 @@ impl Client {
|
||||
// compositor sends, and a window that has just appeared is the
|
||||
// active one.
|
||||
let (w, h) = self.default_size;
|
||||
self.send_toplevel_configure(id, object, w, h, true);
|
||||
let k = self.scale.max(1);
|
||||
self.send_toplevel_configure(id, object, w / k, h / k, true);
|
||||
true
|
||||
}
|
||||
// get_popup(id, parent, positioner)
|
||||
@@ -1994,6 +2037,8 @@ impl Client {
|
||||
x11: s.x11,
|
||||
xid: s.xid,
|
||||
external: external.is_some(),
|
||||
buffer_scale: s.buffer_scale.max(1),
|
||||
client_scale: self.scale,
|
||||
});
|
||||
}
|
||||
// A stable order, so a host that keys textures by index rather than by
|
||||
@@ -3109,6 +3154,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surface_carries_its_buffer_scale_and_its_clients_output_scale() {
|
||||
// The cursor surface from above, drawn at 2x for a 2x output: the host
|
||||
// shows it at width * client_scale / buffer_scale, 1:1 here.
|
||||
let pool = Placed(0x8000, 4096);
|
||||
let (mut c, _) = placed_window(64, 64, 256);
|
||||
c.set_scale(2);
|
||||
c.receive(
|
||||
&Writer::new(2, 0).uint(3).string("wl_seat").uint(5).new_id(50).finish(),
|
||||
&[],
|
||||
&pool,
|
||||
);
|
||||
c.receive(&Writer::new(50, 0).new_id(51).finish(), &[], &pool);
|
||||
c.receive(&Writer::new(3, 0).new_id(52).finish(), &[], &pool);
|
||||
c.receive(&Writer::new(9, 0).new_id(53).int(0).int(24).int(24).int(96).uint(0).finish(), &[], &pool);
|
||||
c.receive(&Writer::new(51, 0).uint(1).object(52).int(4).int(3).finish(), &[], &pool);
|
||||
c.receive(&Writer::new(52, 1).object(53).int(0).int(0).finish(), &[], &pool);
|
||||
c.receive(&Writer::new(52, 8).int(2).finish(), &[], &pool); // set_buffer_scale(2)
|
||||
let before = c.scene().into_iter().find(|s| s.surface == 52);
|
||||
assert!(before.is_none() || before.unwrap().buffer_scale == 1, "pending until commit");
|
||||
c.receive(&Writer::new(52, 6).finish(), &[], &pool);
|
||||
let s = c.scene().into_iter().find(|s| s.surface == 52).expect("no cursor surface");
|
||||
assert_eq!((s.buffer_scale, s.client_scale), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_x11_window_is_a_window_on_the_managers_word() {
|
||||
let pool = Placed(0x9000, 8192);
|
||||
|
||||
+180
@@ -1596,3 +1596,183 @@ The cache saves the **global kernel's module store** and the slot numbering its
|
||||
- On edger, in the page, measure cold `node -e 0` and `apt install ripgrep tcc libc6-dev`.
|
||||
- Load twice in one browser profile. The second load runs with `?xcache=1` for "after", and without it for "before". This needs site.mjs to have a mode that reloads with a persistent profile.
|
||||
- Report translation counts as well as times.
|
||||
|
||||
## 9. The demo: a desktop, a small base, the rest on demand (plan, 2026-10-11)
|
||||
|
||||
What a visitor opens is one page that looks like a desktop, not a list of
|
||||
demos. It is its own build target, `web/build-demo.sh` → `web/dist-demo`,
|
||||
always stripped, and no test runs on it (one smoke leg: the desktop comes
|
||||
up with both windows).
|
||||
|
||||
**The page.** The compositor's output (`compositor.js`, WebGPU scene mode,
|
||||
which draws window decorations itself) fills the page. The shell chrome --
|
||||
a panel with the launcher button, a clock and the console toggle -- is DOM,
|
||||
drawn by the page: it costs the guest's rootfs nothing and needs no guest
|
||||
process. Two windows are open at start:
|
||||
|
||||
- **xterm running bash.** Debian's arm64 xterm (translated) on the page's
|
||||
X server (`hostX`, `web/public/gex/xwayland/`), its shell our wasm32 bash.
|
||||
- **The launcher**, a page-side window listing what can be added: gears,
|
||||
the GTK 3 demo, foot, nvim, node, apt, and the programs of the old demo
|
||||
list worth keeping. Choosing one downloads it (once), adds it to the
|
||||
filesystem and runs it.
|
||||
|
||||
pid 1 is bash reading commands from the page's terminal (hidden; the panel
|
||||
shows it as a console). The launcher starts a program by writing its command
|
||||
line there, so no guest-side launcher exists.
|
||||
|
||||
**The base rootfs** is what bash and xterm need and nothing else:
|
||||
`mkrootfs.py --roots base-files,base-passwd,ncurses-base --extra xterm
|
||||
--ours bash,mawk,sysvinit-utils` (mawk and sysvinit-utils only satisfy
|
||||
base-files' and x11-common's dependencies), then pruned of the fonts xterm
|
||||
does not use (core fonts come from the X server; DejaVu Sans Mono stays for
|
||||
`-fa`), X11 locales other than C/en_US.UTF-8, and debconf's perl.
|
||||
|
||||
**Everything else comes at run time** as zstd-compressed downloads, in one
|
||||
of two forms, and the numbers decide which the launcher uses:
|
||||
|
||||
- (a) a **tar overlay** per launcher item: the item's closure minus the
|
||||
base, built by `mkrootfs.py` as base+item and diffed against the base. The
|
||||
page fetches `pkgs/ITEM.tar.zst`, decodes it with zstddec and writes the
|
||||
files straight into the global kernel's filesystem from a Worker
|
||||
(`k_fs_add`, `k_fs_symlink`, `k_fs_chmod`), while the guest runs. Its dpkg
|
||||
status stanzas go along, so a later `apt` sees the item as installed.
|
||||
- (b) **.deb**: `dpkg -i` of prefetched .debs, or `apt install` from the
|
||||
network (Wisp). apt and dpkg are themselves an overlay (they are not in
|
||||
the base).
|
||||
|
||||
Measured on edger for two or three items (apt from the network, dpkg -i of a
|
||||
prefetched .deb, the tar overlay); the expectation is tar overlays for the
|
||||
launcher and apt for what the user installs, but the measurement decides.
|
||||
|
||||
**Stripped, always.** `wasm-tools strip` takes debug sections off every
|
||||
.wasm (the name section stays: a trap still names its function). Runtime
|
||||
.wasm files ship only as `.wasm.zst` (`zstd --ultra -22`) and a demo build's
|
||||
page decodes them with zstddec: one `fetch` wrapper in the page and in every
|
||||
Worker, so the shim, kernel, providers, Xwayland and Mesa all go through it.
|
||||
The base rootfs ships as `rootfs/base.tar.zst` (`--ultra -22`).
|
||||
|
||||
**Milestones**, each served at a directly openable https URL (100.64.0.3):
|
||||
|
||||
1. base rootfs and the stripped build (sizes, compressed and not);
|
||||
2. the desktop with both windows;
|
||||
3. the launcher and on-demand packages, with the apt / dpkg / tar numbers;
|
||||
4. time to desktop and download size, on edger.
|
||||
|
||||
### 9.1 As built and measured (2026-10-11)
|
||||
|
||||
`web/build-demo.sh` (items: `web/demo-items.json`, overlays:
|
||||
`tools/gexcc/debian/mkoverlay.py`); the smoke and every number below:
|
||||
`tests/demo/desktop.mjs` (`DEMO_STEPS`), headless Chromium on edger's
|
||||
Quadro P620, fresh profile per visit, the dist served from 127.0.0.1 (so
|
||||
fetch times are local-disk, not a real network).
|
||||
|
||||
**Base rootfs** (user, same day: "the base first-load should have coreutils
|
||||
at least"): bash, dash, xterm and its arm64 closure, and ours coreutils,
|
||||
grep, sed, findutils, procps, less, ncurses-bin, gzip, tar, util-linux,
|
||||
hostname, nano, debianutils: **38.8 MiB tree / 38.8 MiB tar / 8.3 MiB zstd**
|
||||
(bash + xterm alone: 20.6 / 20.4 / 5.7). What each tool adds is listed in
|
||||
build-demo.sh; the rest (file, gawk, diffutils, zstd, xz, bsdextrautils,
|
||||
tree: 1.5 MiB) is the launcher's "More tools".
|
||||
|
||||
**Runtime modules**, stripped (name section kept) then zstd -22: shim
|
||||
50.5 → 5.7 → 1.2 MiB, kernel 15.2 → 0.4 → 0.1, Xwayland 2.0 → 2.0 → 0.5,
|
||||
its bridge 11.9 → 0.2 → 0.05; Mesa (fetched on the first GL item only)
|
||||
19.6 → 19.6 → 3.3. **First load: 11.4 MiB** (runtime 1.9, rootfs 8.3, page
|
||||
JS about 1).
|
||||
|
||||
**Time to desktop** (navigation → xterm mapped, launcher open): 4.4–5.1 s
|
||||
over 12 visits (median about 4.6 s). Downloads and unpack are done by about
|
||||
0.6 s and pid 1 starts by about 0.9 s; the rest is the X server and the
|
||||
translated xterm/libX11 coming up.
|
||||
|
||||
**Launcher items, three ways** (same items, ms; tar = fetch + zstd decode +
|
||||
write into the running kernel; dpkg/apt = the guest's own clock, after the
|
||||
apt overlay, 10.1 MiB zstd, 0.4–0.5 s):
|
||||
|
||||
| item | packages | tar overlay | dpkg -i of prefetched .debs | apt-get install (network) |
|
||||
|---|---|---|---|---|
|
||||
| htop | 1 | 26–41 (0.27 MB) | 1,176–1,381 (.debs 0.25 MB) | – (ours; pinned) |
|
||||
| x11-apps | 18 | 106–112 (2.5 MB) | 20,218–21,115 (4.8 MB) | 26,138–26,353 |
|
||||
| neovim | 12 | 317–352 (7.2 MB) | 13,420–13,449 (7.6 MB) | 28,617–28,737 |
|
||||
|
||||
`apt-get update` alone (trixie arm64 indexes over Wisp): 9.1–9.3 s on
|
||||
wss://anura.pro/, 19.0–26.0 s on wss://wisp.mercurywork.shop/. dpkg -i takes 29 to 199 times as long as the overlay
|
||||
for the same files (maintainer scripts, triggers and dpkg itself run
|
||||
translated), and apt adds the network on top. So: **the launcher uses tar
|
||||
overlays; apt stays for what the user installs themselves.** Overlays also
|
||||
record their packages in dpkg's status, so apt sees launcher items as
|
||||
installed.
|
||||
|
||||
Two fixes the measurements forced: the base needs dash (dpkg refuses to
|
||||
run without `sh`), and the trees keep debconf (`man-db`'s postinst sources
|
||||
`/usr/share/debconf/confmodule`). apt installing a Debian package that
|
||||
depends on one only we build (man-db → bsdextrautils) needs our .debs:
|
||||
the apt overlay registers the whole pool (56 MB) as lazy files under
|
||||
/var/lib/gex-apt, fetched from the page only when apt reads one.
|
||||
|
||||
### 9.2 What the first users hit, and the fixes (2026-10-12)
|
||||
|
||||
- **Blurry output.** The canvas was sized in CSS pixels. It is now sized in
|
||||
device pixels, and guest Wayland clients are told the rounded
|
||||
devicePixelRatio as `wl_output.scale`. `set_buffer_scale` is honoured, and
|
||||
each record carries the buffer and client scales in its flags word, bits
|
||||
8–15 and 16–23. X windows stay at scale 1 in device pixels, and xterm is
|
||||
sized through `/root/.Xdefaults` (`Xft.dpi`). Where nothing is scaled, a
|
||||
draw samples nearest, so a terminal's glyphs are its own pixels.
|
||||
- **Two pointers.** No cursor is composited any more. The client's cursor
|
||||
surface (or Xwayland's) becomes the canvas's CSS cursor as a `url()` with
|
||||
its hotspot, cached by content; a null cursor becomes `none`, and the
|
||||
desktop and window frames get `default`.
|
||||
- **glxgears from the xterm: "couldn't get an RGB, Double-buffered visual".**
|
||||
Debian's glvnd `libGL` had no vendor library, because the Mesa drivers were
|
||||
pruned from the trees. The gears item maps gex's marshallers onto
|
||||
Debian's `libGL.so.1`, `libEGL.so.1` and `libGLESv2.so.2`, and the page
|
||||
starts Mesa on a guest's first GL request (`glLazy`).
|
||||
- **htop with no processes.** `/proc` listed only `self`. Each process now
|
||||
tells the process table its name and command line once per image, and
|
||||
`/proc` lists every pid with `stat` (all 52 fields), `status`, `statm`,
|
||||
`cmdline` and `comm`, plus `loadavg`.
|
||||
- **nvim ignored typing.** The pty answered FIONBIO with ENOTTY, so libuv's
|
||||
`uv_pipe_open` failed on the terminal. FIONBIO and the close-on-exec
|
||||
ioctls (FIONCLEX, FIOCLEX, FIOASYNC) are now left to the kernel.
|
||||
- **Terminals and fork.** A fork's child keeps its parent's epoll sets.
|
||||
`/dev/pts` and `/dev/ptmx` exist; `ttyname` and `tty` work; `/proc/<pid>/stat`
|
||||
carries `tty_nr` and `tpgid`, so ps's TTY column shows `pts/N`. The pty
|
||||
master reads EIO once every slave has closed, as on Linux. Before, it
|
||||
waited until the session leader was reaped, so a reader that only
|
||||
reaps after EIO (`pty.fork`, read until EIO, then `waitpid`) never
|
||||
stopped.
|
||||
- **Known gap: epoll across fork is a copy.** The child rebuilds the set from
|
||||
its registrations (the same descriptor numbers name the same files). Linux
|
||||
shares one instance between parent and child. Here an `epoll_ctl` on
|
||||
either side after the fork is not seen by the other, and the two
|
||||
`epoll_wait`s do not share wakeups. Real sharing needs the set in the global
|
||||
kernel, keyed by open file description; it comes with W's kernel-locking
|
||||
step 2 (per-object wait queues in the global kernel, docs/kernel-locking.md).
|
||||
- **xterm showing the frame before the one it drew.** The X server in the
|
||||
page closes a pool's descriptor as soon as it has sent it to the
|
||||
compositor, and its next pool gets the same number. The compositor was
|
||||
handed the number, so a buffer of an older pool was located, at its next
|
||||
commit, in whichever pool held the number now. Typing into the xterm
|
||||
reuses its two buffers, which then shared one pool's memory: the window
|
||||
showed the frame before the last until something made a fresh pool
|
||||
(clicking off and back on did). A cursor pool on the reused number made the
|
||||
commit fail its size check and be dropped. `web/src/hostx.js` now gives the
|
||||
compositor its own handle per descriptor sent, as `SCM_RIGHTS` would.
|
||||
The leg types into the xterm, waits, and compares the X server's copy of
|
||||
the window (`xwd`, through `tests/demo/guest/xsig.py`) with the
|
||||
compositor's output. Before the fix every round differed by the last
|
||||
character and the cursor: 47 dark pixels at dpr 1, 228 at dpr 2. After
|
||||
the fix every round matches exactly. `?hostxcount=1` logs, once a second,
|
||||
the bytes the X server took from and sent to each client, its bytes to the
|
||||
compositor and its scenes published.
|
||||
- **Known gap: pool memory.** A pool's pixels are never freed: the
|
||||
compositor may still read them after the X server closes the descriptor.
|
||||
Xwayland drops a buffer after a second unused and makes a new one, so an
|
||||
xterm printing a line every 1.3 s allocates about 0.9 MB per line.
|
||||
Freeing needs the mapping's lifetime (`gex_shm_unmap` in
|
||||
`web/xwayland/gexos.c` is a no-op) and a release from the compositor.
|
||||
|
||||
Legs: `node tests/demo/desktop.mjs web/dist-demo` (the desktop, then the
|
||||
cursor, presentation and xterm-contents legs; `DEMO_DPR=2` for the device-pixel output).
|
||||
@@ -1383,6 +1383,7 @@ export class Gex {
|
||||
this.#libprovConfig();
|
||||
this.#shim.exports.gex_set_wl?.(this.#wl ? 1 : 0);
|
||||
if (this.#wlOut) this.#shim.exports.gex_set_wl_output?.(...this.#wlOut);
|
||||
if (this.#wlOut) this.#shim.exports.gex_set_wl_scale?.(this.#wlScale);
|
||||
// `kernel.net` is decided inside `gex_load` from this flag, so it must be
|
||||
// set before it (not merely in `useNetwork`, before the shim exists).
|
||||
this.#shim.exports.gex_set_network?.(this.#net ? 1 : 0);
|
||||
@@ -1872,6 +1873,7 @@ export class Gex {
|
||||
this.#shim.exports.gex_set_gl?.(this.#gl ? 1 : 0);
|
||||
this.#shim.exports.gex_set_wl?.(this.#wl ? 1 : 0);
|
||||
if (this.#wlOut) this.#shim.exports.gex_set_wl_output?.(...this.#wlOut);
|
||||
if (this.#wlOut) this.#shim.exports.gex_set_wl_scale?.(this.#wlScale);
|
||||
this.#shim.exports.gex_set_network?.(this.#net ? 1 : 0);
|
||||
// The unit cache and ELF export providers, as `load` gives a process its
|
||||
// own: a forked child's table starts empty while its GOT holds what
|
||||
@@ -4140,15 +4142,18 @@ export class Gex {
|
||||
}
|
||||
|
||||
/** Say how large the compositor's output is, before the guest runs. */
|
||||
wlOutput(w, h) {
|
||||
wlOutput(w, h, scale = 1) {
|
||||
// Kept, and given again to every shim instance this process makes: a
|
||||
// fresh instance (a fork child's, an exec's) starts at the default
|
||||
// 640x480, and W3 starts every Windows program by fork and exec.
|
||||
this.#wlOut = [w | 0, h | 0];
|
||||
this.#wlScale = Math.max(1, scale | 0);
|
||||
this.#shim?.exports?.gex_set_wl_output?.(w | 0, h | 0);
|
||||
this.#shim?.exports?.gex_set_wl_scale?.(this.#wlScale);
|
||||
}
|
||||
|
||||
#wlOut = null;
|
||||
#wlScale = 1;
|
||||
|
||||
/**
|
||||
* This process's name in the compositor's scene arena: its pid, which every
|
||||
|
||||
@@ -384,6 +384,23 @@ export class ProcessHost {
|
||||
return this.#stacks.area + n * this.#stacks.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kernel stack slice for something that is not a guest Worker but calls
|
||||
* into the global kernel from a realm of its own (the demo's overlay
|
||||
* Worker, `web/src/overlay.js`). Undefined until the first Worker has said
|
||||
* where the stacks are. Give it back with `releaseKernelStack(key)`.
|
||||
*/
|
||||
takeKernelStack(key) {
|
||||
return this.#nextKernelStack(`ext:${key}`);
|
||||
}
|
||||
|
||||
releaseKernelStack(key) {
|
||||
const slice = this.#stackOf.get(`ext:${key}`);
|
||||
if (slice === undefined) return;
|
||||
this.#stackOf.delete(`ext:${key}`);
|
||||
this.#stacksFree.push(slice);
|
||||
}
|
||||
|
||||
/** A Worker has ended: stop it, and put its kernel stack back. */
|
||||
#release(key, w) {
|
||||
this.#workers.delete(key);
|
||||
|
||||
@@ -89,6 +89,8 @@ export const EV_MOVED = 10;
|
||||
// meta: what the compositor decided, read by every process.
|
||||
const M_OUT_W = 0;
|
||||
const M_OUT_H = 1;
|
||||
/** Device pixels per surface unit for guest Wayland clients (wl_output.scale). */
|
||||
const M_SCALE = 2;
|
||||
const M_WORDS = 8;
|
||||
|
||||
// Block header: the token that claimed it, a heartbeat so a dead process's
|
||||
@@ -122,15 +124,17 @@ export function makeWlChannel() {
|
||||
}
|
||||
|
||||
/** Tell the guests how large the output is, so a toplevel is configured to it. */
|
||||
export function setOutputSize(channel, w, h) {
|
||||
export function setOutputSize(channel, w, h, scale) {
|
||||
const a = new Int32Array(channel.arena);
|
||||
Atomics.store(a, M_OUT_W, w | 0);
|
||||
Atomics.store(a, M_OUT_H, h | 0);
|
||||
if (scale) Atomics.store(a, M_SCALE, Math.max(1, scale | 0));
|
||||
}
|
||||
|
||||
export function outputSize(channel) {
|
||||
const a = new Int32Array(channel.arena);
|
||||
return { w: Atomics.load(a, M_OUT_W) || 640, h: Atomics.load(a, M_OUT_H) || 480 };
|
||||
return { w: Atomics.load(a, M_OUT_W) || 640, h: Atomics.load(a, M_OUT_H) || 480,
|
||||
scale: Atomics.load(a, M_SCALE) || 1 };
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
@@ -145,6 +149,7 @@ const titleBytes = new Uint8Array(R_TITLE_WORDS * 4);
|
||||
* light per surface and does no work at all for a block that has not changed
|
||||
* since the caller last acked it -- `changed` says which those were.
|
||||
*/
|
||||
|
||||
export function readScene(channel, { onlyChanged = false } = {}) {
|
||||
const a = new Int32Array(channel.arena);
|
||||
const out = [];
|
||||
|
||||
@@ -478,7 +478,7 @@ async function handle(msg) {
|
||||
gex.useWl(proxy);
|
||||
// The output size a toplevel is configured to, before the guest runs.
|
||||
const out = outputSize(msg.wlChannel);
|
||||
gex.wlOutput(out.w, out.h);
|
||||
gex.wlOutput(out.w, out.h, out.scale);
|
||||
// The compositor reads this process's pixels out of this memory, so it
|
||||
// needs the memory itself. Sent once, with the token that names this
|
||||
// process's block in the arena.
|
||||
|
||||
@@ -250,6 +250,8 @@ extern "C" {
|
||||
fn k_setpgid(caller: i32, target: i32, pgid: i32) -> i32;
|
||||
fn k_request_halt(code: i32);
|
||||
fn k_halt_code() -> i32;
|
||||
fn k_proc_set_info(pid: i32, at: gex_kernel::PAddr, len: u32);
|
||||
fn k_proc_snapshot(at: gex_kernel::PAddr, cap: u32) -> i32;
|
||||
|
||||
// The filesystem. Every address below is in *this* process's memory; the
|
||||
// other side copies across with `memory.copy`, which is the only way it
|
||||
@@ -645,6 +647,12 @@ extern "C" fn r_request_halt(code: i32) {
|
||||
extern "C" fn r_halt_code() -> i32 {
|
||||
unsafe { k_halt_code() }
|
||||
}
|
||||
extern "C" fn r_set_info(pid: i32, at: gex_kernel::PAddr, len: u32) {
|
||||
unsafe { k_proc_set_info(pid, at, len) }
|
||||
}
|
||||
extern "C" fn r_snapshot(at: gex_kernel::PAddr, cap: u32) -> i32 {
|
||||
unsafe { k_proc_snapshot(at, cap) }
|
||||
}
|
||||
|
||||
/// The process table every process shares, if the host gave us one.
|
||||
///
|
||||
@@ -676,6 +684,8 @@ fn global_procs() -> Option<Arc<gex_kernel::proc::ProcTable>> {
|
||||
setpgid: r_setpgid,
|
||||
request_halt: r_request_halt,
|
||||
halt_code: r_halt_code,
|
||||
set_info: r_set_info,
|
||||
snapshot: r_snapshot,
|
||||
},
|
||||
)))
|
||||
}
|
||||
@@ -1374,6 +1384,22 @@ impl FileHandle for StdinQueue {
|
||||
fn mount_devices(kernel: &mut Kernel) -> Option<crate::wl::WaylandDevice> {
|
||||
let mut devfs = gex_kernel::vfs::DevFs::new();
|
||||
devfs.add("tty", Arc::new(Tty));
|
||||
if unsafe { k_present() } != 0 {
|
||||
// `/dev/pts`: the pairs some process still holds an end of (the global
|
||||
// kernel numbers them from 0 and the id is the pts number).
|
||||
devfs.add_pts(Arc::new(|| {
|
||||
let mut out = Vec::new();
|
||||
for id in 0.. {
|
||||
if unsafe { k_pty_ptn(id) } < 0 {
|
||||
break;
|
||||
}
|
||||
if unsafe { k_pty_counts(id) } != 0 {
|
||||
out.push(id as u32);
|
||||
}
|
||||
}
|
||||
out
|
||||
}));
|
||||
}
|
||||
if crate::gl::have_gl() {
|
||||
devfs.add("gex-gl", crate::gl::GlDevice::new());
|
||||
}
|
||||
@@ -7814,8 +7840,10 @@ fn stage_fork_fds(h: &Host, child: i32) {
|
||||
/// copy looks like the last reference and the reader sees a false EOF. A file
|
||||
/// the global kernel holds goes as a duplicated id, which survives the unlink
|
||||
/// of a file the receiver would otherwise fail to reopen by name; anything
|
||||
/// else with a path, by path. `None`: nothing with a name -- a local socket,
|
||||
/// an epoll set.
|
||||
/// else with a path, by path. An epoll set goes as its registrations (the
|
||||
/// same descriptor numbers name the same files in a fork's child): a copy, so
|
||||
/// an `epoll_ctl` on one side after the fork is not seen by the other, where
|
||||
/// Linux shares the one set. `None`: nothing with a name -- a local socket.
|
||||
fn carry_name(k: &gex_kernel::syscalls::Kernel, file: &gex_kernel::file::OpenFile, holder: i32) -> Option<String> {
|
||||
if let Some(id) = pipe_id(&file.path) {
|
||||
unsafe { k_pipe_open_end(id, file.writable as i32, holder) };
|
||||
@@ -7841,9 +7869,20 @@ fn carry_name(k: &gex_kernel::syscalls::Kernel, file: &gex_kernel::file::OpenFil
|
||||
let dup = sock.dup_id()?;
|
||||
return Some(format!("sock:{dup}:{}", sock.listening as i32));
|
||||
}
|
||||
if let Some(set) = k.epoll_of_file(file) {
|
||||
let key = Arc::as_ptr(&file.handle) as *const () as usize;
|
||||
return Some(format!("epoll:{key}:{}", set.describe()));
|
||||
}
|
||||
file.path.starts_with('/').then(|| file.path.clone())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While `gex_adopt_fds` runs: the epoll sets rebuilt so far, by the
|
||||
/// parent's key, so two descriptors for one set stay one set.
|
||||
static ADOPTED_EPOLLS: RefCell<Option<std::collections::HashMap<String, Arc<gex_kernel::file::OpenFile>>>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The console descriptors, rebuilt in a child from the name alone. A fork
|
||||
/// stages each descriptor as a path for the child to reopen, which works for a
|
||||
/// file but not the terminal -- a Worker's own `Console`, named `/dev/stdin`
|
||||
@@ -7906,7 +7945,8 @@ pub extern "C" fn gex_adopt_fds(ptr: u32, len: u32) -> i32 {
|
||||
return -1;
|
||||
};
|
||||
let text = text.to_string();
|
||||
with_host(|h| {
|
||||
ADOPTED_EPOLLS.with(|m| *m.borrow_mut() = Some(Default::default()));
|
||||
let n = with_host(|h| {
|
||||
let mut n = 0;
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(5, ' ');
|
||||
@@ -7937,7 +7977,9 @@ pub extern "C" fn gex_adopt_fds(ptr: u32, len: u32) -> i32 {
|
||||
}
|
||||
}
|
||||
n
|
||||
})
|
||||
});
|
||||
ADOPTED_EPOLLS.with(|m| *m.borrow_mut() = None);
|
||||
n
|
||||
}
|
||||
|
||||
/// The other half of [`carry_name`]: the open file a name stands for, in
|
||||
@@ -7985,6 +8027,19 @@ fn rebuild_carried(
|
||||
);
|
||||
f.set_offset(offset);
|
||||
f
|
||||
} else if let Some(rest) = path.strip_prefix("epoll:") {
|
||||
let (key, watches) = rest.split_once(':').ok_or("bad epoll name")?;
|
||||
let watches = gex_kernel::unix::EpollSet::parse(watches).ok_or("bad epoll registrations")?;
|
||||
if let Some(f) = ADOPTED_EPOLLS.with(|m| m.borrow().as_ref().and_then(|m| m.get(key).cloned())) {
|
||||
return Ok(f);
|
||||
}
|
||||
let f = k.adopt_epoll(watches, flags);
|
||||
ADOPTED_EPOLLS.with(|m| {
|
||||
if let Some(m) = m.borrow_mut().as_mut() {
|
||||
m.insert(key.to_string(), Arc::clone(&f));
|
||||
}
|
||||
});
|
||||
f
|
||||
} else if let Some(rest) = path.strip_prefix("sock:") {
|
||||
let r = global_sockets().ok_or("no global kernel")?;
|
||||
let (id, listening) = rest.split_once(':').ok_or("bad socket name")?;
|
||||
|
||||
+13
-1
@@ -192,6 +192,15 @@ fn output_size() -> (i32, i32) {
|
||||
unsafe { OUTPUT }
|
||||
}
|
||||
|
||||
static OUTPUT_SCALE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1);
|
||||
|
||||
/// Device pixels per surface unit (`wl_output.scale`): the page's
|
||||
/// devicePixelRatio, rounded, when its output is in device pixels.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn gex_set_wl_scale(scale: i32) {
|
||||
OUTPUT_SCALE.store(scale.clamp(1, 8), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// This process's name in the compositor's scene arena: its pid.
|
||||
///
|
||||
/// It has to be the *process's* and not the Worker's. A process's threads are
|
||||
@@ -672,7 +681,9 @@ fn push_record(out: &mut Vec<i32>, client: u32, info: &SurfaceInfo) {
|
||||
r[13] = info.damage.y;
|
||||
r[14] = info.damage.w;
|
||||
r[15] = info.damage.h;
|
||||
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1);
|
||||
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1)
|
||||
| ((info.buffer_scale & 0xff) << 8)
|
||||
| ((info.client_scale & 0xff) << 16);
|
||||
// Which X window this is, for a host that knows a drawable and not a
|
||||
// surface -- an OpenGL client naming what it draws into.
|
||||
r[17] = info.xid as i32;
|
||||
@@ -695,6 +706,7 @@ impl HostListener for WaylandDevice {
|
||||
let mut client = Client::new();
|
||||
let (w, h) = output_size();
|
||||
client.set_default_size(w, h);
|
||||
client.set_scale(OUTPUT_SCALE.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let idx = {
|
||||
let mut n = self.inner.next_idx.lock().unwrap();
|
||||
*n += 1;
|
||||
|
||||
@@ -478,7 +478,9 @@ fn push_record(out: &mut Vec<i32>, client: u32, info: &SurfaceInfo) {
|
||||
r[13] = info.damage.y;
|
||||
r[14] = info.damage.w;
|
||||
r[15] = info.damage.h;
|
||||
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1);
|
||||
r[16] = i32::from(info.move_requested) | (i32::from(info.external) << 1)
|
||||
| ((info.buffer_scale & 0xff) << 8)
|
||||
| ((info.client_scale & 0xff) << 16);
|
||||
// The X window this surface is, for a GL client that names a drawable.
|
||||
r[17] = info.xid as i32;
|
||||
let mut n = info.title.len().min(TITLE_BYTES);
|
||||
|
||||
+32
-4
@@ -1820,6 +1820,30 @@ pub extern "C" fn k_halt_code() -> i32 {
|
||||
procs().halt_code().unwrap_or(-1)
|
||||
}
|
||||
|
||||
/// A process's name and command line, for the others' `/proc/<pid>`.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn k_proc_set_info(pid: i32, at: gex_kmem::PAddr, len: u32) {
|
||||
let len = len.min(4096);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
if len > 0 {
|
||||
unsafe { gex_kmem::pull_checked(buf.as_mut_ptr(), at, len) };
|
||||
}
|
||||
procs().set_info(pid, &buf);
|
||||
}
|
||||
|
||||
/// Every process, encoded (`gex_kernel::proc::encode_entries`), into up to
|
||||
/// `cap` bytes at `at`. Returns the whole length: more than `cap` means ask
|
||||
/// again with that much room.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn k_proc_snapshot(at: gex_kmem::PAddr, cap: u32) -> i32 {
|
||||
let b = gex_kernel::proc::encode_entries(&procs().entries());
|
||||
let n = (b.len() as u32).min(cap);
|
||||
if n > 0 {
|
||||
unsafe { gex_kmem::push_checked(at, b.as_ptr(), n) };
|
||||
}
|
||||
b.len() as i32
|
||||
}
|
||||
|
||||
// -- pipes -------------------------------------------------------------------
|
||||
//
|
||||
// A pipe crosses processes as a *number*: each file table holds an id and the
|
||||
@@ -2192,9 +2216,13 @@ pub extern "C" fn k_pty_forget(pid: i32) -> u32 {
|
||||
/// above opens *before* `TIOCSCTTY`, when no session owns it yet. Either one
|
||||
/// alive means the slave is merely between descriptors.
|
||||
fn hangup_is_real(p: &gex_kernel::pty::Pty, id: i32) -> bool {
|
||||
// Alive, not merely in the table: a session leader that exited is a
|
||||
// zombie until its parent waits, and the parent waiting is often the very
|
||||
// reader that needs the hangup to stop reading (`pty.fork`, then read the
|
||||
// master until EIO, then `waitpid`).
|
||||
let sid = p.tty.sid();
|
||||
if sid > 0 {
|
||||
return !procs().exists(sid);
|
||||
return !procs().alive(sid);
|
||||
}
|
||||
let who = last_slave_closer()
|
||||
.lock()
|
||||
@@ -2202,7 +2230,7 @@ fn hangup_is_real(p: &gex_kernel::pty::Pty, id: i32) -> bool {
|
||||
.get(&id)
|
||||
.copied();
|
||||
match who {
|
||||
Some(pid) if pid > 0 => !procs().exists(pid),
|
||||
Some(pid) if pid > 0 => !procs().alive(pid),
|
||||
// Nobody has closed one yet, or the closer is unknown: the old rule.
|
||||
_ => true,
|
||||
}
|
||||
@@ -2243,10 +2271,10 @@ pub extern "C" fn k_pty_read(id: i32, master: i32, at: gex_kmem::PAddr, len: u32
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let r = if master != 0 {
|
||||
match p.master_read(&mut buf) {
|
||||
// End of file, but the session that owns the slave is still there:
|
||||
// Hung up, but the session that owns the slave is still there:
|
||||
// this is the reopen window, not a hangup. See
|
||||
// `hangup_is_real`.
|
||||
Ok(0) if !hangup_is_real(&p, id) => Err(gex_kernel::errno::Errno(11)),
|
||||
Err(e) if e == gex_kernel::errno::Errno::EIO && !hangup_is_real(&p, id) => Err(gex_kernel::errno::Errno(11)),
|
||||
other => other,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
// The demo desktop in a real browser: the demo build's one test (docs/gexcc.md §9).
|
||||
//
|
||||
// node tests/demo/desktop.mjs [DIST] (default web/dist-demo)
|
||||
//
|
||||
// Serves DIST (COOP/COEP, on 127.0.0.1), opens it in headless Chromium and
|
||||
// waits for the desktop: both windows up (the compositor has mapped the
|
||||
// xterm; the launcher is the page's). Prints the page's marks (ms from
|
||||
// navigation): runtime fetched, rootfs fetched, system up, desktop.
|
||||
//
|
||||
// DEMO_STEPS, a JSON list, then drives it (the measurements for §9):
|
||||
// {"launch": "ID"} the launcher's item ID (download + overlay + run)
|
||||
// {"install": "ID"} only its overlay, timed
|
||||
// {"run": "CMD", "label": "L"} CMD in pid 1's session, timed in the guest
|
||||
// ($EPOCHREALTIME); waits for it to end
|
||||
// {"windows": N} wait for N mapped windows
|
||||
// {"hashes": N, "every": MS} N readouts of the output MS apart with no
|
||||
// input; "expect": "change" fails if all equal
|
||||
// {"cursor": "none|default|image"} the canvas's CSS cursor after a pointer move
|
||||
// onto the focused window (no composited one)
|
||||
// {"move": [X, Y]} the pointer to CSS pixel X, Y
|
||||
// {"click": [X, Y]} a left click there
|
||||
// {"keys": ["Escape", ...]} named keys (KeyboardEvent.code)
|
||||
// {"truth": true, "rounds": N} type into the xterm, then compare the X server's
|
||||
// copy of it (xwd) with the compositor's output
|
||||
// (needs the python and xeyes overlays)
|
||||
// A run step with "show": true prints the command's output tail even on success.
|
||||
// Env: CHROME, DEMO_WALL (s, default 300), DEMO_WEBGPU=sw (SwiftShader),
|
||||
// DEMO_QUERY (appended to the URL), DEMO_PROFILE (a profile dir to keep),
|
||||
// DEMO_DPR (--force-device-scale-factor), DEMO_CONSOLE (a file for every
|
||||
// console line), DEMO_CHROME_FLAGS (more browser flags, space-separated).
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { createReadStream, existsSync, statSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join, extname, resolve, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Cdp, browserWsUrl } from '../../web/test/cdp.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const DIST = resolve(process.argv[2] || join(HERE, '../../web/dist-demo'));
|
||||
const CHROME = process.env.CHROME || '/usr/bin/chromium';
|
||||
const WALL = Number(process.env.DEMO_WALL || 300) * 1000;
|
||||
const STEPS = JSON.parse(process.env.DEMO_STEPS || '[]');
|
||||
const t0 = Date.now();
|
||||
const log = (s) => console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${s}`);
|
||||
|
||||
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
|
||||
'.wasm': 'application/wasm', '.json': 'application/json', '.zst': 'application/zstd' };
|
||||
let served = 0;
|
||||
const server = createServer((req, res) => {
|
||||
let p = join(DIST, decodeURIComponent(new URL(req.url, 'http://x').pathname));
|
||||
if (!p.startsWith(DIST)) return res.writeHead(403).end();
|
||||
if (existsSync(p) && statSync(p).isDirectory()) p = join(p, 'index.html');
|
||||
if (!existsSync(p)) p = join(DIST, 'index.html');
|
||||
const size = statSync(p).size;
|
||||
served += size;
|
||||
res.writeHead(200, { 'Content-Type': TYPES[extname(p)] || 'application/octet-stream', 'Content-Length': size,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Resource-Policy': 'same-origin', 'Cache-Control': 'no-cache' });
|
||||
createReadStream(p).pipe(res);
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const url = `http://127.0.0.1:${server.address().port}/${process.env.DEMO_QUERY ? `?${process.env.DEMO_QUERY}` : ''}`;
|
||||
const prof = process.env.DEMO_PROFILE || mkdtempSync(join(tmpdir(), 'gex-desktop-'));
|
||||
const port = 9400 + Math.floor(Math.random() * 500);
|
||||
const sw = process.env.DEMO_WEBGPU === 'sw';
|
||||
const chrome = spawn(CHROME, [
|
||||
// DEMO_HEADED=1: not headless mode but the full browser on Ozone's headless
|
||||
// platform -- the display compositor a person's browser has, whose
|
||||
// screenshots hold the WebGPU canvas (headless mode's do not).
|
||||
...(process.env.DEMO_HEADED ? ['--ozone-platform=headless'] : ['--headless']), '--no-sandbox', '--disable-gpu-sandbox', '--enable-unsafe-webgpu',
|
||||
...(sw ? ['--use-angle=swiftshader-webgl', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader']
|
||||
: ['--use-angle=vulkan', '--enable-features=Vulkan']),
|
||||
...(process.env.DEMO_DPR ? [`--force-device-scale-factor=${process.env.DEMO_DPR}`] : []),
|
||||
...(process.env.DEMO_CHROME_FLAGS ? process.env.DEMO_CHROME_FLAGS.split(/\s+/).filter(Boolean) : []),
|
||||
'--window-size=1280,800', '--enable-logging=stderr', '--v=0',
|
||||
`--remote-debugging-port=${port}`, '--remote-allow-origins=*', `--user-data-dir=${prof}`, 'about:blank',
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
chrome.stderr.on('data', (d) => (stderr += d));
|
||||
const cleanup = () => {
|
||||
try { chrome.kill('SIGKILL'); } catch {}
|
||||
if (!process.env.DEMO_PROFILE) try { rmSync(prof, { recursive: true, force: true }); } catch {}
|
||||
};
|
||||
process.on('exit', cleanup);
|
||||
const wall = setTimeout(() => {
|
||||
log(`FAIL wall of ${WALL / 1000}s`);
|
||||
dump();
|
||||
process.exit(1);
|
||||
}, WALL);
|
||||
|
||||
let ws = null;
|
||||
for (let i = 0; i < 100 && !ws; i++) {
|
||||
ws = await browserWsUrl(port);
|
||||
if (!ws) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!ws) throw new Error('no browser');
|
||||
const b = await Cdp.connect(ws);
|
||||
let page = null;
|
||||
for (let i = 0; i < 50 && !page; i++) {
|
||||
const { targetInfos } = await b.send('Target.getTargets');
|
||||
page = targetInfos.find((t) => t.type === 'page');
|
||||
if (!page) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!page) page = { targetId: (await b.send('Target.createTarget', { url: 'about:blank' })).targetId };
|
||||
const { sessionId: s } = await b.send('Target.attachToTarget', { targetId: page.targetId, flatten: true });
|
||||
const consoleLines = [];
|
||||
b.on('Runtime.consoleAPICalled', (p) => {
|
||||
const line = p.args.map((a) => a.value ?? a.description ?? '').join(' ');
|
||||
consoleLines.push(line);
|
||||
if (/\[desktop\]|\[hostx-count\]|error|Error/.test(line) && p.type !== 'debug') log(`console: ${line.slice(0, 300)}`);
|
||||
});
|
||||
b.on('Runtime.exceptionThrown', (p) => log(`exception: ${p.exceptionDetails?.exception?.description?.slice(0, 400)}`));
|
||||
await b.send('Runtime.enable', {}, s);
|
||||
await b.send('Page.enable', {}, s);
|
||||
const ev = async (expr) => (await b.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }, s)).result?.value;
|
||||
const until = async (expr, what, ms = WALL) => {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const v = await ev(expr).catch(() => undefined);
|
||||
if (v) return v;
|
||||
const err = await ev('globalThis.gexDesktop?.error').catch(() => undefined);
|
||||
if (err) throw new Error(`the page failed: ${err}`);
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
};
|
||||
async function shot(path, after) {
|
||||
await new Promise((r) => setTimeout(r, after));
|
||||
const { data } = await b.send('Page.captureScreenshot', { format: 'png' }, s);
|
||||
const fs = await import('node:fs');
|
||||
fs.writeFileSync(path, Buffer.from(data, 'base64'));
|
||||
// And the compositor's own output, read back: a headless screenshot may
|
||||
// not hold a WebGPU canvas.
|
||||
const r = await ev('globalThis.__gexCompositor?.readoutBase64?.()');
|
||||
if (r) fs.writeFileSync(path.replace(/\.png$/, '.out.png'), png(r.w, r.h, Buffer.from(r.b64, 'base64')));
|
||||
log(`screenshot ${path}${r ? ` and the compositor's ${r.w}x${r.h} output` : ''}`);
|
||||
}
|
||||
function png(w, h, rgba) {
|
||||
const zlib = globalThis.process.getBuiltinModule('node:zlib');
|
||||
const crcT = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
||||
const crc = (buf) => { let c = 0xffffffff; for (const x of buf) c = crcT[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
||||
const chunk = (type, data) => { const l = Buffer.alloc(4); l.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([l, td, c]); };
|
||||
const raw = Buffer.alloc((w * 4 + 1) * h);
|
||||
for (let y = 0; y < h; y++) rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
|
||||
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6;
|
||||
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
/** A character's `KeyboardEvent.code`, and whether it needs Shift (US layout). */
|
||||
function keyCode(ch) {
|
||||
if (/[a-z]/.test(ch)) return [`Key${ch.toUpperCase()}`, false];
|
||||
if (/[A-Z]/.test(ch)) return [`Key${ch}`, true];
|
||||
if (/[0-9]/.test(ch)) return [`Digit${ch}`, false];
|
||||
const plain = { ' ': 'Space', '-': 'Minus', '/': 'Slash', '.': 'Period', '=': 'Equal', ';': 'Semicolon',
|
||||
',': 'Comma', "'": 'Quote', '`': 'Backquote', '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash' };
|
||||
if (plain[ch]) return [plain[ch], false];
|
||||
const shifted = { '|': 'Backslash', '_': 'Minus', '+': 'Equal', ':': 'Semicolon', '"': 'Quote', '~': 'Backquote',
|
||||
'!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', '%': 'Digit5', '^': 'Digit6', '&': 'Digit7',
|
||||
'*': 'Digit8', '(': 'Digit9', ')': 'Digit0', '<': 'Comma', '>': 'Period', '?': 'Slash', '{': 'BracketLeft', '}': 'BracketRight' };
|
||||
return [shifted[ch] ?? '', !!shifted[ch]];
|
||||
}
|
||||
/** The first window's centre and an empty spot of desktop, in CSS pixels. */
|
||||
async function places() {
|
||||
return ev(`(() => {
|
||||
const c = document.getElementById('out').getBoundingClientRect();
|
||||
const k = c.width / document.getElementById('out').width || 1;
|
||||
return globalThis.__gexCompositor.describe().then((d) => {
|
||||
const m = /role=1 (\\d+)x(\\d+)@(-?\\d+),(-?\\d+)/.exec(d.list.join(' '));
|
||||
if (!m) return null;
|
||||
const [w, h, x, y] = m.slice(1).map(Number);
|
||||
return { win: [c.left + (x + w / 2) * k, c.top + (y + h / 2) * k],
|
||||
desk: [c.right - 40, c.bottom - 40], k };
|
||||
});
|
||||
})()`);
|
||||
}
|
||||
/** The page's screenshot, hashed: what the browser displays, where its
|
||||
* screenshots can see a worker's WebGPU canvas (headless ones cannot). */
|
||||
async function screenHash() {
|
||||
const { data } = await b.send('Page.captureScreenshot', { format: 'png' }, s);
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < data.length; i++) h = Math.imul(h ^ data.charCodeAt(i), 16777619) >>> 0;
|
||||
return h;
|
||||
}
|
||||
/** What the page's canvas holds now: the placeholder's last frame, drawn
|
||||
* into a 2D canvas on the page and hashed. A canvas handed to a worker shows
|
||||
* what the worker last committed; this reads that, not the worker's scene. */
|
||||
async function canvasHash() {
|
||||
return ev(`(() => {
|
||||
const c = document.getElementById('out');
|
||||
const t = new OffscreenCanvas(c.width, c.height);
|
||||
const g = t.getContext('2d', { willReadFrequently: true });
|
||||
g.drawImage(c, 0, 0);
|
||||
const d = g.getImageData(0, 0, c.width, c.height).data;
|
||||
let h = 2166136261, lit = 0;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
h = Math.imul(h ^ d[i] ^ (d[i + 1] << 8) ^ (d[i + 2] << 16), 16777619) >>> 0;
|
||||
if (d[i] + d[i + 1] + d[i + 2] > 600) lit++;
|
||||
}
|
||||
return h + ':' + lit;
|
||||
})()`);
|
||||
}
|
||||
/** One key into the focused window, as the page gets it from a person. */
|
||||
async function typeKey(ch) {
|
||||
if (ch === '\n') {
|
||||
const k = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 };
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k, text: '\r' }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k }, s);
|
||||
return;
|
||||
}
|
||||
const [code, shift] = keyCode(ch);
|
||||
const m = shift ? 8 : 0;
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, code, modifiers: m }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, modifiers: m }, s);
|
||||
}
|
||||
async function click(x, y) {
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, s);
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, s);
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, s);
|
||||
}
|
||||
/** A command in pid 1's session (not the xterm's); its output's lines. */
|
||||
async function guestRun(cmd, wall = 120000) {
|
||||
const tag = `g${Math.floor(Math.random() * 1e9)}`;
|
||||
await ev(`globalThis.gexDesktop.run(${JSON.stringify(`{ ${cmd} ; } >/tmp/${tag}.out 2>&1; echo "${tag} rc=$?"; cat /tmp/${tag}.out; echo "${tag} end"`)})`);
|
||||
await until(`globalThis.gexTerminalText().includes(${JSON.stringify(`${tag} end`)})`, cmd.slice(0, 40), wall);
|
||||
const t = (await ev('globalThis.gexTerminalText()')) || '';
|
||||
const body = t.slice(t.search(new RegExp(`${tag} rc=`)));
|
||||
const rc = Number(/rc=(\d+)/.exec(body)[1]);
|
||||
return { rc, lines: body.split('\n').slice(1).filter((l) => !l.includes(`${tag} end`)) };
|
||||
}
|
||||
const SIG_COLS = 16, SIG_ROWS = 8, SIG_EDGE = 2;
|
||||
/** The same signature as tests/demo/guest/xsig.py, of what the compositor
|
||||
* composed where the window is. */
|
||||
async function composedSig(x, y, w, h) {
|
||||
const r = await ev('globalThis.__gexCompositor.readoutBase64()');
|
||||
const px = Buffer.from(r.b64, 'base64');
|
||||
const cells = new Array(SIG_COLS * SIG_ROWS).fill(0);
|
||||
let exact = 2166136261;
|
||||
for (let j = 0; j < h; j++) {
|
||||
const cy = Math.floor(j * SIG_ROWS / h) * SIG_COLS;
|
||||
for (let i = 0; i < w; i++) {
|
||||
const o = ((y + j) * r.w + (x + i)) * 4;
|
||||
const dark = Math.max(px[o], px[o + 1], px[o + 2]) < 128 ? 1 : 0;
|
||||
if (dark) cells[cy + Math.floor(i * SIG_COLS / w)]++;
|
||||
exact = Math.imul(exact ^ dark, 16777619) >>> 0;
|
||||
}
|
||||
}
|
||||
cells.exact = exact;
|
||||
return cells;
|
||||
}
|
||||
/**
|
||||
* Bug 3 as a leg: what the xterm drew is what the page shows. Type into the
|
||||
* focused xterm at a person's pace -- each key a frame, so Xwayland draws into
|
||||
* the buffers it already has rather than making new ones -- then leave it
|
||||
* alone, and compare the X server's own copy of the window (xwd, in the guest)
|
||||
* with the compositor's output where the window is.
|
||||
*/
|
||||
async function truth(step) {
|
||||
const script = (await import('node:fs')).readFileSync(new URL('./guest/xsig.py', import.meta.url)).toString('base64');
|
||||
const put = await guestRun(`echo ${script} | base64 -d > /tmp/xsig.py`);
|
||||
if (put.rc) throw new Error('could not write /tmp/xsig.py');
|
||||
const p = await places();
|
||||
await click(...p.win);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
let bad = 0;
|
||||
const rounds = step.rounds ?? 4;
|
||||
for (let n = 0; n < rounds; n++) {
|
||||
for (const ch of `echo round ${n} the quick brown fox ${'x'.repeat(n * 3)}\n`) {
|
||||
await typeKey(ch);
|
||||
await new Promise((r) => setTimeout(r, step.pace ?? 60));
|
||||
}
|
||||
// The pointer across the window's edge and back: the X server makes a
|
||||
// cursor pool for each shape it shows, between the xterm's frames.
|
||||
for (let i = 0; i < (step.wiggle ?? 3); i++) {
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.desk[0], y: p.desk[1] }, s);
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.win[0], y: p.win[1] }, s);
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
await typeKey(String.fromCharCode(97 + i));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, step.settle ?? 2000));
|
||||
const d = await ev('globalThis.__gexCompositor.describe()');
|
||||
const line = d.list.find((l) => / role=1 /.test(l));
|
||||
const [, w, h, x, y] = /(\d+)x(\d+)@(-?\d+),(-?\d+)/.exec(line).map(Number);
|
||||
const title = d.titles[0];
|
||||
const got = await guestRun(`python3.13 /tmp/xsig.py ${JSON.stringify(title)}`);
|
||||
const size = got.lines.find((l) => l.startsWith('xsize'));
|
||||
if (got.rc || !size) throw new Error(`xsig: rc=${got.rc} ${got.lines.slice(0, 4).join(' | ')}`);
|
||||
const drawn = [];
|
||||
for (let r = 0; r < SIG_ROWS; r++) {
|
||||
const l = got.lines.find((v) => v.startsWith(`xs${r} `));
|
||||
drawn.push(...l.slice(l.indexOf(' ') + 1).split(',').map(Number));
|
||||
}
|
||||
// The X window inside the surface: Xwayland's buffer carries the
|
||||
// window's border, which xwd leaves out -- so the best of a few offsets.
|
||||
const [xw, xh, xexact] = size.split(' ').slice(1).map(Number);
|
||||
let best = null;
|
||||
for (const [dx, dy] of [[0, 0], [1, 1], [-1, -1], [1, 0], [0, 1], [2, 2]]) {
|
||||
const sig = await composedSig(x + dx + SIG_EDGE, y + dy + SIG_EDGE, xw - 2 * SIG_EDGE, xh - 2 * SIG_EDGE);
|
||||
const d = drawn.reduce((a, v, i) => a + Math.abs(v - sig[i]), 0) + (sig.exact === xexact ? 0 : 0.5);
|
||||
if (!best || d < best.d) best = { d, sig, at: `${dx},${dy}` };
|
||||
if (!d) break;
|
||||
}
|
||||
const shown = best.sig;
|
||||
const diff = best.d;
|
||||
const cells = drawn.filter((v, i) => v !== shown[i]).length;
|
||||
// Equal counts and an equal bitmap: a cursor block one character along
|
||||
// stays in its cell, and only the bitmap tells.
|
||||
const ok = cells === 0 && shown.exact === xexact;
|
||||
if (!ok) bad++;
|
||||
if (!ok && process.env.DEMO_SIG) {
|
||||
for (let r = 0; r < SIG_ROWS; r++) {
|
||||
log(` X ${drawn.slice(r * SIG_COLS, (r + 1) * SIG_COLS).join(',')}`);
|
||||
log(` C ${shown.slice(r * SIG_COLS, (r + 1) * SIG_COLS).join(',')}`);
|
||||
}
|
||||
}
|
||||
log(` round ${n}: X window ${size.split(' ').slice(1).join('x')} vs composed ${w}x${h}@${x},${y}: `
|
||||
+ `${ok ? 'same' : `${cells} of ${drawn.length} cells differ (${Math.floor(diff)} dark pixels), bitmaps ${shown.exact === xexact ? 'equal' : 'differ'}`} at +${best.at} (serial ${/serial=(\d+)/.exec(line)[1]})`);
|
||||
}
|
||||
if (bad) failed = true;
|
||||
log(`${bad ? 'FAIL' : 'ok'} xterm shows what it drew: ${rounds - bad} of ${rounds} rounds`);
|
||||
}
|
||||
async function legs() {
|
||||
const p = await places();
|
||||
if (!p) throw new Error('no window to point at');
|
||||
const cursorAt = async ([x, y], want) => {
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, s);
|
||||
let css = '';
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
css = (await ev("getComputedStyle(document.getElementById('out')).cursor")) || '';
|
||||
if (want.test(css)) break;
|
||||
}
|
||||
return css;
|
||||
};
|
||||
const overWin = await cursorAt(p.win, /url\(|image-set/);
|
||||
const overDesk = await cursorAt(p.desk, /^default$/);
|
||||
const okCursor = /url\(|image-set/.test(overWin) && overDesk === 'default';
|
||||
if (!okCursor) failed = true;
|
||||
log(`${okCursor ? 'ok' : 'FAIL'} cursor leg: over the xterm ${JSON.stringify(overWin.slice(0, 60))}, over the desktop ${JSON.stringify(overDesk)}`);
|
||||
// Back over the xterm, focused, and a loop that prints with no input.
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.win[0], y: p.win[1] }, s);
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.win[0], y: p.win[1], button: 'left', clickCount: 1 }, s);
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: p.win[0], y: p.win[1], button: 'left', clickCount: 1 }, s);
|
||||
// The shell may still be starting: wait for its prompt to draw.
|
||||
let prev = await canvasHash();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const h = await canvasHash();
|
||||
if (h === prev) break;
|
||||
prev = h;
|
||||
}
|
||||
for (const ch of 'for i in 1 2 3 4 5; do sleep 1; echo tick$i; done\n') {
|
||||
if (ch === '\n') {
|
||||
const k = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 };
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k, text: '\r' }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k }, s);
|
||||
continue;
|
||||
}
|
||||
const [code, shift] = keyCode(ch);
|
||||
const m = shift ? 8 : 0;
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, code, modifiers: m }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, modifiers: m }, s);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const d0 = await ev('globalThis.__gexCompositor.describe()');
|
||||
// What the page's canvas holds (its last committed frame, drawn into a 2D
|
||||
// canvas), not a screenshot: headless Chromium's screenshots never contain
|
||||
// a WebGPU canvas a worker draws, so they cannot tell shown from not.
|
||||
const held = [await canvasHash()];
|
||||
const shown = [await screenHash()];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
held.push(await canvasHash());
|
||||
shown.push(await screenHash());
|
||||
}
|
||||
const shots = shown.slice(1).filter((h, i) => h !== shown[i]).length;
|
||||
const d1 = await ev('globalThis.__gexCompositor.describe()');
|
||||
const changes = held.slice(1).filter((h, i) => h !== held[i]).length;
|
||||
const okShown = changes >= 3;
|
||||
if (!okShown) failed = true;
|
||||
log(`${okShown ? 'ok' : 'FAIL'} presentation leg: the page's canvas changed ${changes} times in 4 s of the xterm printing with no input`
|
||||
+ ` (compositor: ${d1.frames - d0.frames} frames drawn in ${d1.turns - d0.turns} turns; screenshots changed ${shots} times)`);
|
||||
// What the xterm drew is what the page shows (bug 3): xwd and python come
|
||||
// as overlays, the way a person adds them from the launcher.
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
for (const id of ['python', 'xeyes']) await ev(`globalThis.gexDesktop.install(${JSON.stringify(id)})`);
|
||||
await truth({ rounds: 3 });
|
||||
}
|
||||
function dump() {
|
||||
ev('globalThis.gexTerminalText?.()').then((t) => {
|
||||
console.log('--- console (pid 1) ---');
|
||||
console.log((t || '').trim().split('\n').slice(-40).join('\n'));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
try {
|
||||
log(`open ${url}`);
|
||||
await b.send('Page.navigate', { url }, s);
|
||||
await until('globalThis.gexDesktop?.ready', 'the desktop');
|
||||
const marks = await ev('globalThis.gexDesktop.marks');
|
||||
const rootfs = await ev('globalThis.gexDesktop.rootfs');
|
||||
const desc = await ev('globalThis.__gexCompositor?.describe?.()');
|
||||
const launcherShown = await ev("!document.getElementById('launcher').classList.contains('hidden')");
|
||||
log(`ok desktop: ${JSON.stringify(marks)}; served ${(served / 1048576).toFixed(2)} MiB; ` +
|
||||
`rootfs ${JSON.stringify(rootfs)}; launcher ${launcherShown ? 'open' : 'MISSING'}`);
|
||||
if (desc) log(`compositor: ${JSON.stringify(desc).slice(0, 400)}`);
|
||||
if (!launcherShown) throw new Error('the launcher window is not open');
|
||||
if (process.env.DEMO_SHOT) await shot(process.env.DEMO_SHOT, 1500);
|
||||
// The legs every run has (DEMO_LEGS=0 skips them): the xterm's cursor is
|
||||
// the page's CSS cursor and none is composited; what the xterm prints with
|
||||
// no input reaches the page's canvas; what it draws as it is typed into is
|
||||
// what the compositor shows (the X server's copy against the output).
|
||||
if (process.env.DEMO_LEGS !== '0') await legs();
|
||||
let n = 0;
|
||||
let lastHash = null;
|
||||
for (const step of STEPS) {
|
||||
n++;
|
||||
if (step.windows) {
|
||||
try {
|
||||
await until(`(globalThis.__gexCompositor?.counters?.().windows ?? 0) >= ${step.windows}`, `${step.windows} windows`,
|
||||
(step.wall ?? WALL / 1000) * 1000);
|
||||
log(`ok ${step.windows} windows`);
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
log(`FAIL ${e.message}`);
|
||||
if (!step.soft) throw e;
|
||||
}
|
||||
} else if (step.hashes) {
|
||||
// Counted with the last readout of the step before, so a change that
|
||||
// lands before this step's first readout still counts. "screen": the
|
||||
// page's own screenshot (what is presented), not the compositor's readout.
|
||||
const seen = lastHash === null ? [] : [lastHash];
|
||||
for (let i = 0; i < step.hashes; i++) {
|
||||
await new Promise((r) => setTimeout(r, step.every ?? 1000));
|
||||
if (step.screen) {
|
||||
const { data } = await b.send('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 40, width: 600, height: 400, scale: 1 } }, s);
|
||||
let h = 2166136261;
|
||||
for (let k = 0; k < data.length; k++) h = ((h ^ data.charCodeAt(k)) * 16777619) >>> 0;
|
||||
seen.push(h);
|
||||
} else {
|
||||
const d = await ev('globalThis.__gexCompositor?.describe?.()');
|
||||
seen.push(d?.hash ?? 0);
|
||||
}
|
||||
}
|
||||
lastHash = seen[seen.length - 1];
|
||||
const distinct = new Set(seen).size;
|
||||
const ok = step.expect !== 'change' || distinct > 1;
|
||||
if (!ok) failed = true;
|
||||
log(`${ok ? 'ok' : 'FAIL'} ${step.label ?? 'hashes'}: ${distinct} distinct of ${seen.length} readouts ${step.every ?? 1000} ms apart`);
|
||||
} else if (step.click) {
|
||||
const [x, y] = step.click;
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, s);
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, s);
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, s);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
log(`clicked ${step.click}`);
|
||||
} else if (step.move) {
|
||||
await b.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: step.move[0], y: step.move[1] }, s);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
log(`moved to ${step.move}`);
|
||||
} else if (step.cursor) {
|
||||
await new Promise((r) => setTimeout(r, step.after ?? 1500));
|
||||
const css = (await ev("getComputedStyle(document.getElementById('out')).cursor")) || '';
|
||||
const kind = css.startsWith('url(') || css.includes('image-set') ? 'image' : css;
|
||||
const ok = kind === step.cursor;
|
||||
if (!ok) failed = true;
|
||||
log(`${ok ? 'ok' : 'FAIL'} cursor ${JSON.stringify(css.slice(0, 80))} (want ${step.cursor})`);
|
||||
} else if (step.truth) {
|
||||
await truth(step);
|
||||
} else if (step.keys) {
|
||||
await ev("document.getElementById('out').focus()");
|
||||
for (const code of step.keys) {
|
||||
const key = code === 'Escape' ? 'Escape' : code === 'Enter' ? 'Enter' : code;
|
||||
const vk = { Escape: 27, Enter: 13 }[code] ?? 0;
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: vk, ...(code === 'Enter' ? { text: '\r' } : {}) }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: vk }, s);
|
||||
}
|
||||
log(`keys ${step.keys.join(' ')}`);
|
||||
} else if (step.shot) {
|
||||
await shot(step.shot, step.after ?? 1500);
|
||||
} else if (step.type) {
|
||||
// Keys as a person types them, into whatever the compositor focuses.
|
||||
await ev("document.getElementById('out').focus()");
|
||||
for (const ch of step.type) {
|
||||
if (ch === '\n') {
|
||||
const k = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 };
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...k, text: '\r' }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...k }, s);
|
||||
} else {
|
||||
const [code, shift] = keyCode(ch);
|
||||
const sh = { key: 'Shift', code: 'ShiftLeft', windowsVirtualKeyCode: 16 };
|
||||
if (shift) await b.send('Input.dispatchKeyEvent', { type: 'keyDown', ...sh, modifiers: 8 }, s);
|
||||
const m = shift ? 8 : 0;
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyDown', key: ch, text: ch, code, modifiers: m }, s);
|
||||
await b.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch, code, modifiers: m }, s);
|
||||
if (shift) await b.send('Input.dispatchKeyEvent', { type: 'keyUp', ...sh }, s);
|
||||
}
|
||||
}
|
||||
log(`typed ${JSON.stringify(step.type)}`);
|
||||
} else if (step.install || step.launch) {
|
||||
const id = step.install || step.launch;
|
||||
const a = Date.now();
|
||||
if (step.install) await ev(`globalThis.gexDesktop.install(${JSON.stringify(id)})`);
|
||||
else await ev(`globalThis.gexDesktop.launch(${JSON.stringify(id)})`);
|
||||
const r = await ev(`globalThis.gexDesktop.installs?.[${JSON.stringify(id)}]`);
|
||||
log(`ok ${step.install ? 'install' : 'launch'} ${id}: ${Date.now() - a} ms; overlay ${JSON.stringify(r)}`);
|
||||
} else if (step.run) {
|
||||
const tag = `S${n}x${Math.floor(Math.random() * 1e6)}`;
|
||||
const cmd = `s=$EPOCHREALTIME; { ${step.run} ; } >/tmp/${tag}.out 2>&1; r=$?; e=$EPOCHREALTIME; ` +
|
||||
`printf "\\r\\n"; echo "${tag.toLowerCase()} rc=$r $(( (\${e/./}-\${s/./})/1000 ))ms"; tail -${process.env.DEMO_TAIL || 5} /tmp/${tag}.out`;
|
||||
await ev(`globalThis.gexDesktop.run(${JSON.stringify(cmd)})`);
|
||||
const re = new RegExp(`${tag.toLowerCase()} rc=(\\d+) (\\d+)ms`, 'm');
|
||||
const got = await until(`(() => { const m = /${re.source}/m.exec(globalThis.gexTerminalText()); return m && [m[1], m[2]]; })()`,
|
||||
`"${step.label ?? step.run}"`, (step.wall ?? 600) * 1000);
|
||||
log(`${got[0] === '0' ? 'ok' : 'FAIL'} run ${step.label ?? step.run}: rc=${got[0]} ${got[1]} ms (guest clock)`);
|
||||
if (got[0] !== '0' || step.show) {
|
||||
if (got[0] !== '0') failed = true;
|
||||
// The command's last lines, which the session prints after the result.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const t = (await ev('globalThis.gexTerminalText()')) || '';
|
||||
const at = t.search(re);
|
||||
for (const l of t.slice(at).split('\n').slice(1, Number(process.env.DEMO_TAIL || 6))) if (l.trim()) log(` | ${l}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
log(`FAIL ${e.message}`);
|
||||
await new Promise((r) => { dump(); setTimeout(r, 500); });
|
||||
}
|
||||
clearTimeout(wall);
|
||||
// DEMO_CONSOLE=PATH: every console line the page logged, for a trace.
|
||||
if (process.env.DEMO_CONSOLE) (await import('node:fs')).writeFileSync(process.env.DEMO_CONSOLE, consoleLines.join('\n'));
|
||||
const errs = stderr.split('\n').filter((l) => /Uncaught|RuntimeError|\[gex-console-error\]/.test(l));
|
||||
for (const l of errs.slice(0, 10)) log(`stderr: ${l.slice(0, 300)}`);
|
||||
b.close();
|
||||
server.close();
|
||||
cleanup();
|
||||
process.exit(failed ? 1 : 0);
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# The guest-side legs of the demo (docs/gexcc.md §9), in the page, through
|
||||
# tests/demo/desktop.mjs: /proc lists every process (ps, htop's source), a
|
||||
# pty in raw mode reads keys, nvim takes typed keys, an epoll set survives
|
||||
# fork, ttyname and ps's TTY column. Run on edger:
|
||||
#
|
||||
# scratch/bench/bench.sh -s $PWD/web/dist-demo -s $PWD/tests -- \
|
||||
# bash tests/demo/guest.sh [DIST]
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
DIST=${1:-web/dist-demo}
|
||||
b64() { base64 -w0 "tests/demo/guest/$1"; }
|
||||
STEPS=$(cat <<JSON
|
||||
[
|
||||
{"install":"python"},{"install":"nvim"},{"install":"htop"},
|
||||
{"run":"n=\$(ls /proc | grep -c '^[0-9][0-9]*\$'); echo \$n pids; ps ax | head -5; test \$n -ge 3 && ps ax | grep -q '[x]term'","label":"procfs lists every process","show":true,"wall":60},
|
||||
{"run":"echo $(b64 tty.py) | base64 -d > /tmp/guest-tty.py; python3.13 /tmp/guest-tty.py","label":"terminals and fork","show":true,"wall":120},
|
||||
{"run":"echo $(b64 nvim.py) | base64 -d > /tmp/guest-nvim.py; python3.13 /tmp/guest-nvim.py","label":"nvim on a pty","show":true,"wall":120}
|
||||
]
|
||||
JSON
|
||||
)
|
||||
DEMO_LEGS=${DEMO_LEGS:-0} DEMO_WALL=${DEMO_WALL:-600} DEMO_TAIL=${DEMO_TAIL:-20} DEMO_STEPS="$STEPS" \
|
||||
taskset -c "${BENCH_CORES:-2,4,6,8}" node tests/demo/desktop.mjs "$DIST"
|
||||
@@ -0,0 +1,40 @@
|
||||
# Keys typed into nvim on a pty reach it (tests/demo/guest.sh): libuv opens
|
||||
# the terminal non-blocking (FIONBIO), puts it in raw mode and reads it.
|
||||
import os, pty, select, sys, time
|
||||
|
||||
path = '/tmp/guest-nvim.txt'
|
||||
try:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
pid, m = pty.fork()
|
||||
if pid == 0:
|
||||
os.environ['TERM'] = 'xterm-256color'
|
||||
os.execvp('nvim', ['nvim', '--clean', path])
|
||||
out = b''
|
||||
|
||||
def drain(sec):
|
||||
global out
|
||||
end = time.time() + sec
|
||||
while time.time() < end:
|
||||
r, _, _ = select.select([m], [], [], 0.2)
|
||||
if r:
|
||||
try:
|
||||
out += os.read(m, 65536)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
drain(15)
|
||||
os.write(m, b'ihello')
|
||||
drain(3)
|
||||
os.write(m, b'\x1b')
|
||||
drain(2)
|
||||
os.write(m, b':wq\r')
|
||||
drain(8)
|
||||
try:
|
||||
got = open(path).read()
|
||||
except OSError as e:
|
||||
got = repr(e)
|
||||
ok = got == 'hello\n'
|
||||
print(f"{'ok' if ok else 'FAIL'} nvim took typed keys: {got!r} (drew {len(out)} bytes)", flush=True)
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -0,0 +1,72 @@
|
||||
# Terminals and fork, in the page (tests/demo/guest.sh). Exits non-zero on
|
||||
# the first thing that is not as Linux has it.
|
||||
import os, pty, select, time, sys
|
||||
|
||||
def check(what, ok, got):
|
||||
print(f"{'ok' if ok else 'FAIL'} {what}: {got}", flush=True)
|
||||
if not ok:
|
||||
sys.exit(1)
|
||||
|
||||
# An epoll set made before fork, waited on by the child.
|
||||
r, w = os.pipe()
|
||||
e = select.epoll()
|
||||
e.register(r, select.EPOLLIN)
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
try:
|
||||
ev = e.poll(10)
|
||||
res = f'{ev} {os.read(r, 10)!r}'
|
||||
e.close()
|
||||
except BaseException as ex:
|
||||
res = f'error {ex!r}'
|
||||
open('/tmp/tty-epoll.txt', 'w').write(res)
|
||||
os._exit(0)
|
||||
time.sleep(1)
|
||||
os.write(w, b'ping')
|
||||
os.waitpid(pid, 0)
|
||||
got = open('/tmp/tty-epoll.txt').read()
|
||||
check('epoll set inherited across fork', got == f"[({r}, 1)] b'ping'", got)
|
||||
|
||||
# A raw slave read, the way a full-screen program reads keys.
|
||||
import tty
|
||||
m, s = pty.openpty()
|
||||
tty.setraw(s)
|
||||
os.write(m, b'k')
|
||||
check('raw pty read', os.read(s, 4) == b'k', 'k')
|
||||
|
||||
# ttyname, /dev/pts and /proc/self/fd agree.
|
||||
name = os.ttyname(s)
|
||||
check('ttyname', name.startswith('/dev/pts/'), name)
|
||||
check('/dev/pts lists it', name.rsplit('/', 1)[1] in os.listdir('/dev/pts'), sorted(os.listdir('/dev/pts')))
|
||||
check('/proc/self/fd names it', os.readlink(f'/proc/self/fd/{s}') == name, os.readlink(f'/proc/self/fd/{s}'))
|
||||
|
||||
# A shell on a pty: tty(1), and ps's TTY column from /proc/<pid>/stat.
|
||||
pid, mfd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvp('sh', ['sh', '-c', 'tty; ps -o tty=,comm='])
|
||||
out = b''
|
||||
end = time.time() + 20
|
||||
while time.time() < end and out.count(b'\n') < 3:
|
||||
rr, _, _ = select.select([mfd], [], [], 0.3)
|
||||
if rr:
|
||||
try:
|
||||
out += os.read(mfd, 65536)
|
||||
except OSError:
|
||||
break
|
||||
# Then the shell exits, and the master reads EIO (not end-of-file, not
|
||||
# waiting): before waitpid, while the shell is a zombie, as on Linux.
|
||||
t0 = time.time()
|
||||
ended = None
|
||||
while time.time() - t0 < 15 and ended is None:
|
||||
rr, _, _ = select.select([mfd], [], [], 0.3)
|
||||
if rr:
|
||||
try:
|
||||
if not os.read(mfd, 65536):
|
||||
ended = 'end-of-file'
|
||||
except OSError as ex:
|
||||
ended = f'errno {ex.errno}'
|
||||
os.waitpid(pid, 0)
|
||||
check('the master reads EIO once the shell is gone', ended == 'errno 5', f'{ended} after {time.time() - t0:.1f} s')
|
||||
lines = out.decode(errors='replace').split()
|
||||
check('tty(1) on a pty', bool(lines) and lines[0].startswith('/dev/pts/'), lines[:1])
|
||||
check("ps's TTY column", 'pts/' in out.decode(errors='replace').split('\n', 1)[1], out.decode(errors='replace').strip().replace('\r', ''))
|
||||
@@ -0,0 +1,37 @@
|
||||
# What an X window holds, from the X server: `xwd` of the window named argv[1],
|
||||
# reduced to a signature tests/demo/desktop.mjs computes the same way from what
|
||||
# the compositor composed -- per cell of a COLSxROWS grid, how many pixels are
|
||||
# dark. Prints "xsize W H HASH" (the hash of the whole dark bitmap), then each row of cells as "xsR n,n,...".
|
||||
import struct, subprocess, sys
|
||||
|
||||
COLS, ROWS = 16, 8
|
||||
title = sys.argv[1]
|
||||
d = subprocess.run(['xwd', '-name', title, '-silent', '-nobdrs'], capture_output=True, check=True).stdout
|
||||
h = struct.unpack('>25I', d[:100])
|
||||
(size, _ver, _fmt, _depth, w, ht, _xoff, order, _unit, _bo, _pad, bpp, bpl,
|
||||
_cls, rm, gm, bm, _bits, _ents, ncolors) = h[:20]
|
||||
if bpp != 32:
|
||||
sys.exit(f'xsig: {bpp} bits per pixel')
|
||||
px = d[size + ncolors * 12:]
|
||||
end = '<' if order == 0 else '>'
|
||||
shift = lambda m: (m & -m).bit_length() - 1
|
||||
rs, gs, bs = shift(rm), shift(gm), shift(bm)
|
||||
# The outermost EDGE pixels are left out on both sides: where the window's
|
||||
# border meets its contents is the compositor's business, not the client's.
|
||||
EDGE = 2
|
||||
iw, ih = w - 2 * EDGE, ht - 2 * EDGE
|
||||
cells = [0] * (COLS * ROWS)
|
||||
exact = 2166136261 # FNV-1a over the dark bitmap, row by row
|
||||
for y in range(ih):
|
||||
row = struct.unpack_from(f'{end}{w}I', px, (y + EDGE) * bpl)
|
||||
cy = y * ROWS // ih * COLS
|
||||
for x in range(iw):
|
||||
v = row[x + EDGE]
|
||||
dark = max((v & rm) >> rs, (v & gm) >> gs, (v & bm) >> bs) < 128
|
||||
if dark:
|
||||
cells[cy + x * COLS // iw] += 1
|
||||
exact = ((exact ^ dark) * 16777619) & 0xffffffff
|
||||
# A row of cells a line: the terminal that carries this back wraps long ones.
|
||||
print('xsize', w, ht, exact)
|
||||
for r in range(ROWS):
|
||||
print(f'xs{r}', ','.join(map(str, cells[r * COLS:(r + 1) * COLS])))
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A launcher package for the demo desktop (docs/gexcc.md §9): what TREE has
|
||||
that BASE does not, as a tar.
|
||||
|
||||
Both trees are mkrootfs.py images, TREE built with BASE's packages and more.
|
||||
The tar holds every file, symlink and directory of TREE that BASE lacks or
|
||||
has different, except the dpkg status (one file every image has); instead
|
||||
the stanzas of the packages BASE lacks go in var/lib/gex-overlay/NAME.status,
|
||||
and the page appends them to the running system's status
|
||||
(web/src/overlay-worker.js).
|
||||
|
||||
mkoverlay.py --base BASE --tree TREE --name NAME --tar OUT.tar
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
SKIP = {'var/lib/dpkg/status', 'var/lib/dpkg/status-old', 'var/lib/dpkg/available'}
|
||||
|
||||
|
||||
def stanzas(path):
|
||||
text = open(path, encoding='utf-8', errors='replace').read() if os.path.exists(path) else ''
|
||||
out = {}
|
||||
for st in text.split('\n\n'):
|
||||
st = st.strip('\n')
|
||||
if not st:
|
||||
continue
|
||||
f = dict(l.split(': ', 1) for l in st.split('\n') if ': ' in l and not l.startswith(' '))
|
||||
out[(f.get('Package'), f.get('Architecture'))] = st
|
||||
return out
|
||||
|
||||
|
||||
def digest(p):
|
||||
h = hashlib.sha256()
|
||||
with open(p, 'rb') as f:
|
||||
for b in iter(lambda: f.read(1 << 20), b''):
|
||||
h.update(b)
|
||||
return h.digest()
|
||||
|
||||
|
||||
def same(a, b):
|
||||
if os.path.islink(a) or os.path.islink(b):
|
||||
return os.path.islink(a) and os.path.islink(b) and os.readlink(a) == os.readlink(b)
|
||||
if os.path.isdir(a) or os.path.isdir(b):
|
||||
return os.path.isdir(a) and os.path.isdir(b)
|
||||
if not os.path.lexists(b):
|
||||
return False
|
||||
sa, sb = os.lstat(a), os.lstat(b)
|
||||
return sa.st_size == sb.st_size and (sa.st_mode & 0o111) == (sb.st_mode & 0o111) and digest(a) == digest(b)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--base', required=True)
|
||||
ap.add_argument('--tree', required=True)
|
||||
ap.add_argument('--name', required=True)
|
||||
ap.add_argument('--tar', required=True)
|
||||
a = ap.parse_args()
|
||||
rels = []
|
||||
for d, dirs, files in os.walk(a.tree):
|
||||
dirs.sort()
|
||||
for n in sorted(dirs) + sorted(files):
|
||||
p = os.path.join(d, n)
|
||||
rel = os.path.relpath(p, a.tree)
|
||||
if rel in SKIP:
|
||||
continue
|
||||
if os.path.isdir(p) and not os.path.islink(p):
|
||||
if not os.path.isdir(os.path.join(a.base, rel)):
|
||||
rels.append(rel)
|
||||
continue
|
||||
if not same(p, os.path.join(a.base, rel)):
|
||||
rels.append(rel)
|
||||
have = stanzas(os.path.join(a.base, 'var/lib/dpkg/status'))
|
||||
new = [st for k, st in stanzas(os.path.join(a.tree, 'var/lib/dpkg/status')).items() if k not in have]
|
||||
with tarfile.open(a.tar, 'w', format=tarfile.GNU_FORMAT) as tf:
|
||||
for rel in rels:
|
||||
ti = tf.gettarinfo(os.path.join(a.tree, rel), arcname=rel)
|
||||
ti.uid = ti.gid = 0
|
||||
ti.uname = ti.gname = 'root'
|
||||
if ti.isreg():
|
||||
with open(os.path.join(a.tree, rel), 'rb') as f:
|
||||
tf.addfile(ti, f)
|
||||
else:
|
||||
tf.addfile(ti)
|
||||
body = ('\n\n'.join(new) + '\n\n').encode() if new else b''
|
||||
ti = tarfile.TarInfo(f'var/lib/gex-overlay/{a.name}.status')
|
||||
ti.size = len(body)
|
||||
ti.mode = 0o644
|
||||
tf.addfile(ti, io.BytesIO(body))
|
||||
names = sorted(st.split('\n', 1)[0].split(': ', 1)[1] for st in new)
|
||||
print(f'{a.name}: {len(rels)} paths, {len(new)} packages: {" ".join(names)}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -325,6 +325,7 @@ def main():
|
||||
ap.add_argument('--roots', default=','.join(ROOTS))
|
||||
ap.add_argument('--extra', default='', help='more arm64 packages (and their closure)')
|
||||
ap.add_argument('--exclude', default='', help='our packages to leave out of the image (still in the repo)')
|
||||
ap.add_argument('--ours', default='', help='only these of our packages in the image (still all in the repo)')
|
||||
ap.add_argument('--full', action='store_true', help='keep documentation and translations')
|
||||
a = ap.parse_args()
|
||||
global a_minimal
|
||||
@@ -336,6 +337,9 @@ def main():
|
||||
ours.append((deb, control(deb)))
|
||||
excluded = set(filter(None, a.exclude.split(',')))
|
||||
image_ours = [(d, c) for d, c in ours if c['Package'] not in excluded]
|
||||
if a.ours:
|
||||
only = set(a.ours.split(','))
|
||||
image_ours = [(d, c) for d, c in image_ours if c['Package'] in only]
|
||||
our_names = {c['Package'] for _, c in ours}
|
||||
for _, c in ours:
|
||||
for item in c.get('Provides', '').split(','):
|
||||
|
||||
+8
-3
@@ -3,7 +3,7 @@
|
||||
# single-thread build (SP, docs/single-thread.md), or both, from one tree.
|
||||
#
|
||||
# web/build-all.sh [--mp | --sp | --both] [--debug]
|
||||
# [--no-assets] [--no-gl] [--no-x] [--dist-only]
|
||||
# [--no-assets] [--no-gl] [--no-x] [--dist-only] [--x-only] [--gl-only]
|
||||
#
|
||||
# Each flavour gets its own dist:
|
||||
#
|
||||
@@ -56,7 +56,7 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$HERE/.." && pwd)"
|
||||
CACHE="${GEX_WEB_BUILD:-$ROOT/target/web-build}"
|
||||
DEBUGDIR="$ROOT/target/web-debug"
|
||||
MP=1 SP=0 ASSETS=1 GL=1 X=1 DEBUG=0
|
||||
MP=1 SP=0 ASSETS=1 GL=1 X=1 DEBUG=0 DISTS=1
|
||||
for a in "$@"; do
|
||||
case $a in
|
||||
--mp) MP=1 SP=0 ;;
|
||||
@@ -67,7 +67,11 @@ for a in "$@"; do
|
||||
--no-gl) GL=0 ;;
|
||||
--no-x) X=0 ;;
|
||||
--dist-only) ASSETS=0 GL=0 X=0 ;;
|
||||
*) echo "usage: web/build-all.sh [--mp | --sp | --both] [--debug] [--no-assets] [--no-gl] [--no-x] [--dist-only]" >&2; exit 2 ;;
|
||||
# Only Xwayland into web/public (web/build-demo.sh's step).
|
||||
--x-only) ASSETS=0 GL=0 X=1 DISTS=0 ;;
|
||||
# Only Mesa into web/public (web/build-demo.sh --gl).
|
||||
--gl-only) ASSETS=0 GL=1 X=0 DISTS=0 ;;
|
||||
*) echo "usage: web/build-all.sh [--mp | --sp | --both] [--debug] [--no-assets] [--no-gl] [--no-x] [--dist-only] [--x-only] [--gl-only]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
mkdir -p "$CACHE"
|
||||
@@ -123,6 +127,7 @@ xwl() { # xwl <mt|st>
|
||||
[ "$SP" = 1 ] && [ "$GL" = 1 ] && mesa st
|
||||
[ "$MP" = 1 ] && [ "$X" = 1 ] && xwl mt
|
||||
[ "$SP" = 1 ] && [ "$X" = 1 ] && xwl st
|
||||
[ "$DISTS" = 1 ] || exit 0
|
||||
|
||||
# -- 3. one dist per flavour ---------------------------------------------------
|
||||
dist() { # dist <mp|sp>
|
||||
|
||||
Executable
+286
@@ -0,0 +1,286 @@
|
||||
#!/bin/bash
|
||||
# The demo build: the desktop page (docs/gexcc.md §9) into web/dist-demo,
|
||||
# always stripped, runtime modules shipped as .wasm.zst. No test runs on this
|
||||
# build; tests/demo/smoke.sh checks the desktop comes up with both windows.
|
||||
#
|
||||
# web/build-demo.sh [--no-runtime] [--no-x] [--no-rootfs] [--no-pkgs]
|
||||
#
|
||||
# 1. The runtime the page loads (unless --no-runtime): the shared shim, the
|
||||
# kernel and the seed (shims/browser/build.sh), and the export providers
|
||||
# (gexc cgen-image --providers), into web/public/gex as build-assets.sh does.
|
||||
# 2. Xwayland, threaded (web/build-all.sh --x-only; skipped while its stamp
|
||||
# matches), into web/public/gex/xwayland.
|
||||
# 3. The base rootfs: bash and xterm and what they need, nothing else
|
||||
# (tools/gexcc/debian/mkrootfs.py over the gexcc pool, then pruned), as
|
||||
# rootfs/base.tar.zst. Needs the pool web/build-native-debian.sh builds
|
||||
# ($GEXCC_DEB/pool).
|
||||
# 4. The launcher's packages (unless --no-pkgs): web/demo-items.json, one
|
||||
# pkgs/NAME.tar.zst per item plus pkgs/index.json.
|
||||
# 5. A staged public tree with only what the desktop loads; every .wasm
|
||||
# stripped (`wasm-tools strip`: DWARF and other custom sections out, the
|
||||
# name section kept; the seed and providers keep their gex.* sections) and
|
||||
# compressed (`zstd --ultra -22`) to NAME.wasm.zst, the plain file
|
||||
# removed. Providers stay plain: a Worker reads them synchronously.
|
||||
# 6. vite with GEX_DEMO=1 (desktop.html is the page) and VITE_GEX_ZWASM=1
|
||||
# (zfetch.js decodes the .wasm.zst files).
|
||||
#
|
||||
# Run inside a resource scope:
|
||||
# systemd-run --user --scope -p TasksMax=4096 -p MemoryMax=32G nice -n 19 web/build-demo.sh
|
||||
set -eu
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$HERE/.." && pwd)"
|
||||
CACHE="${GEX_WEB_BUILD:-$ROOT/target/web-build}"
|
||||
export GEXCC_DEB=${GEXCC_DEB:-$HOME/gex-sandbox/scratch/CC/debian}
|
||||
WORK=${GEX_DEMO_WORK:-$GEXCC_DEB/demo}
|
||||
OUT=$HERE/dist-demo
|
||||
PUB=$HERE/public
|
||||
RUNTIME=1 X=1 ROOTFS=1 PKGS=1
|
||||
for a in "$@"; do
|
||||
case $a in
|
||||
--no-runtime) RUNTIME=0 ;;
|
||||
--no-x) X=0 ;;
|
||||
--no-rootfs) ROOTFS=0 ;;
|
||||
--no-pkgs) PKGS=0 ;;
|
||||
*) echo "usage: web/build-demo.sh [--no-runtime] [--no-x] [--no-rootfs] [--no-pkgs]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
size() { stat -c %s "$1"; }
|
||||
mib() { awk -v n="$1" 'BEGIN { printf "%.1f MiB", n / 1048576 }'; }
|
||||
command -v wasm-tools >/dev/null || { echo "need wasm-tools (cargo install wasm-tools)" >&2; exit 1; }
|
||||
command -v zstd >/dev/null || { echo "need zstd" >&2; exit 1; }
|
||||
mkdir -p "$CACHE" "$WORK" "$PUB/gex" "$PUB/rootfs"
|
||||
|
||||
# -- 1. runtime ----------------------------------------------------------------
|
||||
if [ "$RUNTIME" = 1 ]; then
|
||||
say "runtime: shim, kernel, seed"
|
||||
(cd "$ROOT" && shims/browser/build.sh >"$CACHE/demo-shim.log" 2>&1) ||
|
||||
{ tail -20 "$CACHE/demo-shim.log"; exit 1; }
|
||||
say "export providers"
|
||||
cargo build --release --manifest-path "$ROOT/Cargo.toml" -p gexc >/dev/null 2>&1
|
||||
rm -rf "$CACHE/demo-provcache"
|
||||
GEX_CGEN_CACHE="$CACHE/demo-provcache" "$ROOT/target/release/gexc" cgen-image --providers >/dev/null 2>&1
|
||||
mkdir -p "$PUB/gex/cgen/providers"
|
||||
rm -f "$PUB/gex/cgen/providers/"*.wasm
|
||||
cp "$CACHE/demo-provcache/providers/"*.wasm "$PUB/gex/cgen/providers/"
|
||||
fi
|
||||
|
||||
# -- 2. Xwayland -----------------------------------------------------------------
|
||||
if [ "$X" = 1 ]; then
|
||||
"$HERE/build-all.sh" --mp --x-only
|
||||
fi
|
||||
|
||||
# -- 2b. the guest's GL marshallers (glibc, arm64) ------------------------------
|
||||
# For the gears item: libGL/libEGL/libGLESv2 that marshal to the page's Mesa
|
||||
# (guest/gl). A cross gcc when there is one, else zig cc for aarch64 glibc.
|
||||
# The tracked ones in web/public/fs/glibc are used as they are; they are built
|
||||
# only when missing (rebuild them with guest/gl/build.sh when the protocol moves).
|
||||
if [ "$PKGS" = 1 ] && ! [ -f "$PUB/fs/glibc/libGL.so.1" -a -f "$PUB/fs/glibc/libEGL.so.1" -a -f "$PUB/fs/glibc/libGLESv2.so.2" ]; then
|
||||
GL_CC=${GEX_GL_CC:-$(command -v aarch64-linux-gnu-gcc || true)}
|
||||
if [ -z "$GL_CC" ] && [ -x "$HOME/toolchains/zig/zig" ]; then
|
||||
GL_CC=$CACHE/aarch64-gnu-cc
|
||||
printf '#!/bin/sh\nexec %s cc -target aarch64-linux-gnu.2.36 "$@"\n' "$HOME/toolchains/zig/zig" > "$GL_CC"
|
||||
chmod +x "$GL_CC"
|
||||
fi
|
||||
if [ -n "$GL_CC" ]; then
|
||||
say "GL marshallers (glibc)"
|
||||
GEX_CC=$GL_CC "$ROOT/guest/gl/build.sh" "$CACHE/gldriver-glibc" >"$CACHE/demo-gl.log" 2>&1 ||
|
||||
{ tail -20 "$CACHE/demo-gl.log"; exit 1; }
|
||||
mkdir -p "$PUB/fs/glibc"
|
||||
cp "$CACHE"/gldriver-glibc/{libGLESv2.so.2,libEGL.so.1,libGL.so.1} "$PUB/fs/glibc/"
|
||||
else
|
||||
echo "no aarch64 glibc compiler (aarch64-linux-gnu-gcc or ~/toolchains/zig): no GL marshallers" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- 3. the base rootfs --------------------------------------------------------------
|
||||
# What bash and xterm need, and the tools a shell user expects at the first
|
||||
# prompt (user, 2026-10-11: "the base first-load should have coreutils at
|
||||
# least"). All ours (wasm32, from Debian's sources). What each adds over
|
||||
# bash + xterm, zstd -22 of its overlay (2026-10-11): coreutils 0.77 MiB,
|
||||
# util-linux 0.57, procps 0.46, tar 0.41, nano 0.33, findutils 0.30,
|
||||
# ncurses-bin 0.21, less 0.20, grep 0.17, sed 0.09, gzip 0.07, hostname
|
||||
# 0.04, debianutils (run-parts, which: /etc/profile runs run-parts). Left to the launcher's "More tools": file 0.45, gawk 0.35,
|
||||
# diffutils 0.32, zstd 0.21, xz-utils 0.14, bsdextrautils 0.13, tree 0.05.
|
||||
# mawk and sysvinit-utils (0.2 MB) are there because base-files wants an awk
|
||||
# and x11-common an lsb-base; dash is /bin/sh (dpkg, scripts).
|
||||
BASE_ROOTS=base-files,base-passwd,ncurses-base
|
||||
BASE_EXTRA=xterm
|
||||
BASE_OURS=bash,dash,mawk,sysvinit-utils,coreutils,grep,sed,findutils,procps,less,ncurses-bin,gzip,tar,util-linux,hostname,nano,debianutils
|
||||
# Pruned after: fonts xterm does not use (its default font is the X server's
|
||||
# own `fixed`; DejaVu Sans Mono stays for `xterm -fa`), Xlib's locales but C
|
||||
# and en_US.UTF-8, icons and menus.
|
||||
base_prune() { # base_prune TREE
|
||||
local t=$1
|
||||
rm -f "$t"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf "$t"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf \
|
||||
"$t"/usr/share/fonts/truetype/dejavu/DejaVuSerif*.ttf
|
||||
for l in "$t"/usr/share/X11/locale/*; do
|
||||
case ${l##*/} in C|en_US.UTF-8|iso8859-1|locale.alias|locale.dir|compose.dir) ;; *) rm -rf "$l" ;; esac
|
||||
done
|
||||
# locale.dir and compose.dir name every locale; keep their C and en_US lines
|
||||
for f in locale.dir compose.dir; do
|
||||
[ -f "$t/usr/share/X11/locale/$f" ] || continue
|
||||
grep -E '^(#|$)|(^| )(C|en_US\.UTF-8|iso8859-1)/' "$t/usr/share/X11/locale/$f" > "$t/usr/share/X11/locale/$f.n" || true
|
||||
mv "$t/usr/share/X11/locale/$f.n" "$t/usr/share/X11/locale/$f"
|
||||
done
|
||||
# (debconf and its perl stay: maintainer scripts source
|
||||
# /usr/share/debconf/confmodule, and a dpkg -i or apt install runs them)
|
||||
rm -rf "$t"/usr/share/icons "$t"/usr/share/pixmaps "$t"/usr/share/applications \
|
||||
"$t"/usr/share/man "$t"/usr/share/doc-base "$t"/usr/share/lintian "$t"/usr/share/bash-completion \
|
||||
2>/dev/null || true
|
||||
}
|
||||
if [ "$ROOTFS" = 1 ]; then
|
||||
say "base rootfs"
|
||||
python3 -I "$ROOT/tools/gexcc/debian/mkrootfs.py" --pool "$GEXCC_DEB/pool" --out "$WORK/base" \
|
||||
--roots "$BASE_ROOTS" --extra "$BASE_EXTRA" --ours "$BASE_OURS"
|
||||
base_prune "$WORK/base"
|
||||
tar -C "$WORK/base" --owner=0 --group=0 --numeric-owner --sort=name -cf "$WORK/base.tar" .
|
||||
zstd --ultra -22 -T0 -q -f "$WORK/base.tar" -o "$PUB/rootfs/base.tar.zst"
|
||||
echo " base: $(mib "$(du -sb --apparent-size "$WORK/base" | cut -f1)") tree," \
|
||||
"$(mib "$(size "$WORK/base.tar")") tar, $(mib "$(size "$PUB/rootfs/base.tar.zst")") zstd"
|
||||
fi
|
||||
|
||||
# -- 4. the launcher's packages --------------------------------------------------------------
|
||||
# web/demo-items.json: one overlay per distinct `pkg` (default the item's id),
|
||||
# made by mkrootfs.py as the base plus the item and diffed against the base
|
||||
# (tools/gexcc/debian/mkoverlay.py), pruned as the base is. pkgs/index.json is
|
||||
# what the launcher lists. DEMO_DEBS=ID,... also writes pkgs/ID-debs.tar.zst:
|
||||
# the .debs of what that overlay adds, under /var/cache/apt/archives (the
|
||||
# dpkg -i measurement, docs/gexcc.md §9).
|
||||
if [ "$PKGS" = 1 ]; then
|
||||
say "launcher packages"
|
||||
mkdir -p "$PUB/pkgs" "$WORK/pkgs"
|
||||
python3 -I - "$HERE/demo-items.json" > "$WORK/pkgs/list" <<'PY'
|
||||
import json, sys
|
||||
seen = set()
|
||||
for it in json.load(open(sys.argv[1]))['items']:
|
||||
pkg = it.get('pkg', it['id'])
|
||||
if pkg in seen: continue
|
||||
seen.add(pkg)
|
||||
print(pkg, it.get('roots', '-'), it.get('extra', '-') or '-', it.get('ours', '-') or '-')
|
||||
PY
|
||||
while read -r pkg roots extra ours; do
|
||||
[ "$roots" = - ] && roots=$BASE_ROOTS || roots=$BASE_ROOTS,$roots
|
||||
[ "$extra" = - ] && extra=$BASE_EXTRA || extra=$BASE_EXTRA,$extra
|
||||
[ "$ours" = - ] && ours=$BASE_OURS || ours=$BASE_OURS,$ours
|
||||
t=$WORK/pkgs/$pkg
|
||||
python3 -I "$ROOT/tools/gexcc/debian/mkrootfs.py" --pool "$GEXCC_DEB/pool" --out "$t" \
|
||||
--roots "$roots" --extra "$extra" --ours "$ours" 2>"$WORK/pkgs/$pkg.log" ||
|
||||
{ cat "$WORK/pkgs/$pkg.log"; exit 1; }
|
||||
base_prune "$t"
|
||||
# The item's own `prune` (globs under the tree) and `files` (guest
|
||||
# path -> a file under web/public), e.g. gears: Debian's Mesa out, the
|
||||
# gex GL marshallers in.
|
||||
GEXCC_DEB=$GEXCC_DEB python3 -I - "$HERE/demo-items.json" "$pkg" "$t" "$PUB" <<'PY'
|
||||
import glob, json, os, shutil, sys
|
||||
items, pkg, tree, pub = json.load(open(sys.argv[1]))['items'], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
it = next(x for x in items if x.get('pkg', x['id']) == pkg)
|
||||
for g in it.get('prune', []):
|
||||
for p in glob.glob(os.path.join(tree, g)):
|
||||
shutil.rmtree(p) if os.path.isdir(p) and not os.path.islink(p) else os.unlink(p)
|
||||
# `lazypool`: our own .debs (the gex apt repository, /var/lib/gex-apt) as
|
||||
# files the guest fetches from the page when apt reads them, so apt can
|
||||
# install a dependency only we build (bsdextrautils for man-db...) without
|
||||
# the overlay carrying the whole pool. They are published under gex-apt/.
|
||||
if it.get('lazypool'):
|
||||
pool = os.path.join(os.environ['GEXCC_DEB'], 'pool')
|
||||
lazy = []
|
||||
for f in sorted(os.listdir(pool)):
|
||||
if not f.endswith('.deb'):
|
||||
continue
|
||||
name = f.split('_')[0]
|
||||
rel = f'pool/main/{name[0]}/{name}/{f}'
|
||||
out = os.path.join(pub, 'gex-apt', rel)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
shutil.copy(os.path.join(pool, f), out)
|
||||
lazy.append([f'/var/lib/gex-apt/{rel}', os.path.getsize(out), f'gex-apt/{rel}'])
|
||||
d = os.path.join(tree, 'var/lib/gex-overlay')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
json.dump(lazy, open(os.path.join(d, f'{pkg}.lazy'), 'w'))
|
||||
for dst, src in it.get('files', {}).items():
|
||||
d = os.path.join(tree, dst.lstrip('/'))
|
||||
os.makedirs(os.path.dirname(d), exist_ok=True)
|
||||
shutil.copy(os.path.join(pub, src), d)
|
||||
os.chmod(d, 0o755)
|
||||
PY
|
||||
python3 -I "$ROOT/tools/gexcc/debian/mkoverlay.py" --base "$WORK/base" --tree "$t" --name "$pkg" \
|
||||
--tar "$WORK/pkgs/$pkg.tar" | tee "$WORK/pkgs/$pkg.added"
|
||||
zstd --ultra -22 -T0 -q -f "$WORK/pkgs/$pkg.tar" -o "$PUB/pkgs/$pkg.tar.zst"
|
||||
echo " $pkg: $(mib "$(size "$WORK/pkgs/$pkg.tar")") tar, $(mib "$(size "$PUB/pkgs/$pkg.tar.zst")") zstd"
|
||||
case ,${DEMO_DEBS:-}, in *,$pkg,*)
|
||||
d=$WORK/pkgs/$pkg-debs; rm -rf "$d"; mkdir -p "$d/var/cache/apt/archives"
|
||||
for n in $(sed 's/^[^:]*: [0-9]* paths, [0-9]* packages: //' "$WORK/pkgs/$pkg.added"); do
|
||||
f=$(ls "$GEXCC_DEB/pool/${n}_"*.deb 2>/dev/null | head -1)
|
||||
[ -n "$f" ] || f=$(find "$HOME/gex-sandbox/scratch/CC/deb/pool" -name "${n}_*_*.deb" | head -1)
|
||||
[ -n "$f" ] || { echo " no .deb for $n" >&2; continue; }
|
||||
cp "$f" "$d/var/cache/apt/archives/"
|
||||
done
|
||||
tar -C "$d" --owner=0 --group=0 --numeric-owner -cf "$WORK/pkgs/$pkg-debs.tar" .
|
||||
zstd --ultra -22 -T0 -q -f "$WORK/pkgs/$pkg-debs.tar" -o "$PUB/pkgs/$pkg-debs.tar.zst"
|
||||
echo " $pkg-debs: $(ls "$d/var/cache/apt/archives" | wc -l) debs, $(mib "$(size "$PUB/pkgs/$pkg-debs.tar.zst")") zstd"
|
||||
esac
|
||||
done < "$WORK/pkgs/list"
|
||||
python3 -I - "$HERE/demo-items.json" "$PUB/pkgs" > "$PUB/pkgs/index.json" <<'PY'
|
||||
import json, os, sys
|
||||
items = json.load(open(sys.argv[1]))['items']
|
||||
out = []
|
||||
for it in items:
|
||||
pkg = it.get('pkg', it['id'])
|
||||
o = {k: it[k] for k in ('id', 'label', 'desc', 'glyph', 'color', 'cmd', 'auto', 'gl') if k in it}
|
||||
o['pkg'] = pkg
|
||||
o['size'] = os.path.getsize(os.path.join(sys.argv[2], pkg + '.tar.zst'))
|
||||
out.append(o)
|
||||
# The .deb bundles (DEMO_DEBS), for a measurement rather than the launcher.
|
||||
hidden = [{'id': f[:-len('.tar.zst')], 'pkg': f[:-len('.tar.zst')], 'cmd': 'true',
|
||||
'size': os.path.getsize(os.path.join(sys.argv[2], f))}
|
||||
for f in sorted(os.listdir(sys.argv[2])) if f.endswith('-debs.tar.zst')]
|
||||
print(json.dumps({'items': out, 'hidden': hidden}, indent=1))
|
||||
PY
|
||||
fi
|
||||
|
||||
# -- 5. stage --------------------------------------------------------------------------------
|
||||
say "staging"
|
||||
STAGE=$CACHE/public-demo
|
||||
rm -rf "$STAGE"
|
||||
mkdir -p "$STAGE/gex/cgen" "$STAGE/rootfs"
|
||||
for f in gex_browser.shared.wasm gex_kernel.wasm seed.shared.wasm; do
|
||||
[ -f "$PUB/gex/$f" ] || { echo "no web/public/gex/$f: run without --no-runtime" >&2; exit 1; }
|
||||
cp "$PUB/gex/$f" "$STAGE/gex/"
|
||||
done
|
||||
cp -r "$PUB/gex/cgen/providers" "$STAGE/gex/cgen/"
|
||||
[ -f "$PUB/gex/xwayland/Xwayland.wasm" ] || { echo "no web/public/gex/xwayland: run without --no-x" >&2; exit 1; }
|
||||
cp -r "$PUB/gex/xwayland" "$STAGE/gex/"
|
||||
cp "$PUB/rootfs/base.tar.zst" "$STAGE/rootfs/"
|
||||
# Mesa (the host's GL, gex_wgpu), fetched only when a GL program starts.
|
||||
if [ -f "$PUB/gex/gex-wgpu-replay.wasm" ]; then
|
||||
cp "$PUB/gex/gex-wgpu-replay.js" "$PUB/gex/gex-wgpu-replay.wasm" "$STAGE/gex/"
|
||||
else
|
||||
echo " no web/public/gex/gex-wgpu-replay.wasm (web/build-all.sh --mp --gl-only): GL items will not draw" >&2
|
||||
fi
|
||||
[ -d "$PUB/pkgs" ] && cp -r "$PUB/pkgs" "$STAGE/"
|
||||
[ -d "$PUB/gex-apt" ] && cp -r "$PUB/gex-apt" "$STAGE/"
|
||||
before=0 stripped=0 packed=0
|
||||
for w in $(find "$STAGE/gex" -name '*.wasm' -not -path '*/cgen/*'); do
|
||||
b=$(size "$w"); before=$((before + b))
|
||||
case ${w##*/} in
|
||||
seed*.wasm) wasm-tools strip -d '^\.debug' "$w" -o "$w.s" ;; # keeps gex.* and name
|
||||
*) wasm-tools strip "$w" -o "$w.s" ;; # keeps name
|
||||
esac
|
||||
mv -f "$w.s" "$w"
|
||||
s=$(size "$w"); stripped=$((stripped + s))
|
||||
zstd --ultra -22 -T0 -q -f --rm "$w" -o "$w.zst"
|
||||
z=$(size "$w.zst"); packed=$((packed + z))
|
||||
printf ' %-34s %10s -> %10s stripped -> %10s zstd\n' "${w#"$STAGE"/}" "$(mib $b)" "$(mib $s)" "$(mib $z)"
|
||||
done
|
||||
echo " runtime modules: $(mib $before) -> $(mib $stripped) stripped -> $(mib $packed) zstd"
|
||||
|
||||
# -- 6. the page ---------------------------------------------------------------------------
|
||||
say "web/dist-demo"
|
||||
rm -rf "$OUT"
|
||||
(cd "$HERE" && GEX_DEMO=1 VITE_GEX_ZWASM=1 GEX_PUBLIC_DIR="$STAGE" GEX_OUT_DIR="$OUT" \
|
||||
npm run --silent build >"$CACHE/vite-demo.log" 2>&1) || { tail -20 "$CACHE/vite-demo.log"; exit 1; }
|
||||
mv "$OUT/desktop.html" "$OUT/index.html"
|
||||
rm -rf "$STAGE"
|
||||
echo " web/dist-demo: $(du -sh --apparent-size "$OUT" | cut -f1)"
|
||||
echo " first load (runtime + page + base rootfs):" \
|
||||
"$(mib $(( $(find "$OUT" -type f -not -path '*/pkgs/*' -not -path '*/gex-apt/*' -not -name 'gex-wgpu-replay.*' -printf '%s\n' | paste -sd+ | bc) )))"
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"comment": "The demo desktop's launcher (docs/gexcc.md §9). Each item is an overlay built by web/demo-pkgs.sh: mkrootfs.py with the base's packages plus `ours` (gexcc-built, from the pool) and `extra` (Debian arm64, translated), minus the base. `auto` items are fetched in the background once the desktop is up. `pkg` shares another item's overlay.",
|
||||
"items": [
|
||||
{
|
||||
"id": "moretools",
|
||||
"label": "More tools",
|
||||
"desc": "diff, file, gawk, xz, zstd, tree, column... (wasm32, built from Debian source)",
|
||||
"glyph": "$",
|
||||
"color": "#6ee7a8",
|
||||
"ours": "diffutils,gawk,xz-utils,zstd,file,tree,bsdextrautils",
|
||||
"cmd": "true"
|
||||
},
|
||||
{
|
||||
"id": "gears",
|
||||
"label": "glxgears",
|
||||
"desc": "OpenGL: Debian's glxgears (arm64), drawn by Mesa on WebGPU in this page",
|
||||
"glyph": "⚙",
|
||||
"color": "#ff9e64",
|
||||
"gl": true,
|
||||
"extra": "mesa-utils",
|
||||
"ours": "bsdextrautils",
|
||||
"prune": [
|
||||
"usr/lib/aarch64-linux-gnu/libgallium*",
|
||||
"usr/lib/aarch64-linux-gnu/libLLVM*",
|
||||
"usr/lib/aarch64-linux-gnu/dri",
|
||||
"usr/lib/aarch64-linux-gnu/libvulkan*",
|
||||
"usr/share/vulkan",
|
||||
"usr/lib/aarch64-linux-gnu/libz3*"
|
||||
],
|
||||
"files": {
|
||||
"/usr/lib/gex-gl/libEGL.so.1": "fs/glibc/libEGL.so.1",
|
||||
"/usr/lib/gex-gl/libGLESv2.so.2": "fs/glibc/libGLESv2.so.2",
|
||||
"/usr/lib/gex-glx/libGL.so.1": "fs/glibc/libGL.so.1",
|
||||
"/usr/lib/aarch64-linux-gnu/libGL.so.1": "fs/glibc/libGL.so.1",
|
||||
"/usr/lib/aarch64-linux-gnu/libEGL.so.1": "fs/glibc/libEGL.so.1",
|
||||
"/usr/lib/aarch64-linux-gnu/libGLESv2.so.2": "fs/glibc/libGLESv2.so.2"
|
||||
},
|
||||
"cmd": "glxgears"
|
||||
},
|
||||
{
|
||||
"id": "xeyes",
|
||||
"label": "xeyes",
|
||||
"desc": "X11, Debian arm64 (translated)",
|
||||
"glyph": "👀",
|
||||
"color": "#f0b866",
|
||||
"pkg": "x11-apps",
|
||||
"extra": "x11-apps",
|
||||
"cmd": "xeyes",
|
||||
"ours": "bsdextrautils"
|
||||
},
|
||||
{
|
||||
"id": "xclock",
|
||||
"label": "xclock",
|
||||
"desc": "X11, Debian arm64 (translated)",
|
||||
"glyph": "◷",
|
||||
"color": "#f0b866",
|
||||
"pkg": "x11-apps",
|
||||
"extra": "x11-apps",
|
||||
"cmd": "xclock",
|
||||
"ours": "bsdextrautils"
|
||||
},
|
||||
{
|
||||
"id": "foot",
|
||||
"label": "foot",
|
||||
"desc": "A Wayland terminal (wasm32)",
|
||||
"glyph": "▮",
|
||||
"color": "#7dcfff",
|
||||
"ours": "foot,fontconfig",
|
||||
"cmd": "foot"
|
||||
},
|
||||
{
|
||||
"id": "gtk3-demo",
|
||||
"label": "GTK 3 demo",
|
||||
"desc": "GTK 3's widget demo (wasm32)",
|
||||
"glyph": "G",
|
||||
"color": "#bb9af7",
|
||||
"ours": "gtk-3-examples,libgtk-3-common,gtk-update-icon-cache,fontconfig",
|
||||
"cmd": "GDK_BACKEND=wayland gtk3-demo"
|
||||
},
|
||||
{
|
||||
"id": "nvim",
|
||||
"label": "Neovim",
|
||||
"desc": "Debian arm64 (translated), in an xterm",
|
||||
"glyph": "N",
|
||||
"color": "#8bd450",
|
||||
"extra": "neovim",
|
||||
"ours": "bsdextrautils",
|
||||
"cmd": "xterm -T nvim -e nvim"
|
||||
},
|
||||
{
|
||||
"id": "node",
|
||||
"label": "Node.js",
|
||||
"desc": "Debian arm64 node 20 (translated, V8's JIT included), in an xterm",
|
||||
"glyph": "JS",
|
||||
"color": "#9ccc65",
|
||||
"extra": "nodejs",
|
||||
"cmd": "xterm -T node -e node",
|
||||
"ours": "bsdextrautils"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"label": "Python 3.13",
|
||||
"desc": "wasm32, built from Debian source, in an xterm",
|
||||
"glyph": "Py",
|
||||
"color": "#ffd43b",
|
||||
"ours": "python3.13",
|
||||
"cmd": "xterm -T python -e python3.13"
|
||||
},
|
||||
{
|
||||
"id": "htop",
|
||||
"label": "htop",
|
||||
"desc": "wasm32, in an xterm",
|
||||
"glyph": "▤",
|
||||
"color": "#f07a7a",
|
||||
"ours": "htop",
|
||||
"cmd": "xterm -T htop -e htop"
|
||||
},
|
||||
{
|
||||
"id": "doom",
|
||||
"label": "Chocolate Doom",
|
||||
"desc": "SDL2 on X11 (wasm32) playing Freedoom",
|
||||
"glyph": "D",
|
||||
"color": "#e05d44",
|
||||
"ours": "chocolate-doom,bsdextrautils",
|
||||
"extra": "freedoom",
|
||||
"cmd": "chocolate-doom -iwad /usr/share/games/doom/freedoom2.wad -window"
|
||||
},
|
||||
{
|
||||
"id": "apt",
|
||||
"label": "apt",
|
||||
"desc": "Debian's package manager: apt install anything from trixie (needs the network)",
|
||||
"glyph": "⬇",
|
||||
"color": "#d6dbe4",
|
||||
"ours": "apt,dpkg,perl-base,gpgv,openssl,debianutils,coreutils,tar,gzip,xz-utils,zstd,sed,grep,findutils,diffutils,mawk,util-linux",
|
||||
"roots": "apt,dpkg,base-files,base-passwd,debian-archive-keyring,ncurses-base,libc-bin,perl-base,debconf,ca-certificates,usr-is-merged,sensible-utils,netbase,init-system-helpers,login.defs",
|
||||
"cmd": "xterm -T apt -e bash -l -c 'echo apt is installed: try apt update then apt install cowsay; exec bash -l'",
|
||||
"lazypool": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>gex desktop</title>
|
||||
<meta name="description" content="Debian programs, built to WebAssembly or translated from arm64, on a desktop in your browser." />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>▚</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="panel">
|
||||
<button id="apps" class="pbtn" title="Applications">▚ Applications</button>
|
||||
<div id="tasks"></div>
|
||||
<span id="pstatus"></span>
|
||||
<button id="console-btn" class="pbtn" title="The system console: pid 1's terminal">console</button>
|
||||
<span id="clock"></span>
|
||||
</div>
|
||||
<div id="screen">
|
||||
<canvas id="out" tabindex="0"></canvas>
|
||||
<div id="splash">
|
||||
<div class="splash-card">
|
||||
<div class="logo">▚ gex</div>
|
||||
<div id="splash-text">starting</div>
|
||||
<div class="bar"><div id="splash-bar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="launcher" class="dwin hidden">
|
||||
<div class="dwin-title"><span>Applications</span><button class="dwin-close" title="Close">×</button></div>
|
||||
<div class="dwin-body">
|
||||
<p class="lhint">Each program downloads once, the first time you open it, and is added to the running system.</p>
|
||||
<ul id="items"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div id="console" class="dwin hidden">
|
||||
<div class="dwin-title"><span>Console (pid 1)</span><button class="dwin-close" title="Close">×</button></div>
|
||||
<div class="dwin-body"><div id="term"></div></div>
|
||||
</div>
|
||||
<script type="module" src="/src/desktop.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+160
-107
@@ -1,3 +1,5 @@
|
||||
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
|
||||
import './zfetch.js';
|
||||
import { requestGexDevice, textureCanvas } from './gpu-device.js';
|
||||
// gex-wayland's output: a real compositor, in a worker, on WebGPU (the device Mesa uses).
|
||||
//
|
||||
@@ -42,10 +44,22 @@ import {
|
||||
// A frame the compositor draws, not the client: this is what "server-side
|
||||
// decorations" means, and a Qt or KDE app that sees the xdg-decoration
|
||||
// protocol stops drawing its own.
|
||||
const TITLE_H = 26;
|
||||
const BORDER = 1;
|
||||
const BUTTON = 18;
|
||||
const BUTTON_GAP = 6;
|
||||
//
|
||||
// In device pixels: the output is the canvas's backing store, which is the
|
||||
// CSS size times devicePixelRatio, so every metric is scaled by that ratio
|
||||
// (`UI`) and the frame is as large on the screen at any density.
|
||||
let UI = 1;
|
||||
let TITLE_H = 26;
|
||||
let BORDER = 1;
|
||||
let BUTTON = 18;
|
||||
let BUTTON_GAP = 6;
|
||||
function setUiScale(u) {
|
||||
UI = u > 0 && Number.isFinite(u) ? u : 1;
|
||||
TITLE_H = Math.round(26 * UI);
|
||||
BORDER = Math.max(1, Math.round(UI));
|
||||
BUTTON = Math.round(18 * UI);
|
||||
BUTTON_GAP = Math.round(6 * UI);
|
||||
}
|
||||
|
||||
const COL = {
|
||||
desktop: [0.07, 0.09, 0.12, 1],
|
||||
@@ -146,9 +160,13 @@ class Painter {
|
||||
fragment: { module, entryPoint: 'fs', targets: [{ format, blend: { color: blend, alpha: blend } }] },
|
||||
primitive: { topology: 'triangle-strip' },
|
||||
});
|
||||
// Linear and clamped, whatever the texture: the guest's GL frames are
|
||||
// textures Mesa made, and sampling must not depend on their state.
|
||||
// Clamped, whatever the texture: the guest's GL frames are textures Mesa
|
||||
// made, and sampling must not depend on their state. Nearest where a
|
||||
// texture is drawn at its own size (a window, 1:1 with the device's
|
||||
// pixels, at whole-pixel positions), linear only where it is scaled.
|
||||
this.sampler = device.createSampler({ minFilter: 'linear', magFilter: 'linear' });
|
||||
this.nearest = device.createSampler({ minFilter: 'nearest', magFilter: 'nearest' });
|
||||
this.groupsNearest = new WeakMap();
|
||||
this.uniforms = device.createBuffer({ size: SLOT * MAX_DRAWS, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
||||
this.data = new ArrayBuffer(SLOT * MAX_DRAWS);
|
||||
this.f32 = new Float32Array(this.data);
|
||||
@@ -159,18 +177,19 @@ class Painter {
|
||||
this.scene = null;
|
||||
}
|
||||
|
||||
#group(tex) {
|
||||
let g = this.groups.get(tex);
|
||||
#group(tex, nearest) {
|
||||
const groups = nearest ? this.groupsNearest : this.groups;
|
||||
let g = groups.get(tex);
|
||||
if (!g) {
|
||||
g = this.device.createBindGroup({
|
||||
layout: this.layout,
|
||||
entries: [
|
||||
{ binding: 0, resource: { buffer: this.uniforms, size: 80 } },
|
||||
{ binding: 1, resource: this.sampler },
|
||||
{ binding: 1, resource: nearest ? this.nearest : this.sampler },
|
||||
{ binding: 2, resource: tex.createView() },
|
||||
],
|
||||
});
|
||||
this.groups.set(tex, g);
|
||||
groups.set(tex, g);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
@@ -207,7 +226,12 @@ class Painter {
|
||||
f[o + 8] = color[0]; f[o + 9] = color[1]; f[o + 10] = color[2]; f[o + 11] = color[3];
|
||||
f[o + 12] = this.w; f[o + 13] = this.h;
|
||||
this.u32[o + 14] = mode; this.u32[o + 15] = swizzle; this.u32[o + 16] = opaque;
|
||||
this.pass.setBindGroup(0, this.#group(tex), [this.n * SLOT]);
|
||||
// 1:1 and on whole pixels: nearest, so a terminal's glyphs are the
|
||||
// client's own pixels and not a blend of two neighbours.
|
||||
const nearest = mode === 1 || (w === Math.round(w) && h === Math.round(h) && x === Math.round(x)
|
||||
&& y === Math.round(y) && Math.round(w / Math.max(1e-9, uv[2])) === tex.width
|
||||
&& Math.round(h / Math.max(1e-9, uv[3])) === tex.height);
|
||||
this.pass.setBindGroup(0, this.#group(tex, nearest), [this.n * SLOT]);
|
||||
this.pass.draw(4);
|
||||
this.n++;
|
||||
}
|
||||
@@ -331,19 +355,21 @@ class TextCache {
|
||||
get(text) {
|
||||
let e = this.#entries.get(text);
|
||||
if (e) return e;
|
||||
const scale = 2; // drawn at 2x and minified, so the glyphs stay crisp
|
||||
// At device pixels and drawn 1:1, so the glyphs are the browser's own
|
||||
// rendering at this density, not a resampling of it.
|
||||
const cv = new OffscreenCanvas(1, 1);
|
||||
let ctx = cv.getContext('2d');
|
||||
ctx.font = `${13 * scale}px system-ui, sans-serif`;
|
||||
const px = Math.round(13 * UI);
|
||||
ctx.font = `${px}px system-ui, sans-serif`;
|
||||
const width = Math.max(1, Math.ceil(ctx.measureText(text).width));
|
||||
cv.width = width;
|
||||
cv.height = 18 * scale;
|
||||
cv.height = Math.round(18 * UI);
|
||||
ctx = cv.getContext('2d');
|
||||
ctx.font = `${13 * scale}px system-ui, sans-serif`;
|
||||
ctx.font = `${px}px system-ui, sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillText(text, 0, cv.height / 2);
|
||||
e = { tex: canvasTexture(this.#device, cv), w: cv.width / scale, h: cv.height / scale };
|
||||
e = { tex: canvasTexture(this.#device, cv), w: cv.width, h: cv.height };
|
||||
// A cache with no bound is a leak; titles change rarely and a session has
|
||||
// a handful of windows, so a small cap is plenty.
|
||||
if (this.#entries.size > 64) {
|
||||
@@ -358,29 +384,6 @@ class TextCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** The pointer image, drawn when no client has set one of its own. */
|
||||
function defaultCursor(device) {
|
||||
const s = 2;
|
||||
const cv = new OffscreenCanvas(12 * s, 19 * s);
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.scale(s, s);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0.5, 0.5);
|
||||
ctx.lineTo(0.5, 15.5);
|
||||
ctx.lineTo(4.5, 11.5);
|
||||
ctx.lineTo(7, 17.5);
|
||||
ctx.lineTo(9.5, 16.5);
|
||||
ctx.lineTo(7, 10.5);
|
||||
ctx.lineTo(11.5, 10.5);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
return { tex: canvasTexture(device, cv), w: 12, h: 19 };
|
||||
}
|
||||
|
||||
// -- the compositor ---------------------------------------------------------
|
||||
|
||||
const key = (s) => `${s.token}:${s.client}:${s.surface}`;
|
||||
@@ -389,7 +392,6 @@ class Compositor {
|
||||
#device;
|
||||
#painter;
|
||||
#text;
|
||||
#cursorFallback;
|
||||
#channel;
|
||||
/** Guest memories to read pixels from, by the token of the process. */
|
||||
#memories = new Map();
|
||||
@@ -442,7 +444,6 @@ class Compositor {
|
||||
this.#painter.ctx2d = canvas.getContext('2d', { alpha: false });
|
||||
}
|
||||
this.#text = new TextCache(device);
|
||||
this.#cursorFallback = defaultCursor(device);
|
||||
this.resize(canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
@@ -539,7 +540,7 @@ class Compositor {
|
||||
this.#reportPosition(win);
|
||||
} else if (s.role === ROLE_TOPLEVEL) {
|
||||
// Cascade, so two windows that appear together are both reachable.
|
||||
const step = 28;
|
||||
const step = Math.round(28 * UI);
|
||||
const n = this.#placed++;
|
||||
const roomX = Math.max(1, this.#outW - s.width - BORDER);
|
||||
const roomY = Math.max(1, this.#outH - s.height - TITLE_H - BORDER);
|
||||
@@ -550,13 +551,25 @@ class Compositor {
|
||||
this.#reportPosition(win);
|
||||
}
|
||||
}
|
||||
// Device pixels from here on. A surface's buffer is `bs` pixels per
|
||||
// surface unit and its client was told `cs` device pixels per unit
|
||||
// (wl_output.scale), so it covers width * cs / bs device pixels: 1:1
|
||||
// when the client drew at the output's scale. X windows are cs = 1 and
|
||||
// device pixels throughout (the X server is told the device size).
|
||||
const bs = (s.flags >> 8) & 0xff || 1;
|
||||
const cs = (s.flags >> 16) & 0xff || 1;
|
||||
win.role = s.role;
|
||||
win.width = s.width;
|
||||
win.height = s.height;
|
||||
win.bs = bs;
|
||||
win.cs = cs;
|
||||
win.width = Math.round((s.width * cs) / bs);
|
||||
win.height = Math.round((s.height * cs) / bs);
|
||||
win.title = s.title;
|
||||
win.parent = s.parent;
|
||||
win.relX = s.relX;
|
||||
win.relY = s.relY;
|
||||
win.relX = s.relX * cs;
|
||||
win.relY = s.relY * cs;
|
||||
// A cursor's hotspot, in its surface's units (CSS pixels for the page).
|
||||
win.hotX = s.relX;
|
||||
win.hotY = s.relY;
|
||||
win.info = s;
|
||||
// A client that asked to be dragged (a GTK titlebar) starts a drag as if
|
||||
// the pointer had grabbed our own frame.
|
||||
@@ -741,7 +754,7 @@ class Compositor {
|
||||
const f = this.#frameOf(win);
|
||||
return {
|
||||
x: f.frameX + f.frameW - BUTTON - BUTTON_GAP,
|
||||
y: f.frameY + (TITLE_H - BUTTON) / 2,
|
||||
y: f.frameY + Math.floor((TITLE_H - BUTTON) / 2),
|
||||
w: BUTTON,
|
||||
h: BUTTON,
|
||||
};
|
||||
@@ -750,15 +763,15 @@ class Compositor {
|
||||
#drawDecoration(win, active) {
|
||||
const p = this.#painter;
|
||||
const f = this.#frameOf(win);
|
||||
p.solid(f.frameX + 3, f.frameY + 4, f.frameW, f.frameH, COL.shadow);
|
||||
p.solid(f.frameX + Math.round(3 * UI), f.frameY + Math.round(4 * UI), f.frameW, f.frameH, COL.shadow);
|
||||
p.solid(f.frameX, f.frameY, f.frameW, f.frameH, COL.border);
|
||||
p.solid(f.frameX, f.frameY, f.frameW, TITLE_H, active ? COL.titleActive : COL.titleIdle);
|
||||
const label = win.title || 'window';
|
||||
const t = this.#text.get(label);
|
||||
const room = f.frameW - BUTTON - 2 * BUTTON_GAP - 10;
|
||||
const room = f.frameW - BUTTON - 2 * BUTTON_GAP - Math.round(10 * UI);
|
||||
const w = Math.min(t.w, Math.max(0, room));
|
||||
if (w > 4) {
|
||||
p.textured(f.frameX + 8, f.frameY + (TITLE_H - t.h) / 2, w, t.h, t.tex, {
|
||||
p.textured(f.frameX + Math.round(8 * UI), f.frameY + Math.floor((TITLE_H - t.h) / 2), w, t.h, t.tex, {
|
||||
uv: [0, 0, w / t.w, 1],
|
||||
});
|
||||
}
|
||||
@@ -767,8 +780,8 @@ class Compositor {
|
||||
// The cross, from the same text cache as the title: one texture and one
|
||||
// draw, rather than a quad per pixel of two diagonals.
|
||||
const x = this.#text.get('\u2715');
|
||||
const size = Math.min(b.w, b.h) - 6;
|
||||
p.textured(b.x + (b.w - size) / 2, b.y + (b.h - size) / 2, size, size, x.tex);
|
||||
const size = Math.min(b.w, b.h) - Math.round(6 * UI);
|
||||
p.textured(b.x + Math.floor((b.w - size) / 2), b.y + Math.floor((b.h - size) / 2), size, size, x.tex);
|
||||
}
|
||||
|
||||
#drawSurface(k, win) {
|
||||
@@ -852,7 +865,8 @@ class Compositor {
|
||||
/** What the output shows, as a string: equal strings, equal pixels (a GL
|
||||
* window's frame counted by its presented serial). */
|
||||
#signature() {
|
||||
const parts = [this.#outW, this.#outH, this.#focus, this.#pointer.x, this.#pointer.y, this.#order.join(',')];
|
||||
// Not the pointer: the cursor is the page's (`#updateCursor`), not drawn.
|
||||
const parts = [this.#outW, this.#outH, this.#focus, this.#order.join(',')];
|
||||
for (const [k, win] of this.#windows) {
|
||||
const f = win.role === ROLE_TOPLEVEL ? this.#frameOf(win) : null;
|
||||
parts.push(k, win.role, win.width, win.height, win.relX, win.relY, win.title, win.parent,
|
||||
@@ -874,7 +888,6 @@ class Compositor {
|
||||
#directWanted = false;
|
||||
#direct = null; // { k, w, h, ox, oy } while presenting directly
|
||||
#directSerial = -1;
|
||||
#cursorSerial = -1;
|
||||
|
||||
setDirect(on) {
|
||||
this.#directWanted = !!on;
|
||||
@@ -903,12 +916,10 @@ class Compositor {
|
||||
this.#canvas.width = d.w;
|
||||
this.#canvas.height = d.h;
|
||||
this.#directSerial = -1;
|
||||
this.#cursorSerial = -1;
|
||||
post({ kind: 'direct', w: d.w, h: d.h });
|
||||
this.#note(`direct present ${d.w}x${d.h} (${d.k})`);
|
||||
}
|
||||
this.#direct = { k: d.k, w: d.w, h: d.h, ox: d.ox, oy: d.oy };
|
||||
this.#directCursor(byKey);
|
||||
for (const b of blocks) ackBlock(this.#channel, b.block, b.gen);
|
||||
if (d.gl.serial === this.#directSerial) {
|
||||
this.#skipped++;
|
||||
@@ -932,41 +943,93 @@ class Compositor {
|
||||
this.#note('direct present off: compositing');
|
||||
}
|
||||
|
||||
/* The client's cursor as the page's CSS cursor: none when it hid it (a
|
||||
* game drawing its own), its image when it set one. Read from the guest's
|
||||
* buffer when its serial moves. */
|
||||
#directCursor(byKey) {
|
||||
let found = null;
|
||||
for (const [k, win] of this.#windows) if (win.role === ROLE_CURSOR) { found = [k, win]; break; }
|
||||
if (!found) {
|
||||
if (this.#cursorSerial !== 0) post({ kind: 'cursor', css: 'none' });
|
||||
this.#cursorSerial = 0;
|
||||
return;
|
||||
/* The pointer is the page's own CSS cursor, never drawn into the frame:
|
||||
* the image the client under the pointer set (wl_pointer.set_cursor, which
|
||||
* is also how Xwayland shows an X cursor), `none` when that client set no
|
||||
* image (a game drawing its own), and the browser's default over the
|
||||
* desktop and the frames this compositor draws. An image is read from the
|
||||
* guest's buffer when its serial moves, and encoded once per distinct
|
||||
* content: a client that re-commits the same arrow on every motion costs a
|
||||
* hash, not a PNG. */
|
||||
#cursorCss = '';
|
||||
#cursorImages = new Map(); // content hash -> css, or a promise of it
|
||||
|
||||
#updateCursor(byKey) {
|
||||
let css = 'default';
|
||||
const overKey = this.#direct ? this.#direct.k : this.#drag ? null : this.#pointer.over;
|
||||
const over = overKey ? this.#windows.get(overKey) : null;
|
||||
if (over) {
|
||||
let cur = null;
|
||||
for (const [k, w] of this.#windows) {
|
||||
if (w.role === ROLE_CURSOR && w.token === over.token && w.client === over.client) {
|
||||
cur = [k, w];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cur) css = 'none';
|
||||
else {
|
||||
const got = this.#cursorImage(byKey.get(cur[0]), cur[1]);
|
||||
if (got === null) return; // still encoding: keep what is shown
|
||||
css = got;
|
||||
}
|
||||
}
|
||||
const [k, win] = found;
|
||||
const s = byKey.get(k);
|
||||
if (!s || !s.addr || s.serial === this.#cursorSerial) return;
|
||||
this.#cursorSerial = s.serial;
|
||||
if (css === this.#cursorCss) return;
|
||||
this.#cursorCss = css;
|
||||
post({ kind: 'cursor', css });
|
||||
}
|
||||
|
||||
/** The CSS for a cursor surface's image, or null while it is encoded. */
|
||||
#cursorImage(s, win) {
|
||||
if (!s || !s.addr || s.width <= 0 || s.height <= 0 || s.width > 256 || s.height > 256) return 'none';
|
||||
if (win.cursorSerial === s.serial && win.cursorAddr === s.addr && win.cursorCss) return win.cursorCss;
|
||||
const mem = this.#memoryOf(s.token);
|
||||
if (!mem || s.width <= 0 || s.height <= 0 || s.width > 256 || s.height > 256) return;
|
||||
if (!mem || s.addr + (s.height - 1) * s.stride + s.width * 4 > mem.length) return 'none';
|
||||
const px = new Uint8ClampedArray(s.width * s.height * 4);
|
||||
const opaque = s.format === 1;
|
||||
let h = 2166136261 ^ s.width ^ (s.height << 12) ^ (win.hotX << 20) ^ (win.hotY << 26) ^ (win.bs << 8);
|
||||
for (let y = 0; y < s.height; y++) {
|
||||
for (let x = 0; x < s.width; x++) {
|
||||
const i = s.addr + y * s.stride + x * 4;
|
||||
const o = (y * s.width + x) * 4;
|
||||
px[o] = mem[i + 2]; px[o + 1] = mem[i + 1]; px[o + 2] = mem[i]; px[o + 3] = win.info?.format === 1 ? 255 : mem[i + 3];
|
||||
px[o] = mem[i + 2]; px[o + 1] = mem[i + 1]; px[o + 2] = mem[i]; px[o + 3] = opaque ? 255 : mem[i + 3];
|
||||
h = Math.imul(h ^ (px[o] | (px[o + 1] << 8) | (px[o + 2] << 16) | (px[o + 3] << 24)), 16777619);
|
||||
}
|
||||
}
|
||||
const cv = new OffscreenCanvas(s.width, s.height);
|
||||
cv.getContext('2d').putImageData(new ImageData(px, s.width, s.height), 0, 0);
|
||||
const hx = Math.max(0, win.relX | 0), hy = Math.max(0, win.relY | 0);
|
||||
cv.convertToBlob().then((blob) => {
|
||||
post({ kind: 'cursor', css: `url(${URL.createObjectURL(blob)}) ${hx} ${hy}, default` });
|
||||
}).catch(() => {});
|
||||
const key = `${h >>> 0}`;
|
||||
const known = this.#cursorImages.get(key);
|
||||
if (typeof known === 'string') {
|
||||
win.cursorSerial = s.serial;
|
||||
win.cursorAddr = s.addr;
|
||||
win.cursorCss = known;
|
||||
return known;
|
||||
}
|
||||
if (!known) {
|
||||
const cv = new OffscreenCanvas(s.width, s.height);
|
||||
cv.getContext('2d').putImageData(new ImageData(px, s.width, s.height), 0, 0);
|
||||
const hx = Math.max(0, win.hotX | 0);
|
||||
const hy = Math.max(0, win.hotY | 0);
|
||||
const bs = win.bs || 1;
|
||||
this.#cursorImages.set(key, cv.convertToBlob().then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
// A buffer drawn at a scale is that many pixels per CSS pixel.
|
||||
const img = bs > 1 ? `-webkit-image-set(url(${url}) ${bs}x)` : `url(${url})`;
|
||||
this.#cursorImages.set(key, `${img} ${hx} ${hy}, default`);
|
||||
if (this.#cursorImages.size > 64) {
|
||||
for (const [k, v] of this.#cursorImages) {
|
||||
if (k === key) continue;
|
||||
if (typeof v === 'string') URL.revokeObjectURL(v.slice(v.indexOf('url(') + 4, v.indexOf(')')));
|
||||
this.#cursorImages.delete(k);
|
||||
if (this.#cursorImages.size <= 32) break;
|
||||
}
|
||||
}
|
||||
}).catch(() => this.#cursorImages.delete(key)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
frame() {
|
||||
const { byKey, blocks } = this.#reconcile();
|
||||
this.#updateCursor(byKey);
|
||||
const d = this.#directTarget();
|
||||
if (d) {
|
||||
this.#frameDirect(d, byKey, blocks);
|
||||
@@ -975,6 +1038,7 @@ class Compositor {
|
||||
if (this.#direct) this.#leaveDirect();
|
||||
// Upload every surface whose content moved on.
|
||||
for (const [k, s] of byKey) {
|
||||
if (s.role === ROLE_CURSOR) continue; // the page's cursor, not a texture
|
||||
const t = this.#textures.get(k);
|
||||
if (t && t.serial === s.serial) continue;
|
||||
this.#upload(k, s);
|
||||
@@ -1012,27 +1076,8 @@ class Compositor {
|
||||
const win = this.#windows.get(k);
|
||||
if (win) this.#drawSurface(k, win);
|
||||
}
|
||||
// The cursor the client asked for, at the pointer, offset by its hotspot.
|
||||
let drew = false;
|
||||
for (const [k, win] of this.#windows) {
|
||||
if (win.role !== ROLE_CURSOR) continue;
|
||||
const t = this.#textures.get(k);
|
||||
if (!t) continue;
|
||||
this.#painter.textured(
|
||||
this.#pointer.x - win.relX,
|
||||
this.#pointer.y - win.relY,
|
||||
win.width,
|
||||
win.height,
|
||||
t.tex,
|
||||
{ swizzle: 1 },
|
||||
);
|
||||
drew = true;
|
||||
break;
|
||||
}
|
||||
if (!drew) {
|
||||
const c = this.#cursorFallback;
|
||||
this.#painter.textured(this.#pointer.x, this.#pointer.y, c.w, c.h, c.tex);
|
||||
}
|
||||
// No cursor: the client's is the page's own CSS cursor (`#updateCursor`),
|
||||
// so there is one pointer on the screen and it moves at the browser's rate.
|
||||
this.#painter.end();
|
||||
for (const b of blocks) ackBlock(this.#channel, b.block, b.gen);
|
||||
this.#frames++;
|
||||
@@ -1100,8 +1145,9 @@ class Compositor {
|
||||
if (win.configuredAs === want) return;
|
||||
win.configuredAs = want;
|
||||
this.#note(`configure ${win.key} ${want}`);
|
||||
sendEvent(this.#channel, win.block, EV_CONFIGURE, fit.w, fit.h, 0, activated ? 1 : 0,
|
||||
win.client, win.surface);
|
||||
const k = win.cs || 1;
|
||||
sendEvent(this.#channel, win.block, EV_CONFIGURE, Math.floor(fit.w / k), Math.floor(fit.h / k), 0,
|
||||
activated ? 1 : 0, win.client, win.surface);
|
||||
}
|
||||
|
||||
#focusWindow(k) {
|
||||
@@ -1162,8 +1208,9 @@ class Compositor {
|
||||
}
|
||||
this.#pointer.over = over;
|
||||
if (!hit || hit.part !== 'content') return;
|
||||
sendEvent(this.#channel, hit.win.block, EV_MOTION, hit.local.x, hit.local.y, time, 0,
|
||||
hit.win.client, hit.win.surface);
|
||||
const k = hit.win.cs || 1;
|
||||
sendEvent(this.#channel, hit.win.block, EV_MOTION, Math.floor(hit.local.x / k), Math.floor(hit.local.y / k),
|
||||
time, 0, hit.win.client, hit.win.surface);
|
||||
}
|
||||
|
||||
pointerButton(button, pressed, time) {
|
||||
@@ -1215,8 +1262,9 @@ class Compositor {
|
||||
// The press lands where the pointer is, so the client's last motion has
|
||||
// to be this position -- a click without a motion in front of it would
|
||||
// otherwise arrive wherever the pointer was last seen by that client.
|
||||
sendEvent(this.#channel, hit.win.block, EV_MOTION, hit.local.x, hit.local.y, time, 0,
|
||||
hit.win.client, hit.win.surface);
|
||||
const k = hit.win.cs || 1;
|
||||
sendEvent(this.#channel, hit.win.block, EV_MOTION, Math.floor(hit.local.x / k),
|
||||
Math.floor(hit.local.y / k), time, 0, hit.win.client, hit.win.surface);
|
||||
}
|
||||
sendEvent(this.#channel, hit.win.block, EV_BUTTON, button, pressed ? 1 : 0, time, 0,
|
||||
hit.win.client, hit.win.surface);
|
||||
@@ -1301,6 +1349,7 @@ class Compositor {
|
||||
// The GL device's counters (submits, their time, readbacks): the
|
||||
// compositor side of a frame's cost.
|
||||
gl: glDevice?.glState?.() ?? null,
|
||||
turns,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1314,9 +1363,12 @@ let post = (m, transfer) => self.postMessage(m, transfer ?? []);
|
||||
let comp = null;
|
||||
let canvas = null;
|
||||
let running = false;
|
||||
/** Turns of the frame loop, for a test: a loop that stops reads as a still screen. */
|
||||
let turns = 0;
|
||||
|
||||
function loop() {
|
||||
if (!comp || !running) return;
|
||||
turns++;
|
||||
try {
|
||||
// A guest paces itself against frames *displayed*, not produced: its
|
||||
// `eglSwapBuffers` waits for this count to move. On the page that was the
|
||||
@@ -1480,12 +1532,13 @@ async function serveGlRing() {
|
||||
const onMessage = ({ data: m }) => {
|
||||
try {
|
||||
if (m.kind === 'init') {
|
||||
canvas = m.canvas;
|
||||
presentOverride = m.present ?? null;
|
||||
canvas = m.canvas;
|
||||
setUiScale(m.scale || 1);
|
||||
// The compositor draws with WebGPU on the device Mesa will use.
|
||||
if (m.glLimits != null) globalThis.GEX_GL_LIMITS = m.glLimits;
|
||||
requestGexDevice().then((gpu) => {
|
||||
comp = new Compositor(m.canvas, m.channel, m.stats ? new Int32Array(m.stats) : null, gpu);
|
||||
comp = new Compositor(canvas, m.channel, m.stats ? new Int32Array(m.stats) : null, gpu);
|
||||
comp.setDirect(m.direct);
|
||||
running = true;
|
||||
requestAnimationFrame(loop);
|
||||
|
||||
+12
-6
@@ -44,10 +44,13 @@ export class Compositor {
|
||||
#stats;
|
||||
|
||||
/** `makeWorker`: what stands in for compositor-worker.js's Worker (the
|
||||
* single-thread build's system Worker hosts it; see runtime.js). */
|
||||
static async create(canvas, channel, inputTarget = canvas, { makeWorker = null } = {}) {
|
||||
* single-thread build's system Worker hosts it; see runtime.js).
|
||||
* `scale`: the page's devicePixelRatio when the canvas's backing store is
|
||||
* in device pixels (the frames and titles the compositor draws are scaled
|
||||
* by it). */
|
||||
static async create(canvas, channel, inputTarget = canvas, { makeWorker = null, scale = 1 } = {}) {
|
||||
const c = new Compositor();
|
||||
await c.#start(canvas, channel, inputTarget, makeWorker);
|
||||
await c.#start(canvas, channel, inputTarget, makeWorker, { scale });
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -84,7 +87,7 @@ export class Compositor {
|
||||
});
|
||||
}
|
||||
|
||||
async #start(canvas, channel, inputTarget, makeWorker) {
|
||||
async #start(canvas, channel, inputTarget, makeWorker, { scale = 1 } = {}) {
|
||||
const sameTarget = inputTarget === canvas;
|
||||
// A canvas can be handed to a worker once and never again, and a page
|
||||
// that runs a session twice hands over the same element the second time:
|
||||
@@ -124,7 +127,9 @@ export class Compositor {
|
||||
if (m.kind === 'direct') {
|
||||
this.#setDirect(m.w ? { w: m.w, h: m.h } : null);
|
||||
} else if (m.kind === 'cursor') {
|
||||
if (this.#direct) this.#canvas.style.cursor = m.css;
|
||||
// The client's cursor, as the browser's own: one pointer, never a
|
||||
// second one drawn into the frame.
|
||||
this.#canvas.style.cursor = m.css;
|
||||
this.#cursorCss = m.css;
|
||||
} else if (m.kind === 'describe' || m.kind === 'wgsl-cache' || m.kind === 'readout') {
|
||||
this.#pending.get(m.id)?.(m.kind === 'wgsl-cache' ? { blob: m.blob, stats: m.stats } : m.state);
|
||||
@@ -147,7 +152,8 @@ export class Compositor {
|
||||
// 4.6 Muck's 3D scene drew black, so 43 is only per run.
|
||||
const glLimits = pq?.has('gllimits') ? Number(pq.get('gllimits')) || 0 : null;
|
||||
worker.postMessage(
|
||||
{ kind: 'init', canvas: offscreen, channel, stats: this.#stats, present, direct, glLimits },
|
||||
{ kind: 'init', canvas: offscreen, width: canvas.width, height: canvas.height, scale,
|
||||
channel, stats: this.#stats, present, direct, glLimits },
|
||||
[offscreen],
|
||||
);
|
||||
await ready;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
:root {
|
||||
--panel-h: 30px;
|
||||
--bg: #1d2128;
|
||||
--panel: #12151a;
|
||||
--panel-fg: #d8dde6;
|
||||
--muted: #8a93a3;
|
||||
--accent: #6ee7a8;
|
||||
--win: #232831;
|
||||
--win-title: #2c323d;
|
||||
--border: #3a4250;
|
||||
--hover: #323a47;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--panel-fg);
|
||||
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
#panel {
|
||||
position: fixed;
|
||||
inset: 0 0 auto 0;
|
||||
height: var(--panel-h);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid #000;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
.pbtn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--panel-fg);
|
||||
font: inherit;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pbtn:hover, .pbtn.on { background: var(--hover); }
|
||||
#apps { font-weight: 600; }
|
||||
#tasks { flex: 1; display: flex; gap: 4px; overflow: hidden; }
|
||||
#pstatus { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 40vw; }
|
||||
#clock { color: var(--muted); font-variant-numeric: tabular-nums; padding-left: 6px; }
|
||||
#screen {
|
||||
position: fixed;
|
||||
inset: var(--panel-h) 0 0 0;
|
||||
background: radial-gradient(ellipse at 30% 20%, #2a3140 0%, #1a1e25 60%, #14171c 100%);
|
||||
}
|
||||
#out { display: block; width: 100%; height: 100%; outline: none; }
|
||||
#splash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(16, 19, 24, 0.85);
|
||||
z-index: 5;
|
||||
}
|
||||
#splash.gone { display: none; }
|
||||
.splash-card { width: min(360px, 80vw); text-align: center; }
|
||||
.logo { font-size: 28px; font-weight: 700; letter-spacing: 0.02em; margin-bottom: 10px; }
|
||||
#splash-text { color: var(--muted); min-height: 1.4em; }
|
||||
.bar { height: 4px; background: #2a303a; border-radius: 2px; margin-top: 10px; overflow: hidden; }
|
||||
#splash-bar { height: 100%; width: 0; background: var(--accent); transition: width 0.15s; }
|
||||
.dwin {
|
||||
position: fixed;
|
||||
background: var(--win);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dwin.hidden { display: none; }
|
||||
.dwin-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 6px 5px 12px;
|
||||
background: var(--win-title);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dwin-close {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dwin-close:hover { background: #c94f4f; color: #fff; }
|
||||
.dwin-body { flex: 1; overflow: auto; }
|
||||
#launcher { width: 340px; max-height: calc(100vh - 80px); }
|
||||
.lhint { color: var(--muted); margin: 10px 12px 4px; font-size: 12px; }
|
||||
#items { list-style: none; margin: 0; padding: 4px 6px 8px; }
|
||||
#items li {
|
||||
display: grid;
|
||||
grid-template-columns: 34px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#items li:hover { background: var(--hover); }
|
||||
#items li.busy { cursor: progress; }
|
||||
.icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: #10131a;
|
||||
}
|
||||
.iname { font-weight: 600; }
|
||||
.idesc { color: var(--muted); font-size: 12px; }
|
||||
.istate { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
|
||||
.istate.ok { color: var(--accent); }
|
||||
.istate.bad { color: #f07a7a; }
|
||||
#console { width: min(760px, 90vw); height: 380px; }
|
||||
#console .dwin-body { background: #0b0d10; padding: 4px; overflow: hidden; }
|
||||
#term { width: 100%; height: 100%; }
|
||||
@media (max-width: 600px) {
|
||||
#pstatus { display: none; }
|
||||
#launcher { width: calc(100vw - 32px); }
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
// The demo desktop (docs/gexcc.md §9): one page that is a desktop.
|
||||
//
|
||||
// The compositor's output fills the screen; the panel, the launcher and the
|
||||
// console are the page's own DOM, so the shell chrome costs the guest's
|
||||
// rootfs nothing. At start two windows are open: an xterm running bash (on the
|
||||
// X server in this page) and the launcher. pid 1 is bash reading command lines
|
||||
// from the page's terminal -- the console -- and the launcher starts a program
|
||||
// by typing its command line there, after adding its files to the running
|
||||
// system (overlay-worker.js).
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import './desktop.css';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { loadRuntime, fetchBytes, runProgram } from './runtime.js';
|
||||
import { untar } from './tar.js';
|
||||
import { unzstd } from './zfetch.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const q = new URLSearchParams(location.search);
|
||||
// `?strace=1` / `?stracepid=N`: narrate system calls (to the console), as
|
||||
// the other pages do.
|
||||
if (q.get('strace')) globalThis.GEX_STRACE = 1;
|
||||
if (q.has('stracepid')) globalThis.GEX_STRACE_PID = Number(q.get('stracepid'));
|
||||
const T0 = performance.now();
|
||||
const marks = {};
|
||||
const mark = (k) => {
|
||||
marks[k] = Math.round(performance.now() - T0);
|
||||
};
|
||||
globalThis.gexDesktop = { marks, ready: false };
|
||||
|
||||
// The page-wide unit cache, as the demo page has it (main.js).
|
||||
globalThis.GEX_UNIT_CACHE = q.has('unitcache') ? Number(q.get('unitcache')) : 1;
|
||||
if (q.get('debug')) {
|
||||
globalThis.GEX_DEBUG_EXIT = 1;
|
||||
globalThis.GEX_TRAP_STACK = 1;
|
||||
}
|
||||
|
||||
// -- chrome -------------------------------------------------------------------
|
||||
|
||||
const kib = (n) => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
|
||||
function splash(text, frac) {
|
||||
$('splash-text').textContent = text;
|
||||
if (frac !== undefined) $('splash-bar').style.width = `${Math.round(Math.min(1, frac) * 100)}%`;
|
||||
}
|
||||
function status(text) {
|
||||
$('pstatus').textContent = text;
|
||||
}
|
||||
setInterval(() => {
|
||||
$('clock').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}, 1000);
|
||||
$('clock').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
/** A page-side window: draggable by its title, closed by its button. */
|
||||
let zTop = 20;
|
||||
function dwin(el, { x, y, onClose } = {}) {
|
||||
if (x !== undefined) {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
}
|
||||
const raise = () => {
|
||||
el.style.zIndex = String(++zTop);
|
||||
};
|
||||
el.addEventListener('pointerdown', raise);
|
||||
const title = el.querySelector('.dwin-title');
|
||||
title.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const dx = e.clientX - r.left;
|
||||
const dy = e.clientY - r.top;
|
||||
title.setPointerCapture(e.pointerId);
|
||||
const move = (m) => {
|
||||
el.style.left = `${Math.max(0, Math.min(innerWidth - 60, m.clientX - dx))}px`;
|
||||
el.style.top = `${Math.max(30, Math.min(innerHeight - 30, m.clientY - dy))}px`;
|
||||
};
|
||||
const up = () => {
|
||||
title.removeEventListener('pointermove', move);
|
||||
title.removeEventListener('pointerup', up);
|
||||
};
|
||||
title.addEventListener('pointermove', move);
|
||||
title.addEventListener('pointerup', up);
|
||||
});
|
||||
el.querySelector('.dwin-close').addEventListener('click', () => {
|
||||
el.classList.add('hidden');
|
||||
onClose?.();
|
||||
});
|
||||
return {
|
||||
show() {
|
||||
el.classList.remove('hidden');
|
||||
raise();
|
||||
},
|
||||
hide() {
|
||||
el.classList.add('hidden');
|
||||
},
|
||||
toggle() {
|
||||
if (el.classList.contains('hidden')) this.show();
|
||||
else this.hide();
|
||||
return !el.classList.contains('hidden');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const launcherWin = dwin($('launcher'), {
|
||||
x: Math.max(16, innerWidth - 340 - 40),
|
||||
y: 60,
|
||||
onClose: () => $('apps').classList.remove('on'),
|
||||
});
|
||||
const consoleWin = dwin($('console'), {
|
||||
x: 60,
|
||||
y: Math.max(60, innerHeight - 440),
|
||||
onClose: () => $('console-btn').classList.remove('on'),
|
||||
});
|
||||
$('apps').addEventListener('click', () => $('apps').classList.toggle('on', launcherWin.toggle()));
|
||||
|
||||
// The console: pid 1's terminal. Hidden unless asked for.
|
||||
const term = new Terminal({
|
||||
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
convertEol: false,
|
||||
scrollback: 5000,
|
||||
theme: { background: '#0b0d10', foreground: '#d6dbe4' },
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open($('term'));
|
||||
$('console-btn').addEventListener('click', () => {
|
||||
const on = consoleWin.toggle();
|
||||
$('console-btn').classList.toggle('on', on);
|
||||
if (on) fit.fit();
|
||||
});
|
||||
window.gexTerminalText = () => {
|
||||
const b = term.buffer.active;
|
||||
const out = [];
|
||||
// A wrapped row continues the line above it, as it was written.
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
const l = b.getLine(i);
|
||||
const t = l?.translateToString(true) ?? '';
|
||||
if (l?.isWrapped && out.length) out[out.length - 1] += t;
|
||||
else out.push(t);
|
||||
}
|
||||
return out.join('\n');
|
||||
};
|
||||
|
||||
// -- the launcher ---------------------------------------------------------------
|
||||
|
||||
/** What the launcher offers, from the build's pkgs/index.json. */
|
||||
async function catalogue() {
|
||||
try {
|
||||
const r = await fetch('/pkgs/index.json', { cache: 'no-cache' });
|
||||
if (r.ok && (r.headers.get('content-type') || '').includes('json')) return await r.json();
|
||||
} catch {}
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
let system = null;
|
||||
let sysReady = null;
|
||||
const sysUp = new Promise((r) => (sysReady = r));
|
||||
const installed = new Set();
|
||||
const installing = new Map();
|
||||
let overlayWorker = null;
|
||||
let overlayStack;
|
||||
const overlayWaiters = [];
|
||||
|
||||
function overlay() {
|
||||
if (overlayWorker) return overlayWorker;
|
||||
overlayWorker = new Worker(new URL('./overlay-worker.js', import.meta.url), { type: 'module' });
|
||||
overlayWorker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.progress) {
|
||||
const w = overlayWaiters.find((x) => x.name === m.progress);
|
||||
w?.onProgress?.(m.got, m.total);
|
||||
return;
|
||||
}
|
||||
const i = overlayWaiters.findIndex((x) => x.name === (m.done ?? m.name));
|
||||
if (i < 0) return;
|
||||
const [w] = overlayWaiters.splice(i, 1);
|
||||
if (m.error) w.reject(new Error(m.error));
|
||||
else w.resolve(m);
|
||||
};
|
||||
return overlayWorker;
|
||||
}
|
||||
|
||||
/** Add one package's overlay to the running system (once). */
|
||||
function install(item, cat, onProgress) {
|
||||
if (installed.has(item.id)) return Promise.resolve(null);
|
||||
if (installing.has(item.id)) return installing.get(item.id);
|
||||
const p = (async () => {
|
||||
for (const d of item.deps ?? []) {
|
||||
const dep = cat.items.find((x) => x.id === d) ?? cat.hidden?.find((x) => x.id === d);
|
||||
if (dep) await install(dep, cat, onProgress);
|
||||
}
|
||||
await sysUp;
|
||||
while (overlayStack === undefined) {
|
||||
overlayStack = system.host.takeKernelStack('overlay');
|
||||
if (overlayStack === undefined) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
const r = await new Promise((resolve, reject) => {
|
||||
overlayWaiters.push({ name: item.id, resolve, reject, onProgress });
|
||||
overlay().postMessage({
|
||||
spec: system.kernel,
|
||||
stackTop: overlayStack,
|
||||
url: `/pkgs/${item.pkg ?? item.id}.tar.zst`,
|
||||
name: item.id,
|
||||
});
|
||||
});
|
||||
installed.add(item.id);
|
||||
console.log(`[desktop] ${item.id}: ${r.files} files, ${kib(r.bytes)} from ${kib(r.compressed)}; ` +
|
||||
`fetch ${r.fetchMs} ms, decode ${r.decodeMs} ms, write ${r.writeMs} ms`);
|
||||
globalThis.gexDesktop.installs = { ...(globalThis.gexDesktop.installs ?? {}), [item.id]: r };
|
||||
return r;
|
||||
})();
|
||||
installing.set(item.id, p);
|
||||
p.finally(() => installing.delete(item.id));
|
||||
return p;
|
||||
}
|
||||
|
||||
/** `s` as one shell word. */
|
||||
const shq = (s) => `'${s.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
/** Type a command line to pid 1, which runs it in the background. */
|
||||
function run(cmd) {
|
||||
system.tty?.input(`${cmd}\n`);
|
||||
}
|
||||
globalThis.gexDesktop.run = (cmd) => run(cmd);
|
||||
|
||||
async function launch(item, li, cat) {
|
||||
const state = li.querySelector('.istate');
|
||||
if (li.classList.contains('busy')) return;
|
||||
li.classList.add('busy');
|
||||
state.className = 'istate';
|
||||
try {
|
||||
if (!installed.has(item.id)) {
|
||||
state.textContent = 'downloading';
|
||||
await install(item, cat, (got, total) => {
|
||||
state.textContent = total ? `${Math.round((100 * got) / total)}%` : kib(got);
|
||||
});
|
||||
}
|
||||
state.textContent = 'starting';
|
||||
await sysUp;
|
||||
// A GL program: the host's Mesa first (fetched on first use).
|
||||
if (item.gl) {
|
||||
state.textContent = 'starting GL';
|
||||
await system.startGl?.();
|
||||
}
|
||||
// In a session of its own, off pid 1's terminal: a program that takes
|
||||
// the terminal (gtk3-demo does) would otherwise leave pid 1 unable to
|
||||
// read the next command line. Its output goes to /tmp/launch-ID.log.
|
||||
run(`setsid -f sh -c ${shq(item.cmd)} </dev/null >/tmp/launch-${item.id}.log 2>&1`);
|
||||
state.textContent = 'open';
|
||||
state.className = 'istate ok';
|
||||
} catch (e) {
|
||||
state.textContent = 'failed';
|
||||
state.className = 'istate bad';
|
||||
state.title = String(e.message ?? e);
|
||||
console.error(e);
|
||||
} finally {
|
||||
li.classList.remove('busy');
|
||||
}
|
||||
}
|
||||
|
||||
async function fillLauncher() {
|
||||
const cat = await catalogue();
|
||||
const ul = $('items');
|
||||
ul.replaceChildren();
|
||||
for (const item of cat.items) {
|
||||
const li = document.createElement('li');
|
||||
li.dataset.id = item.id;
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'icon';
|
||||
icon.style.background = item.color ?? '#7aa2f7';
|
||||
icon.textContent = item.glyph ?? item.label.slice(0, 1).toUpperCase();
|
||||
const text = document.createElement('div');
|
||||
text.innerHTML = '<div class="iname"></div><div class="idesc"></div>';
|
||||
text.querySelector('.iname').textContent = item.label;
|
||||
text.querySelector('.idesc').textContent = item.desc ?? '';
|
||||
const state = document.createElement('div');
|
||||
state.className = 'istate';
|
||||
state.textContent = item.size ? kib(item.size) : '';
|
||||
if (item.base) {
|
||||
installed.add(item.id);
|
||||
state.textContent = 'installed';
|
||||
}
|
||||
li.append(icon, text, state);
|
||||
li.addEventListener('click', () => launch(item, li, cat));
|
||||
ul.append(li);
|
||||
}
|
||||
globalThis.gexDesktop.launch = (id) => {
|
||||
const li = ul.querySelector(`li[data-id="${id}"]`);
|
||||
const item = cat.items.find((x) => x.id === id);
|
||||
return item && li ? launch(item, li, cat) : Promise.reject(new Error(`no item ${id}`));
|
||||
};
|
||||
globalThis.gexDesktop.install = (id) => {
|
||||
const item = cat.items.find((x) => x.id === id) ?? cat.hidden?.find((x) => x.id === id);
|
||||
return item ? install(item, cat) : Promise.reject(new Error(`no item ${id}`));
|
||||
};
|
||||
return cat;
|
||||
}
|
||||
|
||||
// -- boot ---------------------------------------------------------------------------
|
||||
|
||||
/** The page's density: the output is in device pixels (see `boot`). */
|
||||
const DPR = Math.max(1, Number(q.get('dpr')) || globalThis.devicePixelRatio || 1);
|
||||
|
||||
/** X resources for this density. The X server's screen is in device pixels
|
||||
* (X clients draw 1:1, never resampled), so at a high density an X client
|
||||
* is told the DPI to size its fonts by, and xterm draws with an outline
|
||||
* font instead of the 13-pixel bitmap `fixed`, which would be half size. */
|
||||
function xdefaults() {
|
||||
if (DPR < 1.25) return '';
|
||||
return [
|
||||
`Xft.dpi: ${Math.round(96 * DPR)}`,
|
||||
'Xft.antialias: true',
|
||||
'Xft.hinting: true',
|
||||
'Xft.hintstyle: hintslight',
|
||||
'Xft.rgba: none',
|
||||
'XTerm*faceName: DejaVu Sans Mono',
|
||||
'XTerm*faceSize: 9',
|
||||
`Xcursor.size: ${Math.round(16 * DPR)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** The guest's session: pid 1. */
|
||||
function session(cat) {
|
||||
const xterm = q.get('xterm') ?? cat.xterm ?? 'xterm -geometry 92x30+24+24 -e bash -l';
|
||||
return [
|
||||
'# The desktop session (web/src/desktop.js): pid 1.',
|
||||
'export DISPLAY=:0 XDG_RUNTIME_DIR=/tmp WAYLAND_DISPLAY=wayland-0',
|
||||
'export HOME=/root SHELL=/bin/bash TERM=xterm-256color LANG=C.UTF-8',
|
||||
'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
'cd /root',
|
||||
`${xterm} &`,
|
||||
'# The launcher types a command line here; each runs in the background.',
|
||||
'while IFS= read -r line; do',
|
||||
' [ -n "$line" ] || continue',
|
||||
' ( eval "$line" ) &',
|
||||
'done',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const canvas = $('out');
|
||||
// The backing store in device pixels, so nothing the compositor shows is
|
||||
// stretched by the browser: at devicePixelRatio 2 a canvas sized in CSS
|
||||
// pixels was every window's pixels doubled and smoothed.
|
||||
const deviceSize = () => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return [Math.max(320, Math.round(r.width * DPR)), Math.max(240, Math.round(r.height * DPR))];
|
||||
};
|
||||
[canvas.width, canvas.height] = deviceSize();
|
||||
|
||||
splash('fetching the runtime', 0);
|
||||
const rt = loadRuntime((got, total) => {
|
||||
splash(`fetching the runtime — ${kib(got)}${total ? ` of ${kib(total)}` : ''}`, total ? (0.5 * got) / total : undefined);
|
||||
});
|
||||
const catP = fillLauncher();
|
||||
const fsUrl = q.get('rootfs') || '/rootfs/base.tar.zst';
|
||||
const fsP = fetchBytes(fsUrl).then(async (z) => {
|
||||
mark('rootfsFetched');
|
||||
const raw = fsUrl.endsWith('.zst') ? await unzstd(z) : z;
|
||||
return { tree: untar(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength)), z: z.length, raw: raw.length };
|
||||
});
|
||||
// The network (Wisp), for apt and anything else that reaches out: started
|
||||
// beside the downloads, and the desktop does not wait for a slow proxy
|
||||
// past the rootfs. `?wisp=` picks the proxy, `?wisp=` empty turns it off.
|
||||
const wisp = q.has('wisp') ? q.get('wisp') : 'wss://anura.pro/';
|
||||
const netP = wisp
|
||||
? import('./net.js').then(async ({ WispNetwork }) => {
|
||||
const n = new WispNetwork(wisp, (e) => status(`network: ${e.split(' ')[0]}`));
|
||||
await n.start();
|
||||
mark('network');
|
||||
return n;
|
||||
}).catch((e) => {
|
||||
console.warn(`[desktop] no network: ${e.message ?? e}`);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
await rt;
|
||||
mark('runtime');
|
||||
splash('fetching the system', 0.6);
|
||||
const [{ tree, z, raw }, cat] = await Promise.all([fsP, catP]);
|
||||
const network = await Promise.race([netP, new Promise((r) => setTimeout(() => r(null), 3000))]);
|
||||
if (!network) status('network: off');
|
||||
mark('rootfs');
|
||||
globalThis.gexDesktop.rootfs = { compressed: z, bytes: raw, files: Object.keys(tree.files).length };
|
||||
splash('starting X and the shell', 0.8);
|
||||
const files = { ...tree.files, '/etc/gex-desktop/session': new TextEncoder().encode(session(cat)) };
|
||||
const xd = xdefaults();
|
||||
if (xd) files['/root/.Xdefaults'] = new TextEncoder().encode(xd);
|
||||
const elf = files['/usr/bin/bash'];
|
||||
if (!elf) throw new Error(`${fsUrl} has no /usr/bin/bash`);
|
||||
|
||||
// Both windows are up when the compositor has mapped one (the xterm: the
|
||||
// launcher is the page's own).
|
||||
const watch = setInterval(() => {
|
||||
const c = system?.compositor?.counters?.();
|
||||
if (c && c.windows >= 1) {
|
||||
clearInterval(watch);
|
||||
mark('desktop');
|
||||
$('splash').classList.add('gone');
|
||||
launcherWin.show();
|
||||
$('apps').classList.add('on');
|
||||
status('');
|
||||
globalThis.gexDesktop.ready = true;
|
||||
console.log(`[desktop] up: ${JSON.stringify(marks)}`);
|
||||
// The `auto` items (the core tools) come in behind the desktop, so the
|
||||
// xterm's shell has `ls` a moment later without the base carrying it.
|
||||
if (!q.has('noauto')) {
|
||||
for (const item of cat.items.filter((x) => x.auto)) {
|
||||
const li = $('items').querySelector(`li[data-id="${item.id}"]`);
|
||||
const st = li?.querySelector('.istate');
|
||||
if (st) st.textContent = 'adding';
|
||||
install(item, cat)
|
||||
.then(() => {
|
||||
if (st) { st.textContent = 'installed'; st.className = 'istate ok'; }
|
||||
mark(`auto:${item.id}`);
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
const { status: st } = await runProgram(term, {
|
||||
elf,
|
||||
files,
|
||||
dirs: tree.dirs,
|
||||
modes: tree.modes,
|
||||
hard: tree.hard,
|
||||
symlinks: tree.links,
|
||||
env: [
|
||||
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
'HOME=/root',
|
||||
'TERM=xterm-256color',
|
||||
'LANG=C.UTF-8',
|
||||
'DEBIAN_FRONTEND=noninteractive',
|
||||
],
|
||||
args: ['/bin/bash', '/etc/gex-desktop/session'],
|
||||
wl: canvas,
|
||||
wlInput: canvas,
|
||||
wlScale: DPR,
|
||||
hostX: true,
|
||||
// GL programs marshal to Mesa in the compositor's Worker, started on the
|
||||
// first GL item (`startGl`), so the first load does not carry it.
|
||||
gl: 'compositor',
|
||||
glLazy: true,
|
||||
cwd: '/root',
|
||||
tty: true,
|
||||
uid: 0,
|
||||
network,
|
||||
onSystem: (s) => {
|
||||
system = s;
|
||||
mark('system');
|
||||
// The output follows the window.
|
||||
let pending = 0;
|
||||
addEventListener('resize', () => {
|
||||
clearTimeout(pending);
|
||||
pending = setTimeout(() => s.compositor?.resize(...deviceSize()), 150);
|
||||
});
|
||||
sysReady(s);
|
||||
},
|
||||
});
|
||||
clearInterval(watch);
|
||||
status(`session ended (${st})`);
|
||||
}
|
||||
|
||||
// `?diag=1`: what the compositor drew and what the page's canvas holds, twice
|
||||
// a second, in a corner. A screen that does not change while "drawn" counts
|
||||
// up and "held" changes is a browser not displaying frames the page has; a
|
||||
// "drawn" that stays put is the compositor not seeing the client's frames.
|
||||
// The corner box repaints the page twice a second, which may itself bring a
|
||||
// stalled canvas up to date; `?diag=log` says the same in the console
|
||||
// instead, every 2 s, and changes nothing on the page.
|
||||
if (q.get('diag')) {
|
||||
const quiet = q.get('diag') === 'log';
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = 'position:fixed;right:8px;bottom:8px;z-index:9;font:12px monospace;'
|
||||
+ 'background:#000c;color:#9f9;padding:4px 6px;pointer-events:none;white-space:pre';
|
||||
if (!quiet) document.body.append(box);
|
||||
let lastHeld = '';
|
||||
let ticks = 0;
|
||||
let heldChanges = 0;
|
||||
setInterval(() => {
|
||||
const comp = globalThis.__gexCompositor;
|
||||
const c = document.getElementById('out');
|
||||
let held = '-';
|
||||
try {
|
||||
const t = new OffscreenCanvas(c.width, c.height);
|
||||
const g = t.getContext('2d', { willReadFrequently: true });
|
||||
g.drawImage(c, 0, 0);
|
||||
const d = g.getImageData(0, 0, c.width, c.height).data;
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < d.length; i += 16) h = Math.imul(h ^ d[i] ^ (d[i + 1] << 8) ^ (d[i + 2] << 16), 16777619) >>> 0;
|
||||
held = h.toString(16);
|
||||
} catch (e) {
|
||||
held = `! ${e?.message ?? e}`;
|
||||
}
|
||||
if (held !== lastHeld) heldChanges++;
|
||||
lastHeld = held;
|
||||
const n = comp?.counters?.() ?? {};
|
||||
box.textContent = `drawn ${n.frames ?? '-'} held ${held} (${heldChanges} changes) ${c.width}x${c.height} dpr ${devicePixelRatio}`;
|
||||
if (quiet && ticks++ % 4 === 0) console.log(`[diag] ${box.textContent}`);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
boot().catch((e) => {
|
||||
console.error(e);
|
||||
splash(`could not start: ${e.message ?? e}`);
|
||||
status('failed');
|
||||
globalThis.gexDesktop.error = String(e?.stack ?? e);
|
||||
});
|
||||
+65
-4
@@ -95,6 +95,24 @@ export class HostX {
|
||||
#fds = new Map();
|
||||
/** Pools of pixels, by descriptor: where they are in the server's memory. */
|
||||
#pools = new Map();
|
||||
/**
|
||||
* The pools the compositor holds, by its own handle for each: what a
|
||||
* descriptor sent on the Wayland connection became when it arrived.
|
||||
*
|
||||
* A descriptor is the server's name for a pool only until the server closes
|
||||
* it, and Xwayland closes a pool's descriptor as soon as it has sent it --
|
||||
* then the next pool it makes gets the same number. Handing the compositor
|
||||
* the number made every buffer of the old pool resolve, at its next commit,
|
||||
* to whichever pool had the number now. Typing into the xterm reuses its
|
||||
* two buffers, and with both pools on one number both showed the second's
|
||||
* memory: the window was a frame behind what it drew until something made
|
||||
* a fresh pool (clicking off it and back on did). A cursor pool on the
|
||||
* number was too small to hold the frame, and the commit was dropped. Over
|
||||
* a socket `SCM_RIGHTS` gives the receiver its own descriptor for the same
|
||||
* memory, and this is that: a handle per descriptor sent, never reused.
|
||||
*/
|
||||
#held = new Map();
|
||||
#nextHeld = 1;
|
||||
/**
|
||||
* Descriptors owed to the server on its Wayland connection.
|
||||
*
|
||||
@@ -117,10 +135,17 @@ export class HostX {
|
||||
#trace = 0;
|
||||
/** Publishes so far, for the note in `#tick`. */
|
||||
#published = 0;
|
||||
/**
|
||||
* `?hostxcount=1`: once a second, what moved through the server -- bytes in
|
||||
* from and out to each X client, bytes to the compositor, scenes published.
|
||||
* Where a picture stops between a client drawing and the page composing it.
|
||||
*/
|
||||
#count = null;
|
||||
|
||||
async start(msg) {
|
||||
this.#channel = msg.wlChannel;
|
||||
this.#trace = msg.trace | 0;
|
||||
if (msg.count) this.#count = { at: Date.now(), n: new Map(), polls: 0 };
|
||||
this.#token = msg.token;
|
||||
const size = outputSize(msg.wlChannel);
|
||||
this.#out = { w: size.w, h: size.h };
|
||||
@@ -333,7 +358,7 @@ export class HostX {
|
||||
|
||||
/** Where the pool this descriptor names lives, for the compositor. */
|
||||
#locate(fd, out) {
|
||||
const pool = this.#pools.get(fd);
|
||||
const pool = this.#held.get(fd);
|
||||
this.#hwView();
|
||||
const w = this.#hwWords;
|
||||
if (!pool) {
|
||||
@@ -342,7 +367,7 @@ export class HostX {
|
||||
// healthy -- the surface is there, the commits arrive, the scene is
|
||||
// published with an address of zero.
|
||||
this.#once(`pool:${fd}`, `[hostx] descriptor ${fd} is not a pool`
|
||||
+ ` (pools: ${[...this.#pools.keys()].join(',') || 'none'})`);
|
||||
+ ` (held: ${this.#held.size})`);
|
||||
w[out >> 2] = 0;
|
||||
w[(out >> 2) + 1] = 0;
|
||||
return 0;
|
||||
@@ -399,6 +424,9 @@ export class HostX {
|
||||
// reading them. So the allocation stays -- freeing it here is the one
|
||||
// way to make an X window's contents vanish mid-frame.
|
||||
self.#fds.delete(fd);
|
||||
// The server's name for a pool goes with the descriptor; the compositor
|
||||
// has its own (`#held`), and the memory stays (see above).
|
||||
self.#pools.delete(fd);
|
||||
try {
|
||||
self.#x.FS.close(self.#x.FS.getStream(fd));
|
||||
} catch {
|
||||
@@ -442,7 +470,12 @@ export class HostX {
|
||||
send(fd, buf, len, fds, nfds) {
|
||||
const ep = self.#fds.get(fd);
|
||||
if (!ep) return -EBADF;
|
||||
if (ep.kind === 'kernel') return self.#k.k_sock_write(ep.id, buf, len);
|
||||
if (ep.kind === 'kernel') {
|
||||
const r = self.#k.k_sock_write(ep.id, buf, len);
|
||||
if (self.#count && r > 0) self.#bump(`out${fd}`, r);
|
||||
return r;
|
||||
}
|
||||
if (self.#count) self.#bump(ep.kind, len);
|
||||
if (ep.kind === 'wl' || ep.kind === 'wm') {
|
||||
// Into the compositor module's memory, which is a different address
|
||||
// space: a protocol message, never a frame.
|
||||
@@ -450,7 +483,17 @@ export class HostX {
|
||||
const p = self.#hw.hw_scratch(pad + nfds * 4);
|
||||
self.#hwView().set(self.#x.HEAPU8.subarray(buf, buf + len), p);
|
||||
for (let i = 0; i < nfds; i++) {
|
||||
self.#hwWords[((p + pad) >> 2) + i] = self.#x.HEAP32[(fds >> 2) + i];
|
||||
// The compositor's own reference to the pool (see `#held`), not
|
||||
// the server's descriptor number, which the server is about to
|
||||
// close and reuse.
|
||||
const sent = self.#x.HEAP32[(fds >> 2) + i];
|
||||
const pool = ep.kind === 'wl' ? self.#pools.get(sent) : null;
|
||||
let word = sent;
|
||||
if (pool) {
|
||||
word = self.#nextHeld++;
|
||||
self.#held.set(word, pool);
|
||||
}
|
||||
self.#hwWords[((p + pad) >> 2) + i] = word;
|
||||
}
|
||||
if (nfds && self.#trace) {
|
||||
const got = [];
|
||||
@@ -472,6 +515,7 @@ export class HostX {
|
||||
if (nfds) self.#x.HEAP32[nfds >> 2] = 0;
|
||||
if (ep.kind === 'kernel') {
|
||||
const n = self.#k.k_sock_read(ep.id, buf, len);
|
||||
if (self.#count && n > 0) self.#bump(`in${fd}`, n);
|
||||
return n;
|
||||
}
|
||||
if (ep.kind === 'wl' || ep.kind === 'wm') {
|
||||
@@ -583,11 +627,28 @@ export class HostX {
|
||||
this.log(`[hostx] keymap event: ${n} bytes as fd ${fd}`);
|
||||
}
|
||||
|
||||
/** `?hostxcount=1`: `n` more bytes under `key` this second. */
|
||||
#bump(key, n) {
|
||||
const m = this.#count.n;
|
||||
m.set(key, (m.get(key) ?? 0) + n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Between two turns of the server's loop: run the window manager, take the
|
||||
* page's input, publish the scene.
|
||||
*/
|
||||
#tick() {
|
||||
if (this.#count) {
|
||||
const c = this.#count;
|
||||
c.polls++;
|
||||
if (Date.now() - c.at >= 1000) {
|
||||
const parts = [...c.n].map(([k, v]) => `${k}=${v}`).join(' ');
|
||||
this.log(`[hostx-count] turns=${c.polls} pub=${this.#published} ${parts}`);
|
||||
c.n.clear();
|
||||
c.polls = 0;
|
||||
c.at = Date.now();
|
||||
}
|
||||
}
|
||||
// A hook for a harness: this is the only turn anything else gets once the
|
||||
// server's `main` has the thread. See web/test/hostx.mjs.
|
||||
globalThis.GEX_HOSTX_TICK?.();
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// A package overlay, written into the running system's filesystem.
|
||||
//
|
||||
// The demo desktop's launcher (web/src/desktop.js) adds a program by fetching
|
||||
// `pkgs/NAME.tar.zst` -- the program's closure minus the base rootfs, built by
|
||||
// web/build-demo.sh -- and writing its files straight into the global kernel's
|
||||
// filesystem while the guest runs. That is this Worker: an instance of the
|
||||
// global kernel of its own (as `persist-worker.js` is), calling `k_fs_*`
|
||||
// with the bytes in a scratch "process" memory. A Worker and not the page
|
||||
// because the kernel's locks may wait, and the page's thread cannot.
|
||||
//
|
||||
// The tar's `var/lib/gex-overlay/NAME.status` holds the dpkg status stanzas
|
||||
// of what it adds; they are appended to /var/lib/dpkg/status, so a later
|
||||
// `apt` or `dpkg -l` sees the program as installed.
|
||||
//
|
||||
// Message in: { spec: { module, memory }, stackTop, url, name }
|
||||
// Messages out: { progress, got, total }, { done, files, bytes, ms }, { error }
|
||||
|
||||
import { lazyFetch } from '../../shims/browser/js/lazy-fetch.mjs';
|
||||
import { untar } from './tar.js';
|
||||
import { isZstd, unzstd } from './zfetch.js';
|
||||
|
||||
const enc = new TextEncoder();
|
||||
let k = null;
|
||||
let mem = null;
|
||||
let view = null;
|
||||
|
||||
async function kernel(spec, stackTop) {
|
||||
if (k) return;
|
||||
let inst;
|
||||
for (const shared of [true, false]) {
|
||||
mem = new WebAssembly.Memory({ initial: 16, maximum: 65535, shared });
|
||||
try {
|
||||
inst = await WebAssembly.instantiate(spec.module, {
|
||||
env: { memory: spec.memory, process: mem, lazy_fetch: lazyFetch(spec.memory) },
|
||||
});
|
||||
break;
|
||||
} catch (e) {
|
||||
if (!shared) throw e;
|
||||
}
|
||||
}
|
||||
k = inst.exports;
|
||||
if (stackTop && k.__stack_pointer) k.__stack_pointer.value = stackTop | 0;
|
||||
}
|
||||
|
||||
function bytes(need) {
|
||||
const have = mem.buffer.byteLength;
|
||||
if (need > have) mem.grow(Math.ceil((need - have) / 65536));
|
||||
if (!view || view.buffer !== mem.buffer || view.length !== mem.buffer.byteLength) {
|
||||
view = new Uint8Array(mem.buffer);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Two strings (or a string and bytes) laid out at 0 and after it. */
|
||||
function put(a, b) {
|
||||
const pa = enc.encode(a);
|
||||
const pb = typeof b === 'string' ? enc.encode(b) : b;
|
||||
const at = (pa.length + 15) & ~15;
|
||||
const v = bytes(at + (pb?.length ?? 0) + 16);
|
||||
v.set(pa, 0);
|
||||
if (pb) v.set(pb, at);
|
||||
return [0, pa.length, at, pb?.length ?? 0];
|
||||
}
|
||||
|
||||
function readFile(path) {
|
||||
const [pp, pl] = put(path);
|
||||
const h = k.k_fs_open(pp, pl, 0n, 0);
|
||||
if (h < 0) return null;
|
||||
const parts = [];
|
||||
let off = 0;
|
||||
const at = 1 << 16;
|
||||
for (;;) {
|
||||
bytes(at + (1 << 20));
|
||||
const n = k.k_fs_read(h, BigInt(off), at, 1 << 20);
|
||||
if (n <= 0) break;
|
||||
parts.push(new Uint8Array(mem.buffer).slice(at, at + n));
|
||||
off += n;
|
||||
}
|
||||
k.k_fs_close(h);
|
||||
const out = new Uint8Array(off);
|
||||
let o = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, o);
|
||||
o += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function addFile(path, data) {
|
||||
const [pp, pl, dp, dl] = put(path, data);
|
||||
k.k_fs_unlink(pp, pl); // a symlink in the way would be written through
|
||||
k.k_fs_add(pp, pl, dp, dl);
|
||||
}
|
||||
|
||||
async function overlay({ url, name }) {
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${url}: ${res.status}`);
|
||||
const total = Number(res.headers.get('content-length')) || 0;
|
||||
const chunks = [];
|
||||
let got = 0;
|
||||
const reader = res.body.getReader();
|
||||
let last = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
got += value.length;
|
||||
if (performance.now() - last > 100) {
|
||||
last = performance.now();
|
||||
self.postMessage({ progress: name, got, total });
|
||||
}
|
||||
}
|
||||
let z = new Uint8Array(got);
|
||||
let o = 0;
|
||||
for (const c of chunks) {
|
||||
z.set(c, o);
|
||||
o += c.length;
|
||||
}
|
||||
const tFetched = performance.now();
|
||||
if (!isZstd(z)) throw new Error(`${url}: not a zstd file`);
|
||||
const raw = await unzstd(z);
|
||||
z = null;
|
||||
const tree = untar(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
|
||||
const tDecoded = performance.now();
|
||||
|
||||
let n = 0;
|
||||
let size = 0;
|
||||
for (const d of tree.dirs) {
|
||||
const [pp, pl] = put(d);
|
||||
k.k_fs_mkdir(pp, pl, 0o755);
|
||||
}
|
||||
let status = '';
|
||||
// Files fetched from the page as the guest reads them (`k_fs_add_lazy`):
|
||||
// [guest path, size, same-origin path]. The apt overlay's own .debs.
|
||||
const lazy = [];
|
||||
for (const [p, data] of Object.entries(tree.files)) {
|
||||
if (p.startsWith('/var/lib/gex-overlay/') && p.endsWith('.status')) {
|
||||
status += new TextDecoder().decode(data);
|
||||
}
|
||||
if (p.startsWith('/var/lib/gex-overlay/') && p.endsWith('.lazy')) {
|
||||
lazy.push(...JSON.parse(new TextDecoder().decode(data)));
|
||||
}
|
||||
addFile(p, data);
|
||||
if (tree.modes[p] !== undefined) {
|
||||
const [pp, pl] = put(p);
|
||||
k.k_fs_chmod(pp, pl, tree.modes[p]);
|
||||
}
|
||||
n++;
|
||||
size += data.length;
|
||||
}
|
||||
for (const [p, target] of Object.entries(tree.hard)) {
|
||||
const [tp, tl, pp, pl] = put(target, p);
|
||||
k.k_fs_unlink(pp, pl);
|
||||
k.k_fs_link(tp, tl, pp, pl);
|
||||
n++;
|
||||
}
|
||||
for (const [p, target] of Object.entries(tree.links)) {
|
||||
const [tp, tl, pp, pl] = put(target, p);
|
||||
k.k_fs_unlink(pp, pl);
|
||||
k.k_fs_symlink(tp, tl, pp, pl);
|
||||
n++;
|
||||
}
|
||||
for (const [p, size, url] of lazy) {
|
||||
const [pp, pl, up, ul] = put(p, url);
|
||||
k.k_fs_add_lazy(pp, pl, up, ul, BigInt(size));
|
||||
n++;
|
||||
}
|
||||
if (status) {
|
||||
const cur = readFile('/var/lib/dpkg/status') ?? new Uint8Array();
|
||||
const old = new TextDecoder().decode(cur);
|
||||
// Two overlays may carry the same package (a library both need): one
|
||||
// stanza per package and architecture, or dpkg refuses the database.
|
||||
const key = (st) => `${/^Package: (.*)$/m.exec(st)?.[1]}:${/^Architecture: (.*)$/m.exec(st)?.[1]}`;
|
||||
const have = new Set(old.split(/\n\n+/).filter((x) => x.trim()).map(key));
|
||||
const add = status.split(/\n\n+/).filter((x) => x.trim() && !have.has(key(x)));
|
||||
const text = old.replace(/\n*$/, '\n\n') + add.map((x) => `${x.replace(/\n+$/, '')}\n\n`).join('');
|
||||
addFile('/var/lib/dpkg/status', enc.encode(text));
|
||||
}
|
||||
k.k_wake?.();
|
||||
return {
|
||||
files: n,
|
||||
bytes: size,
|
||||
fetchMs: Math.round(tFetched - t0),
|
||||
decodeMs: Math.round(tDecoded - tFetched),
|
||||
writeMs: Math.round(performance.now() - tDecoded),
|
||||
ms: Math.round(performance.now() - t0),
|
||||
compressed: got,
|
||||
};
|
||||
}
|
||||
|
||||
let queue = Promise.resolve();
|
||||
self.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
queue = queue
|
||||
.then(async () => {
|
||||
await kernel(m.spec, m.stackTop);
|
||||
const r = await overlay(m);
|
||||
self.postMessage({ done: m.name, ...r });
|
||||
})
|
||||
.catch((err) => self.postMessage({ error: String(err?.stack ?? err), name: m.name }));
|
||||
};
|
||||
+52
-6
@@ -5,6 +5,8 @@
|
||||
// editing is the guest's choice (ICANON in its termios), and the page has to
|
||||
// follow it or every keystroke appears twice.
|
||||
|
||||
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
|
||||
import './zfetch.js';
|
||||
import { Env } from '../../shims/browser/js/gex.mjs';
|
||||
import { ProcessHost } from '../../shims/browser/js/host.mjs';
|
||||
import { PageTty } from '../../shims/browser/js/tty.mjs';
|
||||
@@ -556,6 +558,10 @@ async function runProgramHere(
|
||||
// one wl_shm frames landed on. There is one output now, so this is only a
|
||||
// fallback for where to attach the listeners.
|
||||
wlInput = null,
|
||||
// The page's devicePixelRatio when `wl`'s backing store is in device
|
||||
// pixels: the compositor scales its frames by it and guest Wayland
|
||||
// clients are told the rounded value as wl_output.scale.
|
||||
wlScale = 1,
|
||||
// Run the X server in the page instead of in a guest process: a Worker
|
||||
// holding `Xwayland.wasm` and the compositor beside it (see
|
||||
// `web/src/hostx.js`). Guest X clients reach it through the global
|
||||
@@ -610,6 +616,16 @@ async function runProgramHere(
|
||||
// page compiles its units while the bundle downloads), as [name, Module]
|
||||
// pairs every Worker takes instead of fetching and compiling its own.
|
||||
preModules = null,
|
||||
// Called once the system is up and before pid 1 starts, with what an
|
||||
// embedder needs to act on the running system from outside the guest:
|
||||
// the process host (kernel stack slices), the global kernel, the shared
|
||||
// terminal and the compositor. The demo desktop's launcher adds files
|
||||
// with it (web/src/overlay.js) and types commands to pid 1.
|
||||
onSystem = null,
|
||||
// With `gl: 'compositor'`: start the host's Mesa only when `startGl`
|
||||
// (handed to `onSystem`) is called -- a desktop whose first windows
|
||||
// draw no GL does not download it.
|
||||
glLazy = false,
|
||||
},
|
||||
) {
|
||||
const { shimBytes, seedBytes } = await loadRuntime();
|
||||
@@ -641,6 +657,8 @@ async function runProgramHere(
|
||||
// gated on `have_gl()`) and gles-gears died at `eglInitialize`/`ring_open`.
|
||||
let glChannel = null;
|
||||
let glServe = null;
|
||||
let startGl = null;
|
||||
let glStarted = null;
|
||||
if (gl && single.on) {
|
||||
// The single-thread build: no ring. Each process's `gl_submit` calls the
|
||||
// compositor's Mesa in this realm on its own memory (compositor-worker.js
|
||||
@@ -714,16 +732,38 @@ async function runProgramHere(
|
||||
}
|
||||
const canvas = typeof wl?.getContext === 'function' ? wl : null;
|
||||
if (canvas) {
|
||||
setOutputSize(wlChannel, canvas.width, canvas.height);
|
||||
setOutputSize(wlChannel, canvas.width, canvas.height, Math.max(1, Math.round(wlScale)));
|
||||
const { Compositor } = await import('./compositor.js');
|
||||
compositor = await Compositor.create(canvas, wlChannel, wlInput || canvas);
|
||||
compositor = await Compositor.create(canvas, wlChannel, wlInput || canvas, { scale: wlScale });
|
||||
globalThis.__gexCompositor = compositor;
|
||||
if (gl === 'compositor' && glChannel) {
|
||||
await startHostGl(compositor, glChannel, {
|
||||
const opts = {
|
||||
api: 'core',
|
||||
backend: glBackend,
|
||||
canvasBlit: new URLSearchParams(location.search).get('glcanvas') === '1',
|
||||
}, term);
|
||||
};
|
||||
// `glLazy`: Mesa (about 20 MB before compression) is fetched when
|
||||
// the embedder first asks (`onSystem`'s `startGl`), not at boot.
|
||||
startGl = () => (glStarted ??= startHostGl(compositor, glChannel, opts, term));
|
||||
if (!glLazy) await startGl();
|
||||
else if (glChannel.control && typeof Atomics.waitAsync === 'function') {
|
||||
// And by the guest: a GL program started from a shell, not from
|
||||
// the embedder's launcher, makes its first request into a ring
|
||||
// nothing serves yet -- and waits thirty seconds for the answer,
|
||||
// then dies. Its first request starts Mesa (gl-bridge.mjs STATE).
|
||||
const ctl = new Int32Array(glChannel.control);
|
||||
(async () => {
|
||||
while (!glStarted) {
|
||||
const st = Atomics.load(ctl, 0);
|
||||
if (st === 1) {
|
||||
startGl();
|
||||
break;
|
||||
}
|
||||
const w = Atomics.waitAsync(ctl, 0, st, 5000);
|
||||
if (w.async) await w.value;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,10 +796,14 @@ async function runProgramHere(
|
||||
// global kernel that only exists once the server has bound it.
|
||||
let listening = null;
|
||||
const up = new Promise((resolve) => (listening = resolve));
|
||||
const countX = !!flag('hostxcount');
|
||||
xServer.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.kind === 'log') term.writeln(m.text);
|
||||
else if (m.kind === 'wl-memory') compositor?.addMemory(m.token, m.memory);
|
||||
if (m.kind === 'log') {
|
||||
term.writeln(m.text);
|
||||
// `?hostxcount=1`: to the console too, timed, for a harness.
|
||||
if (countX) console.log(`${(performance.now() / 1000).toFixed(2)} ${m.text}`);
|
||||
} else if (m.kind === 'wl-memory') compositor?.addMemory(m.token, m.memory);
|
||||
else if (m.kind === 'ready') listening(true);
|
||||
else if (m.kind === 'failed') {
|
||||
term.writeln(`gex: the X server failed: ${m.why}`);
|
||||
@@ -784,6 +828,7 @@ async function runProgramHere(
|
||||
verbose: globalThis.GEX_HOSTX_VERBOSE || Number(new URLSearchParams(location.search).get('xverbose')) || undefined,
|
||||
trace: globalThis.GEX_HOSTX_TRACE || undefined,
|
||||
wlTrace: flag('wltrace') ? 1 : undefined,
|
||||
count: flag('hostxcount') ? 1 : undefined,
|
||||
});
|
||||
// Two modules and a kernel to instantiate; a slow machine takes a few
|
||||
// seconds. A run that goes ahead without the display is worse than one that
|
||||
@@ -1011,6 +1056,7 @@ async function runProgramHere(
|
||||
if (sharedTty) sharedTty.resize(rows, cols);
|
||||
else host.resize(rows, cols);
|
||||
});
|
||||
onSystem?.({ host, kernel, tty: sharedTty, compositor, startGl });
|
||||
try {
|
||||
const status = await host.start({
|
||||
elf,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// large module synchronously and make synchronous requests (the page refuses
|
||||
// both above 8 MB / at all), and the runtime does both; and because a guest
|
||||
// that runs long stalls this thread, not the page's input and drawing.
|
||||
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
|
||||
import './zfetch.js';
|
||||
import { enableSingle } from '../../shims/browser/js/single.mjs';
|
||||
|
||||
enableSingle();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// through the shared arena instead, which is exactly how a guest process's
|
||||
// compositor gets them too.
|
||||
|
||||
// Demo build: runtime .wasm as .wasm.zst (zfetch.js); a no-op elsewhere.
|
||||
import './zfetch.js';
|
||||
import { HostX } from './hostx.js';
|
||||
|
||||
let host = null;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// The demo build's runtime .wasm files, fetched as .wasm.zst.
|
||||
//
|
||||
// `web/build-demo.sh` ships every runtime module (shim, kernel, seed, Xwayland,
|
||||
// its compositor bridge, Mesa) only as `NAME.wasm.zst`: about a tenth of the
|
||||
// stripped size for the shim. Rather than teach each loader about it, the
|
||||
// demo build wraps `fetch` once per realm -- the page and each Worker that
|
||||
// fetches a module -- so a same-origin request for `X.wasm` asks for
|
||||
// `X.wasm.zst` and answers with the decoded bytes, typed application/wasm so
|
||||
// `instantiateStreaming` (emscripten's Xwayland glue) takes them as well.
|
||||
//
|
||||
// Off in every other build (`VITE_GEX_ZWASM` is set by build-demo.sh only), and
|
||||
// under Node, where there is no `import.meta.env`.
|
||||
|
||||
const ON = import.meta.env?.VITE_GEX_ZWASM === '1';
|
||||
|
||||
let decoder = null;
|
||||
async function zstd() {
|
||||
if (!decoder) {
|
||||
decoder = (async () => {
|
||||
const { ZSTDDecoder } = await import('../eject-site/vendor/zstddec-stream.mjs');
|
||||
const d = new ZSTDDecoder();
|
||||
await d.init();
|
||||
return d;
|
||||
})();
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
|
||||
/** zstd's frame magic, little-endian 0xFD2FB528. */
|
||||
export function isZstd(b) {
|
||||
return b.length > 4 && b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd;
|
||||
}
|
||||
|
||||
/** Decode zstd bytes (the shared decoder, initialised once per realm). */
|
||||
export async function unzstd(bytes) {
|
||||
return (await zstd()).decode(bytes);
|
||||
}
|
||||
|
||||
export function installZFetch() {
|
||||
if (!ON || globalThis.__gexZFetch || typeof fetch !== 'function') return;
|
||||
const plain = globalThis.fetch.bind(globalThis);
|
||||
globalThis.__gexZFetch = plain;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const href = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url;
|
||||
let u = null;
|
||||
try {
|
||||
u = new URL(href, globalThis.location?.href);
|
||||
} catch {}
|
||||
if (u && u.origin === globalThis.location?.origin && u.pathname.endsWith('.wasm')) {
|
||||
u.pathname += '.zst';
|
||||
const res = await plain(u.href, init);
|
||||
if (res.ok) {
|
||||
const z = new Uint8Array(await res.arrayBuffer());
|
||||
// A missing file answers index.html with a 200 (the static server's
|
||||
// fallback): only a zstd frame is taken as the module.
|
||||
if (isZstd(z)) {
|
||||
const body = await unzstd(z);
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/wasm', 'content-length': String(body.length) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return plain(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
installZFetch();
|
||||
+10
-1
@@ -24,7 +24,16 @@ export default defineConfig({
|
||||
// web/build-all.sh builds each flavour's dist from its own staged public
|
||||
// tree: GEX_PUBLIC_DIR (default public/) into GEX_OUT_DIR (default dist/).
|
||||
publicDir: process.env.GEX_PUBLIC_DIR || 'public',
|
||||
build: { target: 'es2022', chunkSizeWarningLimit: 2048, outDir: process.env.GEX_OUT_DIR || 'dist' },
|
||||
build: {
|
||||
target: 'es2022',
|
||||
chunkSizeWarningLimit: 2048,
|
||||
outDir: process.env.GEX_OUT_DIR || 'dist',
|
||||
// web/build-demo.sh: the desktop (desktop.html) is the demo build's only
|
||||
// page; every other build is the demo list (index.html).
|
||||
...(process.env.GEX_DEMO === '1'
|
||||
? { rollupOptions: { input: fileURLToPath(new URL('desktop.html', import.meta.url)) } }
|
||||
: {}),
|
||||
},
|
||||
// `worker.mjs` is an ES module with top-level await; the default `iife`
|
||||
// worker output cannot express that.
|
||||
worker: { format: 'es' },
|
||||
|
||||
Reference in new issue
Block a user