diff --git a/local/docs/GRUB-INTEGRATION-PLAN.md b/local/docs/GRUB-INTEGRATION-PLAN.md new file mode 100644 index 0000000000..c94e3f4e5f --- /dev/null +++ b/local/docs/GRUB-INTEGRATION-PLAN.md @@ -0,0 +1,391 @@ +# GRUB Integration Plan — Red Bear OS + +**Date:** 2026-04-17 +**Status:** Fully implemented (build-tested, not yet runtime boot-tested). ESP formatted as FAT32 +per UEFI spec. Both Phase 1 (post-build script) and Phase 2 (installer-native) are wired. +**Remaining:** Runtime UEFI boot validation in QEMU (`make all CONFIG_NAME=redbear-grub && make qemu`). +**Prerequisite:** The `grub` package is included in `redbear-grub.toml` for clean-tree builds. +**Approach:** Option A — GRUB as boot manager, chainloading Redox bootloader + +## Overview + +Add GNU GRUB as an optional boot manager for Red Bear OS. GRUB presents a menu +at boot and chainloads the existing Redox bootloader, which then boots the +kernel normally. This gives users: + +- Multi-boot capability alongside Linux, Windows, or other OSes +- Boot menu with timeout and manual selection +- Familiar GRUB rescue shell for debugging +- No changes to the Redox kernel, RedoxFS, or existing boot flow + +## Architecture + +``` +UEFI firmware + → EFI/BOOT/BOOTX64.EFI (GRUB standalone image) + → grub.cfg: default entry chainloads Redox bootloader + → EFI/REDBEAR/redbear.efi (Redox bootloader) + → Reads RedoxFS partition + → Loads kernel + → Boots Red Bear OS +``` + +### ESP Layout (GRUB mode) + +``` +EFI/ +├── BOOT/ +│ ├── BOOTX64.EFI ← GRUB (primary, loaded by UEFI firmware) +│ └── grub.cfg ← GRUB configuration +└── REDBEAR/ + └── redbear.efi ← Redox bootloader (chainload target) +``` + +### ESP Layout (default, no GRUB) + +``` +EFI/ +└── BOOT/ + └── BOOTX64.EFI ← Redox bootloader (unchanged) +``` + +## Why GRUB? + +1. **GRUB does not support RedoxFS.** Writing a GRUB filesystem module for + RedoxFS is high-risk, GPL-licensing-sensitive work. Chainloading avoids it. +2. **The Redox bootloader works.** It reads RedoxFS directly and boots the + kernel. No need to replicate that logic in GRUB. +3. **GRUB is universally understood.** System administrators know GRUB. A + `grub.cfg` is easier to customize than a custom bootloader. +4. **Multi-boot.** GRUB can boot Linux, Windows, and other OSes alongside + Red Bear OS without any changes to those systems. + +## GRUB Module Set + +The standalone EFI image includes these modules: + +| Module | Purpose | +|--------|---------| +| `part_gpt` | GPT partition table support | +| `part_msdos` | MBR partition table support | +| `fat` | FAT32 filesystem (ESP) | +| `ext2` | ext2/3/4 filesystem | +| `normal` | Normal mode (menu, scripting) | +| `configfile` | Load configuration files | +| `search` | Search for files/volumes | +| `search_fs_uuid` | Search by filesystem UUID | +| `search_label` | Search by volume label | +| `echo` | Print messages | +| `test` | Conditional expressions | +| `ls` | List files and devices | +| `cat` | Display file contents | +| `halt` | Shut down | +| `reboot` | Reboot | + +Note: `chainloader` is a built-in command in GRUB 2.12 (no separate module needed). + +Red Bear policy now requires a local `redoxfs.mod` artifact for GRUB builds. +The GRUB recipe resolves it in this order: +1. `local/recipes/core/grub/modules/redoxfs.mod` +2. `${COOKBOOK_SYSROOT}/usr/lib/grub/x86_64-efi/redoxfs.mod` + +If neither exists, the GRUB recipe fails fast. + +## GRUB Configuration + +The default `grub.cfg`: + +```cfg +# Red Bear OS GRUB Configuration +set default=0 +set timeout=5 + +menuentry "Red Bear OS" { + chainloader /EFI/REDBEAR/redbear.efi + boot +} + +menuentry "Reboot" { + reboot +} + +menuentry "Shutdown" { + halt +} +``` + +Users can customize `grub.cfg` to add entries for other operating systems, +change the timeout, or add additional Red Bear OS entries (e.g., recovery +mode with different kernel parameters, once supported). + +## ESP Size Requirements + +| Component | Typical Size | +|-----------|--------------| +| GRUB EFI binary (with modules) | ~500 KiB (varies with module list) | +| Redox bootloader | 100–200 KiB | +| grub.cfg | < 1 KiB | +| **Total** | **~1 MiB** | + +The default ESP is 1 MiB (too small for GRUB). Configs using GRUB must set: + +```toml +[general] +efi_partition_size = 16 # 16 MiB, enough for GRUB + Redox bootloader + margin +``` + +## Linux-Compatible CLI + +Red Bear OS provides `grub-install` and `grub-mkconfig` wrappers that match GNU GRUB +command-line conventions. Users migrating from Linux can use familiar switches. + +| Linux Command | Red Bear OS Location | +|---------------|---------------------| +| `grub-install` | `local/scripts/grub-install` | +| `grub-mkconfig` | `local/scripts/grub-mkconfig` | + +Add to PATH for convenience: +```bash +export PATH="$PWD/local/scripts:$PATH" +``` + +### grub-install + +```bash +# Install GRUB into a disk image +grub-install --target=x86_64-efi --disk-image=build/x86_64/harddrive.img + +# Verbose mode +grub-install --target=x86_64-efi --disk-image=build/x86_64/harddrive.img --verbose + +# Show help +grub-install --help +``` + +Supported options: `--target=`, `--efi-directory=`, `--bootloader-id=`, `--removable`, +`--disk-image=`, `--modules=`, `--no-nvram`, `--verbose`, `--help`, `--version`. + +Unsupported Linux options are accepted and ignored silently for script compatibility. + +### grub-mkconfig + +```bash +# Preview generated config +grub-mkconfig + +# Write to file +grub-mkconfig -o local/recipes/core/grub/grub.cfg + +# Custom timeout +grub-mkconfig --timeout=10 -o /boot/grub/grub.cfg +``` + +Supported options: `-o`/`--output=`, `--timeout=`, `--set-default=`, `--help`, `--version`. + +## Implementation — Phase 1: Post-Build Script + +Phase 1 uses a post-build script to modify the ESP in an existing disk image. +This approach requires **no changes to the installer** and works immediately. + +### Files + +| File | Purpose | +|------|---------| +| `local/recipes/core/grub/recipe.toml` | Build GRUB from source, produce `grub.efi` | +| `local/recipes/core/grub/grub.cfg` | Default GRUB configuration | +| `local/recipes/core/grub/modules/redoxfs.mod` | Mandatory local GRUB RedoxFS module artifact | +| `local/scripts/install-grub.sh` | Post-build ESP modification script | +| `local/scripts/fat_tool.py` | Python FAT32 tool (no mtools dependency) | +| `recipes/core/grub → local/recipes/core/grub` | Symlink for recipe discovery | + +### Workflow + +```bash +# 1. Build GRUB recipe +make r.grub + +# 2. Build Red Bear OS (with larger ESP) +make all CONFIG_NAME=redbear-full # Must have efi_partition_size = 16 + +# 3. Install GRUB into disk image +./local/scripts/install-grub.sh build/x86_64/harddrive.img + +# 4. Test +make qemu +``` + +### Requirements + +- Python 3 (for `fat_tool.py` — no mtools dependency) +- GRUB build dependencies: `gcc`, `make`, `bison`, `flex`, `autoconf`, `automake` +- ESP must be ≥ 8 MiB (set `efi_partition_size = 16` in config) + +## Implementation — Phase 2: Installer-Native Support + +Phase 2 adds GRUB awareness directly to the Redox installer, eliminating the +post-build script step. The installer reads `bootloader = "grub"` from config, +fetches the GRUB package alongside the bootloader, and writes the chainload +ESP layout automatically. + +### Changes Made + +1. **`GeneralConfig`** (`config/general.rs`): Added `bootloader: Option` + field (`"redox"` default, `"grub"` for GRUB), with merge support. + +2. **`DiskOption`** (`installer.rs`): Added `grub_efi: Option<&[u8]>` and + `grub_config: Option<&[u8]>` fields for optional GRUB data. + +3. **`fetch_bootloaders`**: When `bootloader = "grub"`, installs the `grub` + package alongside `bootloader` and returns `grub.efi` + `grub.cfg` data. + Return type extended to `(bios, efi, grub_efi, grub_cfg)`. + +4. **`with_whole_disk` / `with_whole_disk_ext4`**: When `grub_efi` and + `grub_config` are both present, writes the GRUB chainload layout: + - `EFI/BOOT/BOOTX64.EFI` ← GRUB + - `EFI/BOOT/grub.cfg` ← GRUB configuration + - `EFI/REDBEAR/redbear.efi` ← Redox bootloader (chainload target) + +5. **`install_inner`**: Passes GRUB data from `fetch_bootloaders` through + `DiskOption`. + +6. **CLI** (`bin/installer.rs`): Added `--bootloader grub` flag that sets + `config.general.bootloader`. + +7. **TUI** (`bin/installer_tui.rs`): Updated `DiskOption` construction with + `grub_efi: None, grub_config: None`. + +### Config Usage + +```toml +# config/redbear-grub.toml +include = ["redbear-full.toml"] + +[general] +bootloader = "grub" +efi_partition_size = 16 +``` + +Or via CLI (note: INSTALLER_OPTS replaces defaults, so --cookbook=. must be included): +```bash +./target/release/repo cook installer +make all CONFIG_NAME=redbear-full INSTALLER_OPTS="--cookbook=. --bootloader grub" +``` + +**Note:** The config file approach (`redbear-grub.toml`) is preferred over the CLI flag +because INSTALLER_OPTS completely replaces the default value (`--cookbook=.`) rather than +appending to it. Omitting `--cookbook=.` breaks local package resolution for GRUB. + +## GRUB Recipe Design + +The GRUB recipe uses `template = "custom"` because GRUB must be built for the +**host machine** (it's a build tool that produces EFI binaries), not for the +Redox target. The cookbook's `configure` template cross-compiles for Redox, +which is wrong for GRUB. + +Key build steps: +1. Configure with `--target=x86_64 --with-platform=efi` (produces x86_64 EFI) +2. Disable unnecessary components (themes, mkfont, mount, device-mapper) +3. Run `grub-mkimage` to create standalone EFI binary with curated modules +4. Stage `grub.efi` and `grub.cfg` to `/usr/lib/boot/` + +### Build Notes + +The recipe uses `template = "custom"` because the cookbook's default `configure` +template sets `--host="${GNU_TARGET}"` for Redox cross-compilation, which is wrong +for GRUB (a host build tool producing EFI binaries). + +Two issues required workarounds: + +1. **Cross-compiler override.** The cookbook sets `CC`, `CXX`, `CFLAGS`, etc. to + the Redox cross-toolchain. GRUB must be built with the host compiler. Fix: + `unset CC CXX CPP LD AR NM RANLIB OBJCOPY STRIP PKG_CONFIG` and + `unset CFLAGS CXXFLAGS CPPFLAGS LDFLAGS` at the top of the script. + +2. **Missing `extra_deps.lst`.** GRUB 2.12 release tarballs omit + `grub-core/extra_deps.lst` (normally generated by `autogen.sh` from git). + Fix: `touch "${COOKBOOK_SOURCE}/grub-core/extra_deps.lst"` before configure. + +3. **grub.cfg location.** The config file lives in the recipe directory + (`${COOKBOOK_RECIPE}/grub.cfg`), not in the extracted source tarball + (`${COOKBOOK_SOURCE}/`). The copy step uses `COOKBOOK_RECIPE`. + +## Security Considerations + +- GRUB configuration is on the ESP (FAT32), which is readable/writable by any OS +- Secure Boot: GRUB standalone images are not signed. Users needing Secure Boot + must sign `BOOTX64.EFI` with their own key or use `shim` +- The chainload target (`EFI/REDBEAR/redbear.efi`) is also on the ESP +- No credentials or secrets are stored in the GRUB configuration + +## Limitations + +- GRUB cannot read RedoxFS (no module exists) +- Cannot pass kernel parameters directly (chainloading bypasses this) +- BIOS boot is not supported (only UEFI) +- ESP must be sized to ≥ 8 MiB in config (16 MiB recommended) +- GRUB bootloader is incompatible with `skip_partitions = true` (requires GPT layout with ESP) +- TUI installer does not support GRUB mode (intentional — TUI is for live disk reinstall) +- Runtime UEFI boot test has not been performed yet (requires full `make all` build, ~hours) + +## Testing + +### Phase 1: Post-build script (standalone) + +```bash +# Build GRUB recipe +make r.grub + +# Build image (any config with efi_partition_size >= 16) +make all CONFIG_NAME=redbear-full + +# Install GRUB into disk image (uses fat_tool.py, no mtools needed) +./local/scripts/install-grub.sh build/x86_64/harddrive.img + +# Verify ESP contents +python3 local/scripts/fat_tool.py ls build/x86_64/harddrive.img 1048576 / + +# Boot in QEMU +make qemu +# Expected: GRUB menu appears, "Red Bear OS" entry boots successfully +``` + +### Phase 2: Installer-native (automatic) + +```bash +# Build GRUB recipe (must be built before installer runs) +make r.grub + +# Build image with GRUB config (installer fetches GRUB automatically) +make all CONFIG_NAME=redbear-grub + +# Or via CLI flag +make all CONFIG_NAME=redbear-full INSTALLER_OPTS="--bootloader grub --cookbook=." + +# Verify ESP contents +python3 local/scripts/fat_tool.py ls build/x86_64/harddrive.img 1048576 / + +# Boot in QEMU +make qemu +# Expected: GRUB menu appears, "Red Bear OS" entry boots successfully +``` + +### Unit tests (no full build required) + +```bash +# Verify GRUB recipe builds +CI=1 ./target/release/repo cook grub + +# Verify host-side installer accepts --bootloader flag +build/fstools/bin/redox_installer --bootloader=grub --config=config/redbear-grub.toml --list-packages + +# Verify fat_tool.py operations +python3 local/scripts/fat_tool.py --help +``` + +## References + +- GNU GRUB Manual: https://www.gnu.org/software/grub/manual/grub/grub.html +- GRUB EFI standalone image: `grub-mkimage -O x86_64-efi ...` +- UEFI boot specification: `EFI/BOOT/BOOTX64.EFI` is the fallback boot path +- Redox bootloader source: `recipes/core/bootloader/source/` +- Installer GPT layout: `recipes/core/installer/source/src/installer.rs` diff --git a/local/docs/KERNEL-IPC-CREDENTIAL-PLAN.md b/local/docs/KERNEL-IPC-CREDENTIAL-PLAN.md new file mode 100644 index 0000000000..688cafb017 --- /dev/null +++ b/local/docs/KERNEL-IPC-CREDENTIAL-PLAN.md @@ -0,0 +1,748 @@ +# Red Bear OS — Kernel, IPC, and Credential Syscalls Plan + +**Date:** 2026-04-30 +**Scope:** Kernel architecture, IPC infrastructure, credential syscalls, process isolation +**Implementation status:** Phases K1-K2, K4 ✅ complete. Phases K3, K5 deferred. +**Status:** This document is the canonical kernel + IPC plan, extending `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` + +## 1. Purpose + +This plan defines the implementation roadmap for kernel hardening, IPC improvements, and credential +syscall implementation in Red Bear OS. It is the **canonical kernel authority** superseding scattered +kernel guidance in other docs. + +**Relationship to existing plans:** + +| Document | Relationship | +|----------|-------------| +| `CONSOLE-TO-KDE-DESKTOP-PLAN.md` | Parent: CONSOLE-TO-KDE v4.0 (Kernel & Core Infrastructure) | +| `IRQ-AND-LOWLEVEL-CONTROLLERS-ENHANCEMENT-PLAN.md` | Sibling: IRQ/PCI/MSI-X — not duplicated here | +| `RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md` | Companion: relibc IPC surface — this plan covers kernel side | +| `ACPI-IMPROVEMENT-PLAN.md` | Sibling: ACPI power/shutdown — relevant for §4 (shutdown robustness) | +| `CONSOLE-TO-KDE-DESKTOP-PLAN.md` | Consumer: desktop stack depends on kernel work here | + +## 2. Current Architecture Assessment + +### 2.1 Kernel Overview + +The Redox microkernel (`recipes/core/kernel/source/`) is a ~20-40k LoC Rust microkernel. It runs in +ring 0 and provides: + +- **12 kernel schemes**: `debug`, `event`, `memory`, `pipe`, `irq`, `time`, `sys`, `proc`, `serio`, + `acpi`, `dtb`, `user` (userspace scheme wrapper) +- **~35 handled syscalls**: file I/O, memory mapping, process control, futex, time +- **Catch-all ENOSYS**: all unhandled syscall numbers return `ENOSYS` + +``` +recipes/core/kernel/source/src/ +├── syscall/ # Syscall dispatch: mod.rs (handlers), fs.rs, process.rs, futex.rs, time.rs +│ └── mod.rs # Main syscall() dispatch: 35 explicit match arms, _ => ENOSYS +├── scheme/ # Kernel schemes: debug, event, memory, pipe, irq, time, sys, proc, serio +│ ├── mod.rs # Scheme trait definition, SchemeId, FileHandle types +│ ├── proc.rs # Process manager scheme (fork, exec, signal, credential setting) +│ └── sys/ # System info scheme: context list, syscall debug, uname +├── context/ # Process/thread context management +│ ├── context.rs # Context struct: euid, egid, pid, files, signals, addr_space +│ └── memory.rs # Address space, grants, mmap implementation +├── memory/ # Physical/virtual memory management, page tables +└── sync/ # Locking primitives (RwLock, Mutex, CleanLockToken) +``` + +### 2.2 Syscall Dispatch Architecture + +The kernel's `syscall()` function in `syscall/mod.rs` dispatches based on `a` (syscall number): + +```rust +// From recipes/core/kernel/source/src/syscall/mod.rs (line 75) +match a { + SYS_WRITE2 => file_op_generic_ext(..), + SYS_WRITE => sys_write(..), + SYS_FMAP => { .. }, // Anonymous or file-backed mmap + SYS_READ2 => file_op_generic_ext(..), + SYS_READ => sys_read(..), + SYS_FPATH => file_op_generic(..), + SYS_FSTAT => fstat(..), + SYS_DUP => dup(..), + SYS_DUP2 => dup2(..), + SYS_SENDFD => sendfd(..), + SYS_OPENAT => openat(..), + SYS_UNLINKAT => unlinkat(..), + SYS_CLOSE => close(..), + SYS_CALL => call(..), // Scheme IPC: send message to scheme + SYS_FEVENT => fevent(..), // Register event on fd + SYS_YIELD => sched_yield(..), + SYS_NANOSLEEP => nanosleep(..), + SYS_CLOCK_GETTIME => clock_gettime(..), + SYS_FUTEX => futex(..), + SYS_MPROTECT => mprotect(..), + SYS_MREMAP => mremap(..), + // ... ~15 more file operations (fchmod, fchown, fcntl, flink, frename, ftruncate, fsync, etc.) + _ => Err(Error::new(ENOSYS)), // ← CATCH-ALL: all credential syscalls fall here +} +``` + +Syscall numbers come from the external `redox_syscall` crate (crates.io), not from the kernel tree. +The kernel consumes them via `use syscall::number::*`. + +### 2.3 Credential Architecture (Current) + +**Kernel Context struct** (`context/context.rs`): + +```rust +pub struct Context { + // Credential fields (initialized to 0): + pub euid: u32, // Effective user ID — used for scheme access control + pub egid: u32, // Effective group ID + pub pid: usize, // Process ID (set via proc scheme) + + // NOT present in kernel: + // ruid, suid — real/saved UID (maintained in userspace redox-rt) + // rgid, sgid — real/saved GID (maintained in userspace redox-rt) + // supplementary groups — not implemented anywhere + + // Access control interface: + pub fn caller_ctx(&self) -> CallerCtx { + CallerCtx { uid: self.euid, gid: self.egid, pid: self.pid } + } +} +``` + +**Credential read path** (userspace, no kernel involvement): +``` +getuid() → relibc::platform::redox::getuid() + → redox_rt::sys::posix_getresugid() + → reads local DYNAMIC_PROC_INFO { ruid, euid, suid, rgid, egid, sgid } + → returns cached userspace values (NO kernel syscall) +``` + +**Credential write path** (through `proc:` scheme): +``` +setresuid(ruid, euid, suid) → relibc::platform::redox::setresuid() + → redox_rt::sys::posix_setresugid(&Resugid { ruid, euid, suid, .. }) + → packs 6×u32 into buffer + → this_proc_call(&buf, CallFlags::empty(), &[ProcCall::SetResugid as u64]) + → SYS_CALL to proc: scheme + → kernel proc scheme handler (scheme/proc.rs:1269): + guard.euid = info.euid; + guard.egid = info.egid; +``` + +**Key finding**: The kernel DOES support credential setting through the `proc:` scheme, using +`ProcSchemeAttrs` with `euid`/`egid`/`pid`/`prio`/`debug_name` fields. The `getuid()`/`getgid()` +functions work through userspace-cached values in `redox-rt`. `setresuid()`/`setresgid()` work +through the proc scheme. + +**What's genuinely broken:** + +| Function | Status | Root Cause | +|----------|--------|------------| +| `setgroups()` | **ENOSYS stub** | relibc/redox/mod.rs:1205 — `todo_skip!(0, "setgroups({}, {:p}): not implemented")` | +| `getgroups()` | /etc/group-based | Works via `getpwuid()` + `getgrent()` iteration — doesn't use kernel groups | +| `initgroups()` | No-op | No supplementary group infrastructure | + +### 2.4 IPC Architecture + +**Scheme-based IPC** is the primary IPC mechanism: + +``` +┌─────────────┐ SYS_CALL(syscall) ┌──────────────┐ +│ Userspace │ ──────────────────────────→│ Kernel │ +│ Process A │ open/read/write/fevent │ Scheme │ +│ │ ←──────────────────────────│ Dispatch │ +└─────────────┘ result (usize/-errno) └──────┬───────┘ + │ + ┌─────────────────────┤ + │ │ + ┌────▼──────┐ ┌──────▼──────┐ + │ Kernel │ │ Userspace │ + │ Schemes │ │ Scheme │ + │ (12) │ │ Daemons │ + │ │ │ (via user:) │ + │ debug: │ │ │ + │ event: │ │ ptyd │ + │ memory: │ │ pcid │ + │ pipe: │ │ ext4d │ + │ irq: │ │ fatd │ + │ time: │ │ redox-drm │ + │ sys: │ │ ... │ + │ proc: │ │ │ + │ serio: │ │ │ + └───────────┘ └──────────────┘ +``` + +**IPC primitives available:** + +| Primitive | Mechanism | Kernel/Userspace | +|-----------|-----------|-----------------| +| `pipe:` scheme | Kernel pipe scheme — bidirectional byte streams | Kernel | +| `shm_open()` / `mmap(MAP_SHARED)` | Shared memory via memory scheme grants | Kernel | +| `SYS_CALL` + scheme messages | Send/receive typed messages to scheme daemons | Kernel dispatch, userspace handler | +| `fevent()` | Register kernel-level events on file descriptors | Kernel | +| `sendfd()` | Pass file descriptors between processes | Kernel | +| `event:` scheme | Kernel event notification (used by eventfd/signalfd/timerfd) | Kernel | +| Signals | `sigprocmask` + `sigaction` via proc: scheme | Kernel delivery, userspace handling | +| Futex | Fast userspace mutex via `SYS_FUTEX` | Kernel | + +**Current IPC limitations:** + +| Limitation | Impact | +|-----------|--------| +| No `SYS_PTRACE` | ptrace not available (handled via proc: scheme paths) | +| No `SYS_KILL` | Signal sending via proc: scheme only | +| eventfd/signalfd/timerfd recipe-applied | Bounded compatibility layers, not plain-source | +| `ifaddrs` synthetic | Only `loopback` + `eth0`, not live enumeration | +| POSIX message queues not implemented | `mqueue.h` missing entirely | +| SysV message queues not implemented | `sys/msg.h` missing entirely | +| No UNIX domain sockets (`AF_UNIX`) path | Socket-based IPC limited | + +### 2.5 Process Model + +Redox uses a **userspace process manager** (`procmgr` via `proc:` scheme): + +- **fork**: Implemented through proc: scheme → kernel creates new Context with cloned address space +- **exec**: Replaces address space with new executable image +- **spawn**: Combined fork+exec via proc: scheme +- **wait/waitpid/waitid**: Recipe-applied patch via proc: scheme (signals child exit) +- **Credentials on fork**: Address space cloned (userspace `DYNAMIC_PROC_INFO` inherited) +- **Credentials on exec**: `setresuid()` behavior (suid-bit not implemented in kernel) + +The kernel's Context struct tracks: +- `owner_proc_id: Option` — parent process for exit notification +- `files: Arc` — file descriptor table (can be shared) +- `addr_space: Option>` — address space (can be shared = threads) +- `sig: Option` — signal handler configuration + +## 3. Critical Gaps and Blockers + +### 3.1 Credential Syscall Blocker (Priority: P0-CRITICAL) + +The `setgroups()` function is **ENOSYS**. This blocks: +- `polkit` — uses `setgroups()` for privilege management +- `dbus-daemon` — uses credentials for service activation +- `logind` / `redbear-sessiond` — needs credential awareness +- `sudo` / `su` — uses `initgroups()` → `setgroups()` +- Any program that changes user identity + +**Root cause chain:** +1. `redox_syscall` crate (crates.io, upstream) has no `SYS_SETGROUPS`/`SYS_GETGROUPS` numbers +2. Kernel has no supplementary group table in Context struct +3. No group inheritance on fork/exec +4. relibc `setgroups()` is a `todo_skip!()` stub +5. `getgroups()` bypasses kernel entirely (reads /etc/group) + +### 3.2 Kernel-Level Access Control Gap (Priority: P1) + +The kernel's `caller_ctx()` provides `{euid, egid, pid}` to scheme handlers, but: + +1. **No consistent enforcement**: Kernel schemes may or may not check caller credentials +2. **No ruid/suid tracking**: Cannot distinguish real vs effective identity in kernel +3. **All processes start as root** (euid=0, egid=0): No privilege separation at boot +4. **No supplementary groups in kernel**: Only egid checked + +### 3.3 IPC Completeness Gaps (Priority: P2) + +| Gap | Priority | Blocked By | +|-----|----------|------------| +| POSIX message queues (`mqueue.h`) | P2 | Scheme design needed | +| SysV message queues (`sys/msg.h`) | P2 | Scheme design needed | +| UNIX domain sockets (`AF_UNIX`) | P2 | Kernel or scheme implementation | +| Non-synthetic `ifaddrs` | P3 | Network stack enumeration | +| eventfd/signalfd/timerfd → plain-source | P3 | Upstream relibc convergence | + +### 3.4 Resource Limits (Priority: P2) + +`SYS_GETRLIMIT` / `SYS_SETRLIMIT` return ENOSYS. This is a microkernel design choice: +- Resource limits are typically library-level policy in capability systems +- Current approach: limits enforced in userspace daemons +- Desktop impact: systemd/logind expect rlimit support for service management + +### 3.5 Shutdown Robustness (Priority: P2) + +ACPI shutdown via `kstop` eventing exists but has gaps: +- `acpid` startup has panic-grade `expect` paths +- `_S5` derivation gated on PCI timing +- DMAR orphaned in `acpid` source +- See `local/docs/ACPI-IMPROVEMENT-PLAN.md` for full detail + +## 4. Implementation Plan + +### Phase K1: Kernel Credential Foundation (Week 1-2) + +**Goal**: Add supplementary group support to the kernel and wire `setgroups()`/`getgroups()`. + +#### K1.1 — Add supplementary groups to kernel Context + +```rust +// Context struct additions (context/context.rs): +pub struct Context { + // Existing: + pub euid: u32, + pub egid: u32, + pub pid: usize, + + // NEW: Real/saved IDs (moved from userspace redox-rt to kernel): + pub ruid: u32, + pub rgid: u32, + pub suid: u32, + pub sgid: u32, + + // NEW: Supplementary groups + pub groups: Vec, // Or Arc<[u32]> for sharing +} +``` + +**Files modified:** +- `recipes/core/kernel/source/src/context/context.rs` — add fields, initialize, clone on fork +- `recipes/core/kernel/source/src/scheme/proc.rs` — extend `ProcSchemeAttrs` to include ruid/suid/rgid/sgid/groups +- `local/patches/kernel/` — new patch: `P4-credential-fields.patch` + +#### K1.2 — Add `SYS_SETGROUPS` and `SYS_GETGROUPS` to redox_syscall + +The `redox_syscall` crate is upstream (crates.io). Red Bear must either: +- **Option A (preferred)**: Contribute upstream PR to add syscall numbers +- **Option B**: Vendor fork of `redox_syscall` in `local/` overlay +- **Option C**: Define Red Bear-local syscall numbers in kernel directly + +**Recommended: Option A + B fallback**: +1. Submit upstream PR to `redox_syscall` adding: + - `SYS_SETGROUPS`, `SYS_GETGROUPS` + - `SYS_SETUID`, `SYS_SETGID`, `SYS_GETUID`, `SYS_GETGID` + - `SYS_GETEUID`, `SYS_GETEGID` + - `SYS_SETREUID`, `SYS_SETREGID` + - `SYS_GETRESUID`, `SYS_GETRESGID` + +2. While upstream PR is pending, use a local `redox_syscall` patch: + - Copy `redox_syscall` crate into `local/vendor/redox_syscall/` + - Add syscall number constants + - Point kernel Cargo.toml to local path + - Patch tracked in `local/patches/kernel/P4-redox-syscall-numbers.patch` + +#### K1.3 — Add kernel syscall handlers + +**New file:** `recipes/core/kernel/source/src/syscall/cred.rs` + +```rust +// Credential syscall handlers +pub fn setresuid(ruid: u32, euid: u32, suid: u32, token: &mut CleanLockToken) -> Result { + let context_lock = context::current(); + let mut context = context_lock.write(token.token()); + + // Permission check: must be root or match current values + if context.euid != 0 { + if let Some(ruid) = ruid_opt { /* check ruid == current ruid/euid/suid */ } + // ... POSIX permission model + } + + // Set values + if ruid != u32::MAX { context.ruid = ruid; } + if euid != u32::MAX { context.euid = euid; } + if suid != u32::MAX { context.suid = suid; } + Ok(0) +} + +pub fn setgroups(groups: &[u32], token: &mut CleanLockToken) -> Result { + // Requires: euid == 0 + let context_lock = context::current(); + let mut context = context_lock.write(token.token()); + if context.euid != 0 { return Err(Error::new(EPERM)); } + context.groups = groups.to_vec(); + Ok(0) +} + +pub fn getgroups(token: &mut CleanLockToken) -> Result> { + let context_lock = context::current(); + let context = context_lock.read(token.token()); + Ok(context.groups.clone()) +} +``` + +**Modified file:** `recipes/core/kernel/source/src/syscall/mod.rs` +```rust +match a { + // ... existing arms ... + SYS_SETRESUID => setresuid(b as u32, c as u32, d as u32, token), + SYS_SETRESGID => setresgid(b as u32, c as u32, d as u32, token), + SYS_GETRESUID => getresuid(UserSlice::wo(b, c)?, token), + SYS_GETRESGID => getresgid(UserSlice::wo(b, c)?, token), + SYS_SETUID => setuid(b as u32, token), + SYS_SETGID => setgid(b as u32, token), + SYS_GETUID => Ok(getuid(token)), + SYS_GETGID => Ok(getgid(token)), + SYS_GETEUID => Ok(geteuid(token)), + SYS_GETEGID => Ok(getegid(token)), + SYS_SETGROUPS => setgroups(UserSlice::ro(b, c)?, token).map(|()| 0), + SYS_GETGROUPS => getgroups(UserSlice::wo(b, c)?, token), + // ... existing arms ... +} +``` + +#### K1.4 — Wire relibc setgroups()/getgroups() through real syscalls + +**Modified:** `recipes/core/relibc/source/src/platform/redox/mod.rs` +```rust +// Replace todo_skip!() stub: +unsafe fn setgroups(size: size_t, list: *const gid_t) -> Result<()> { + if size < 0 || size > NGROUPS_MAX { return Err(Errno(EINVAL)); } + let groups = core::slice::from_raw_parts(list, size as usize); + syscall::setgroups(groups)?; + Ok(()) +} + +// Replace /etc/group-based getgroups: +fn getgroups(mut list: Out<[gid_t]>) -> Result { + let mut buf = [0u32; NGROUPS_MAX as usize]; + let count = syscall::getgroups(&mut buf)?; + for (i, gid) in buf[..count].iter().enumerate() { + list[i] = *gid as gid_t; + } + Ok(count as c_int) +} +``` + +#### K1.5 — Add credential syscall stubs in redox-rt + +**Modified:** `recipes/core/relibc/source/redox-rt/src/sys.rs` +```rust +pub fn setgroups(groups: &[u32]) -> Result<()> { + unsafe { + redox_syscall::syscall5( + redox_syscall::SYS_SETGROUPS, + groups.as_ptr() as usize, + groups.len(), + 0, 0, 0, + ) + .map(|_| ()) + .map_err(|e| Error::new(e.errno as i32)) + } +} + +pub fn getgroups(buf: &mut [u32]) -> Result { + unsafe { + redox_syscall::syscall3( + redox_syscall::SYS_GETGROUPS, + buf.as_mut_ptr() as usize, + buf.len(), + 0, + ) + .map_err(|e| Error::new(e.errno as i32)) + } +} +``` + +#### K1.6 — Patch management + +All kernel and relibc source changes must be mirrored into `local/patches/`: + +```bash +local/patches/ +├── kernel/ +│ ├── redox.patch # Updated symlink target +│ ├── P4-credential-fields.patch # Context struct additions +│ ├── P4-credential-syscalls.patch # Syscall handlers + dispatch +│ └── P4-redox-syscall-numbers.patch # Local redox_syscall additions +├── relibc/ +│ ├── P4-setgroups-kernel.patch # Setgroups through real syscall +│ ├── P4-getgroups-kernel.patch # Getgroups through real syscall +│ └── P4-redox-rt-cred-syscalls.patch # redox-rt syscall wrappers +``` + +### Phase K2: Kernel Access Control Hardening (Week 2-3) + +**Goal**: Enforce credential checks in kernel schemes, add proper privilege separation. + +#### K2.1 — Enforce scheme-level credential checks + +Each kernel scheme handler currently receives `CallerCtx { uid, gid, pid }`. Ensure consistent +credential enforcement: + +| Scheme | Current Check | Required Check | +|--------|--------------|----------------| +| `memory:` | Physical memory access → root only | ✅ Already enforced (euid==0 for phys) | +| `irq:` | IRQ registration → root only | ✅ Already enforced | +| `proc:` | Process inspection → caller == target OR root | 🔄 Review: ensure consistent | +| `sys:` | System info → read-only for all | ✅ Appropriate | +| `debug:` | Debug output → should be root-only | 🔄 Review: add check | +| `serio:` | PS/2 device → root only | 🔄 Review: add check | +| `event:` | Event registration → process-own only | 🔄 Review: ensure isolation | + +#### K2.2 — Bootstrap with non-root init process + +Currently all processes start as euid=0/egid=0. The boot sequence should: +1. Kernel bootstrap context starts as root (euid=0, egid=0) — required for init +2. Init (`/sbin/init`) runs as root +3. Init drops privileges before spawning user services: + ```rust + // In init or service manager: + setresuid(1000, 1000, 1000); // Drop to regular user + setgroups(&[1000, 27, 100]); // Set supplementary groups + // Then spawn child services with restricted permissions + ``` + +#### K2.3 — Add `initgroups()` support + +```rust +// In relibc/src/platform/redox/mod.rs: +fn initgroups(user: CStr, group: gid_t) -> Result<()> { + // 1. Set primary group + setgid(group)?; + // 2. Parse /etc/group for supplementary groups containing this user + let mut groups = vec![group]; + // ... iterate getgrent() to find user memberships ... + // 3. Set supplementary groups via kernel syscall + setgroups(&groups)?; + Ok(()) +} +``` + +### Phase K3: IPC Infrastructure Improvements (Week 3-5) + +**Goal**: Complete IPC primitives needed for desktop infrastructure. + +#### K3.1 — POSIX Message Queues (`mqueue.h`) + +**Design decision**: Implement as a userspace scheme daemon (not kernel syscalls). + +``` +mqd: +├── Registers as scheme:mqueue +├── Stores queues in memory backed by shm_open() + mmap() +├── mq_open() → open scheme:mqueue/{name} +├── mq_send() → write to fd +├── mq_receive() → read from fd +├── mq_notify() → fevent() on fd for async notification +├── mq_close() → close fd +└── mq_unlink() → unlink scheme:mqueue/{name} +``` + +**Implementation:** +- New Red Bear package: `local/recipes/system/mqueued/` +- Relibc header: `recipes/core/relibc/source/src/header/mqueue/` +- Recipe in `local/recipes/system/mqueued/recipe.toml` +- Init service: `/usr/lib/init.d/50_mqueued.service` + +#### K3.2 — SysV Message Queues (`sys/msg.h`) + +**Design decision**: Implement as scheme daemon or on top of POSIX message queues. +- Recommended: implement directly alongside `mqueued` using shared infrastructure. +- Low priority — Qt/KDE do not depend on SysV msg queues. + +#### K3.3 — UNIX Domain Sockets (`AF_UNIX` / `SOCK_STREAM`) + +**Current state**: D-Bus uses abstract sockets on Linux. Redox uses scheme-based communication. +- For D-Bus compatibility: `redbear-sessiond` already uses `zbus` with custom transport +- For general `AF_UNIX`: implement as `scheme:unix` daemon backed by kernel pipe scheme +- Priority: P3 — D-Bus is already working through scheme transport + +#### K3.4 — Non-synthetic Interface Enumeration + +Replace the hardcoded `loopback` + `eth0` model with live network interface enumeration: +- Query `smolnetd` or equivalent for active interfaces +- Expose through `getifaddrs()` properly +- Priority: P3 — needed for NetworkManager-like functionality + +#### K3.5 — eventfd/signalfd/timerfd → plain-source convergence + +Current state: all three are recipe-applied patches. Goal: upstream into relibc mainline. +- Monitor upstream relibc for equivalent implementations +- When upstream absorbs: shrink/drop Red Bear patch chain +- When upstream does NOT absorb after 3+ months: promote to durable Red Bear-maintained +- See `local/docs/RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md` Phase I5 + +### Phase K4: Resource Limits and Process Management (Week 4-6) + +#### K4.1 — RLIMIT Support + +**Decision**: Enforce resource limits in userspace, not kernel. +- The kernel is a microkernel — resource limits are policy +- `getrlimit()` / `setrlimit()` → libc stubs with reasonable defaults +- Process enforcement → `procmgr` (userspace process manager) via proc: scheme +- File descriptor limits → already enforced via `CONTEXT_MAX_FILES` in kernel +- Memory limits → userspace `procmgr` can kill processes exceeding limits + +```rust +// relibc implementation (userspace, no kernel changes needed): +fn getrlimit(resource: c_int, rlim: *mut rlimit) -> Result<()> { + match resource { + RLIMIT_NOFILE => { rlim.rlim_cur = 1024; rlim.rlim_max = 4096; } + RLIMIT_NPROC => { rlim.rlim_cur = 256; rlim.rlim_max = 1024; } + RLIMIT_AS => { rlim.rlim_cur = RLIM_INFINITY; rlim.rlim_max = RLIM_INFINITY; } + RLIMIT_CORE => { rlim.rlim_cur = 0; rlim.rlim_max = RLIM_INFINITY; } + // ... other resource types with reasonable defaults + _ => return Err(Errno(EINVAL)), + } + Ok(()) +} +``` + +#### K4.2 — PTRACE via proc: scheme + +`SYS_PTRACE` is not implemented as a direct syscall. The Redox model uses the `proc:` scheme +for process inspection and manipulation: +- Already partially implemented in `scheme/proc.rs` +- Memory read/write through proc: scheme file operations +- Register read/write through proc: scheme +- Signal injection through proc: scheme + +Improvements needed: +- Document the proc: scheme ptrace API surface +- Ensure all ptrace operations have proc: scheme equivalents +- Add `PTRACE_*` constants to redox_syscall for compatibility + +#### K4.3 — clock_settime + +`SYS_CLOCK_SETTIME` returns ENOSYS. Implementation: +- Add scheme write path to `/scheme/sys/update_time_offset` +- Or implement as direct syscall for precision +- Priority: P3 — needed for NTP synchronization + +### Phase K5: Shutdown and Power Management (Week 5-7) + +See `local/docs/ACPI-IMPROVEMENT-PLAN.md` for full ACPI plan. This section covers kernel-specific +work only. + +#### K5.1 — Hardened acpid Startup + +- Remove panic-grade `expect` paths in kernel ACPI/AML handling +- Add graceful fallback when ACPI tables are missing or malformed +- See ACPI-IMPROVEMENT-PLAN.md Wave 1 + +#### K5.2 — kstop Shutdown Robustness + +- Current: `_S5` shutdown via `kstop` event exists but gated on PCI timing +- Required: deterministic shutdown ordering: + 1. Notify userspace services of impending shutdown + 2. Sync filesystems + 3. Power off via ACPI/FADT +- See ACPI-IMPROVEMENT-PLAN.md Wave 2 + +#### K5.3 — Sleep State Support + +- S3 (suspend-to-RAM) and S4 (hibernate) are not yet supported +- Requires: kernel state serialization, device reinitialization +- Priority: P4 — long-term, not blocking desktop + +## 5. Dependency Chain + +``` +Phase K1 (credential syscalls) ─────────────────────┐ + │ │ + ├──► polkit compatibility │ + ├──► dbus-daemon credential checks │ + ├──► sudo/su user switching │ + ├──► redbear-sessiond login1 handoff │ + └──► greeter/session-launch credential drop │ + │ +Phase K2 (access control) ────────────────────────────┤ + │ │ + ├──► Privilege-separated boot sequence │ + ├──► Scheme-level credential enforcement │ + └──► initgroups() for service launching │ + │ +Phase K3 (IPC) ───────────────────────────────────────┤ + │ │ + ├──► POSIX message queues → needed by some apps │ + ├──► AF_UNIX → broader D-Bus transport options │ + └──► eventfd/signalfd/timerfd → KDE/Qt runtime │ + │ +Phase K4 (limits/ptrace) ─────────────────────────────┤ + │ │ + ├──► RLIMIT → systemd/logind compatibility │ + ├──► PTRACE → debugging support │ + └──► clock_settime → NTP synchronization │ + ▼ + Desktop infrastructure + ready for KDE Plasma +``` + +## 6. Integration with Existing Work + +### 6.1 Already in Progress (do not duplicate) + +| Area | Canonical Plan | Status | +|------|---------------|--------| +| IRQ / MSI-X / IOMMU | `IRQ-AND-LOWLEVEL-CONTROLLERS-ENHANCEMENT-PLAN.md` | Waves 1-6 complete, hardware validation open | +| ACPI shutdown / power | `ACPI-IMPROVEMENT-PLAN.md` | Waves 1-2 complete, sleep states deferred | +| relibc IPC surface | `RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md` | Phases I1-I5, message queues deferred | +| D-Bus / sessiond | `DBUS-INTEGRATION-PLAN.md` | Phase 1 complete, Phase 2 in progress | +| Greeter / login | `GREETER-LOGIN-IMPLEMENTATION-PLAN.md` | Active, bounded proof passing | +| Desktop path | `CONSOLE-TO-KDE-DESKTOP-PLAN.md` | Phase 1-5 model, KWin building | + +### 6.2 This Plan Covers (uniquely) + +| Area | This Plan | Not Covered By | +|------|-----------|---------------| +| Kernel credential architecture | §3, Phase K1 | Any existing plan | +| Kernel access control hardening | §3.2, Phase K2 | Any existing plan | +| `setgroups()` / `getgroups()` kernel implementation | Phase K1.2-K1.4 | Only stub noted elsewhere | +| Supplementary group infrastructure | Phase K1.1 | Not covered anywhere | +| POSIX/SysV message queues | Phase K3.1-K3.2 | Deferred in relibc-IPC plan | +| UNIX domain sockets | Phase K3.3 | Not covered | +| RLIMIT design decision | Phase K4.1 | Noted as gap only | +| PTRACE via proc: scheme | Phase K4.2 | Not covered | +| clock_settime implementation | Phase K4.3 | Noted as gap only | + +## 7. Patch Governance + +All kernel and relibc source changes must follow the durability policy (see `local/AGENTS.md`): + +1. **Make changes** in `recipes/core/kernel/source/` or `recipes/core/relibc/source/` +2. **Generate patches**: `git diff` in the source tree → `local/patches//P4-*.patch` +3. **Wire patches** into `recipes/core//recipe.toml` patches list +4. **Commit** patches + recipe changes before session end +5. **Assume** source trees may be thrown away by `make distclean` or upstream refresh + +### Patch naming convention: +``` +local/patches/kernel/P4-credential-fields.patch +local/patches/kernel/P4-credential-syscalls.patch +local/patches/kernel/P4-redox-syscall-numbers.patch +local/patches/relibc/P4-setgroups-kernel.patch +local/patches/relibc/P4-getgroups-kernel.patch +local/patches/relibc/P4-redox-rt-cred-syscalls.patch +local/patches/relibc/P4-initgroups.patch +``` + +## 8. Validation and Evidence + +### 8.1 Build Evidence + +| Check | Command | +|-------|---------| +| Kernel compiles | `make r.kernel` | +| relibc compiles | `make r.relibc` | +| Full OS builds | `make all CONFIG_NAME=redbear-full` | + +### 8.2 Runtime Evidence + +| Test | Verification | +|------|-------------| +| `getuid()` returns non-zero after login | `id` command in guest | +| `setgroups()` succeeds for root | `sudo -u user id` in guest | +| `setresuid()` properly changes euid | `su user -c 'id'` | +| `initgroups()` populates groups | `groups` command in guest | +| Credentials survive fork | `bash -c 'id'` | +| Credentials dropped on exec (if SUID implemented) | TBD | +| polkit can query credentials | `pkexec echo ok` | +| dbus-daemon starts without errors | `dbus-monitor` | + +### 8.3 Verification Scripts + +Create bounded proof scripts: +```bash +local/scripts/test-credential-syscalls-qemu.sh # QEMU launcher +local/scripts/test-credential-syscalls-guest.sh # In-guest checker +``` + +## 9. References + +- `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` — Canonical comprehensive plan +- `docs/01-REDOX-ARCHITECTURE.md` — Architecture reference +- `local/docs/IRQ-AND-LOWLEVEL-CONTROLLERS-ENHANCEMENT-PLAN.md` — IRQ/PCI plan (sibling) +- `local/docs/RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md` — IPC surface plan (companion) +- `local/docs/ACPI-IMPROVEMENT-PLAN.md` — ACPI/shutdown plan (sibling) +- `local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md` — Desktop path plan (consumer) +- `recipes/core/kernel/source/src/syscall/mod.rs` — Syscall dispatch (primary implementation target) +- `recipes/core/kernel/source/src/context/context.rs` — Context struct (credential fields) +- `recipes/core/kernel/source/src/scheme/proc.rs` — Proc scheme (credential setting) +- `recipes/core/relibc/source/src/platform/redox/mod.rs` — relibc Redox platform (credential stubs) +- `recipes/core/relibc/source/redox-rt/src/sys.rs` — redox-rt credential primitives diff --git a/local/docs/RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md b/local/docs/RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md new file mode 100644 index 0000000000..18d26195a6 --- /dev/null +++ b/local/docs/RELIBC-IPC-ASSESSMENT-AND-IMPROVEMENT-PLAN.md @@ -0,0 +1,165 @@ +# Red Bear OS relibc IPC Assessment and Improvement Plan + +## Purpose + +This document is the IPC-focused companion to +`local/docs/CONSOLE-TO-KDE-DESKTOP-PLAN.md`. + +Its job is to describe the current IPC-facing relibc surface honestly, especially where the active +Red Bear build depends on recipe-applied compatibility layers rather than plain-source upstream +relibc. + +## Evidence model + +This document uses the same terms as the canonical relibc plan: + +- **plain-source-visible** +- **recipe-applied** +- **test-present** +- **runtime-unrevalidated in this pass** + +Do not collapse those into one generic "implemented" label. + +## Current IPC inventory + +| Surface | Plain source | Active build | Notes | +|---|---|---|---| +| `shm_open()` / `shm_unlink()` | yes | yes | provided through `sys_mman` in the live source tree | +| named POSIX semaphores | no | yes | added by `P3-semaphore-fixes.patch` on top of `shm_open()` / `mmap()` | +| `eventfd` | no | yes | added by `P3-eventfd-mod.patch` through `/scheme/event/eventfd/...` | +| `signalfd` | no | yes | added by `P3-signalfd.patch` through `/scheme/event` plus signal-mask handling | +| `timerfd` | no | yes | added by `P3-timerfd-relative.patch` through `/scheme/time/{clockid}` | +| `waitid()` | no | yes | added by `P3-waitid.patch` | +| `ifaddrs` / `net_if` support used by IPC-adjacent consumers | no | yes | added by `P3-ifaddrs-net_if.patch`; currently synthetic | +| SysV shm (`sys/shm.h`) | no | yes | activated via `P3-sysv-shm-impl.patch` in recipe (2026-04-29) | +| SysV sem (`sys/sem.h`) | no | yes | activated via `P3-sysv-sem-impl.patch` in recipe (2026-04-29) | +| POSIX message queues (`mqueue.h`) | no | no | still TODO in the live source tree | +| SysV message queues (`sys/msg.h`) | no | no | still TODO in the live source tree | + +## Observed limitations + +### Named POSIX semaphores + +The active patch chain implements named semaphores by storing a `Semaphore` inside shared memory +opened through `shm_open()` and mapped with `mmap()`. That is a useful bounded compatibility path, +but it should still be described as a Red Bear recipe-applied layer, not a plain-source upstream +relibc completion. + +### fd-event APIs + +`eventfd`, `signalfd`, and `timerfd` are present in the active build, but they are all scheme-backed +compatibility layers: + +- `eventfd` depends on `/scheme/event/eventfd/...` +- `signalfd` depends on `/scheme/event` and blocks the supplied mask with `sigprocmask()` +- `timerfd` depends on `/scheme/time/{clockid}` and currently rejects unsupported flag combinations + +These are real compatibility layers, but they should still be described as bounded until broader +consumer/runtime proof is recorded. + +### Deferred SysV shm/sem work + +SysV shm/sem carriers were activated in recipe (2026-04-29). Message queues remain deferred follow-up work. + +### Interface enumeration used by networking-adjacent consumers + +The current `P3-ifaddrs-net_if.patch` replaces `ENOSYS`, but it does so with a synthetic two-entry +model: + +- `loopback` +- `eth0` + +That is enough for some bounded consumers, but it should not be described as live full interface +enumeration. + +## Downstream pressure + +### Qt / KDE + +Qt and KDE remain the strongest pressure on relibc IPC semantics. + +They do not only need headers to exist. They need the active compatibility layers to behave well +enough for: + +- shared-memory consumers, +- named semaphore consumers, +- direct `eventfd` / `timerfd` users, +- and process-control paths such as `waitid()`. + +### Wayland-facing consumers + +Wayland-facing pressure is strongest on the fd-event side of the IPC story: + +- `eventfd` +- `signalfd` +- `timerfd` + +That is a different pressure profile from the SysV and named-semaphore side. + +## Fresh verification in this pass + +This pass revalidated the active concrete-wave IPC-facing surface through the relibc test recipe: + +- `sys_eventfd/eventfd` +- `sys_signalfd/signalfd` +- `sys_timerfd/timerfd` +- `waitid` +- `semaphore/named` +- `semaphore/unnamed` + +These are bounded relibc-target proofs. They improve confidence in the active fd-event and named +semaphore surface. SysV shm/sem are now active in the recipe (2026-04-29); message queues remain deferred. + +## Improvement plan + +### Phase I1 — Keep IPC claims aligned with the active build surface + +- document patch-applied IPC layers as patch-applied +- stop describing them as plain-source-visible unless they move into the live source tree +- keep this doc aligned with `recipes/core/relibc/recipe.toml` + +### Phase I2 — Decide the support contract for bounded IPC layers + +For each major IPC area, choose one of these paths explicitly: + +- bounded compatibility layer with honest documentation, +- or broader semantics work with explicit proof targets. + +This is especially important for: + +- SysV shm, +- SysV sem, +- named semaphores, +- and `ifaddrs`-driven interface discovery. + +### Phase I3 — Add proof where current docs only imply confidence + +Highest-value areas: + +- the fd-event slice used by Wayland-facing consumers, +- shared-memory and named-semaphore behavior used by Qt/KDE, +- and the currently synthetic interface-discovery path. + +### Phase I4 — Triage message queues directly + +Message queues are still genuine absences, not just bounded implementations. + +This doc should keep them visible until Red Bear either: + +- implements them, +- proves they are unnecessary for the intended consumer set, +- or explicitly documents them as deferred/non-goals. + +### Phase I5 — Converge with upstream deliberately + +When upstream relibc absorbs equivalent IPC functionality, prefer the upstream path and shrink the +Red Bear patch chain. Until then, keep the active IPC carrier set explicit and documented. + +## Bottom line + +The current Red Bear relibc IPC story is **material patch-applied compatibility, not plain-source +completion**. + +That is still valuable progress, but the repo should describe it honestly: several important IPC +surfaces exist in the active build, several of them are still bounded, and message queues remain a +real missing area. diff --git a/local/docs/SCRIPT-BEHAVIOR-MATRIX.md b/local/docs/SCRIPT-BEHAVIOR-MATRIX.md new file mode 100644 index 0000000000..28c6696628 --- /dev/null +++ b/local/docs/SCRIPT-BEHAVIOR-MATRIX.md @@ -0,0 +1,114 @@ +# Red Bear OS Script Behavior Matrix + +## Purpose + +This document centralizes what the main repository scripts do and do not handle under the Red Bear +release fork model. + +The goal is to remove guesswork from the sync/fetch/apply/build workflow. + +## Matrix + +| Script | Primary role | What it handles | What it does **not** guarantee | +|---|---|---|---| +| `local/scripts/provision-release.sh` | Refresh top-level upstream repo state | fetches upstream, reports conflict risk, rebases repo commits, reapplies build-system release fork via `apply-patches.sh` | does not automatically solve every subsystem release fork conflict; does not by itself make upstream WIP recipes safe shipping inputs | +| `local/scripts/apply-patches.sh` | Reapply durable Red Bear release fork | applies build-system patches, relinks recipe patch symlinks, relinks local recipe release fork into `recipes/` | does not fully rebase stale patch carriers; does not validate runtime behavior; does not decide WIP ownership for you | +| `local/scripts/build-redbear.sh` | Build Red Bear profiles from upstream base + local release fork | applies release fork, builds cookbook if needed, validates profile naming, launches the actual image build; only allows upstream recipe immutable archived when passed `--upstream` | does not guarantee every nested upstream source tree is fresh; does not replace explicit subsystem/runtime validation | +| `scripts/fetch-all-sources.sh` | Fetch mainline recipe source inputs for builds | downloads mainline/upstream recipe sources, reports status/preflight, and supports config-scoped fetches while leaving local release fork in place | does not mean fetched upstream WIP source is the durable shipping source of truth | +| `local/scripts/fetch-sources.sh` | Fetch mainline recipe sources for browsing and patching | when passed `--upstream`, fetches `recipes/*` source trees so the upstream-managed side is locally available for reading, editing, and patch preparation | does not decide whether upstream should replace the local release fork | +| `local/scripts/build-redbear-wifictl-redox.sh` | Build `redbear-wifictl` for the Redox target with the repo toolchain | prepends `prefix/x86_64-unknown-redox/sysroot/bin` to `PATH` and runs `cargo build --target x86_64-unknown-redox` in the `redbear-wifictl` crate | does not prove runtime Wi-Fi behavior; only closes the target-build environment gap for this crate | +| `local/scripts/test-iwlwifi-driver-runtime.sh` | Exercise the bounded Intel driver lifecycle inside a target runtime | validates bounded probe/prepare/init/activate/scan/connect/disconnect/retry surfaces for `redbear-iwlwifi` on a live target runtime | does not prove real AP association, packet flow, DHCP success over Wi-Fi, or end-to-end connectivity | +| `local/scripts/test-wifi-control-runtime.sh` | Exercise the bounded Wi-Fi control/profile lifecycle inside a target runtime | validates `/scheme/wifictl` control nodes, bounded connect/disconnect behavior, and profile-manager/runtime reporting surfaces on a live target runtime | does not prove real AP association or end-to-end connectivity | +| `local/scripts/test-wifi-baremetal-runtime.sh` | Exercise bounded Intel Wi-Fi runtime lifecycle on a target system | validates driver probe, control probe, bounded connect/disconnect, profile-manager start/stop via the `wifi-open-bounded` profile, Wi-Fi lifecycle reporting, and writes `/tmp/redbear-phase5-wifi-capture.json` on the target | does not prove real AP association, packet flow, DHCP success over Wi-Fi, or end-to-end hardware connectivity | +| `local/scripts/test-wifi-passthrough-qemu.sh` | Launch Red Bear with VFIO-passed Intel Wi-Fi hardware | boots a Red Bear guest with a passed-through Intel Wi-Fi PCI function, auto-runs the in-guest bounded Wi-Fi validation command, and can copy the packaged capture bundle back to a host-side file during `--check` | depends on host VFIO setup and still does not by itself guarantee real AP association or end-to-end Wi-Fi connectivity | +| `local/scripts/test-bluetooth-runtime.sh` | Compatibility guest entrypoint for the bounded Bluetooth Battery Level slice | runs the packaged `redbear-bluetooth-battery-check` helper inside a Redox guest or target runtime | does not run on the host and does not expand the Bluetooth support claim beyond the packaged checker’s bounded scope | +| `local/scripts/test-bluetooth-qemu.sh` | Launch or validate the bounded Bluetooth Battery Level slice in QEMU | boots `redbear-bluetooth-experimental`, auto-runs the packaged checker during `--check`, reruns it in one boot, and reruns it again after a clean reboot | does not by itself guarantee that the current QEMU proof passes; does not prove real controller bring-up, generic BLE/GATT maturity, write/notify support, or real hardware Bluetooth behavior | +| `local/scripts/test-drm-display-runtime.sh` | Run the bounded DRM/KMS display checker in a target runtime | invokes the packaged `redbear-drm-display-check` helper for AMD or Intel, proving scheme/card reachability, connector/mode enumeration, and bounded direct modeset proof over the Red Bear DRM ioctl surface when requested | does not prove render command submission, fence semantics, or hardware rendering | +| `local/scripts/test-amd-gpu.sh` | AMD wrapper for the bounded DRM/KMS display checker | runs `test-drm-display-runtime.sh --vendor amd` | still only display-path evidence | +| `local/scripts/test-intel-gpu.sh` | Intel wrapper for the bounded DRM/KMS display checker | runs `test-drm-display-runtime.sh --vendor intel` | still only display-path evidence | +| `local/scripts/test-msix-qemu.sh` | Bounded MSI-X proof in QEMU | validates that the current virtio-net guest path reaches MSI-X-capable interrupt delivery and emits normalized `IRQ_DRIVER`, `IRQ_MODE`, `IRQ_REASON`, and `IRQ_LOG` output for the bounded guest/runtime proof | does not prove broad hardware MSI-X reliability or per-device fallback behavior outside the bounded guest path | +| `local/scripts/test-iommu-qemu.sh` | Bounded IOMMU first-use proof in QEMU | validates guest-visible AMD-Vi initialization and bounded event/drain behavior through the current `iommu` runtime path | does not prove real-hardware interrupt remapping quality or full DMA-remapping correctness | +| `local/scripts/test-xhci-irq-qemu.sh` | Bounded xHCI interrupt-mode proof in QEMU | validates that the xHCI guest path reaches an interrupt-driven mode under the current bounded runtime checker and emits normalized `IRQ_DRIVER`, `IRQ_MODE`, `IRQ_REASON`, and `IRQ_LOG` output | does not prove full USB topology maturity or broad hardware interrupt robustness | +| `local/scripts/test-lowlevel-controllers-qemu.sh` | Aggregate bounded low-level controller proof wrapper | runs MSI-X, xHCI IRQ, IOMMU first-use, PS/2/serio, and monotonic timer proofs in one sequence, defaulting to `redbear-mini` while automatically upgrading only the IOMMU leg to `redbear-full` because that runtime currently ships `/usr/bin/iommu`; if the required `redbear-full` image is absent, that single IOMMU leg is explicitly skipped rather than aborting the rest of the bounded wrapper | does not replace the individual proof helpers and does not prove real-hardware controller quality | +| `local/scripts/prepare-wifi-vfio.sh` | Prepare or restore an Intel Wi-Fi PCI function for passthrough | binds a chosen PCI function to `vfio-pci` or restores it to a specified host driver | does not verify guest Wi-Fi functionality and must be used carefully on a host with a safe detachable target device | +| `local/scripts/validate-wifi-vfio-host.sh` | Check whether a host looks ready for Wi-Fi VFIO testing | validates PCI presence, current driver, UEFI firmware, Red Bear image presence, QEMU/expect availability, VFIO module state, and IOMMU group visibility; exits non-zero when blockers are found | does not bind devices or prove the guest Wi-Fi stack works | +| `local/scripts/run-wifi-passthrough-validation.sh` | End-to-end host-side passthrough validation wrapper | prepares VFIO, runs the packaged in-guest Wi-Fi validation path, captures the guest JSON artifact to the host, writes a host-side metadata sidecar, and restores the host driver afterwards | still depends on real VFIO/hardware support and does not itself guarantee end-to-end Wi-Fi connectivity | +| `local/scripts/package-wifi-validation-artifacts.sh` | Bundle Wi-Fi validation evidence into one archive | packages common capture/log artifacts from bare-metal or VFIO validation runs into a single tarball | does not create missing artifacts or validate their contents | +| `local/scripts/summarize-wifi-validation-artifacts.sh` | Summarize Wi-Fi validation evidence quickly | extracts key runtime signals from a capture JSON or packaged tarball for fast triage | does not replace full artifact review or prove runtime correctness | +| `local/scripts/finalize-wifi-validation-run.sh` | One-shot post-run Wi-Fi triage helper | runs the packaged analyzer on a capture JSON and then packages the chosen artifacts into a tarball | still depends on a real target run having produced the capture/artifacts first | + +The packaged companion command for those scripts is `redbear-phase5-wifi-check`, which performs the +bounded in-target Wi-Fi lifecycle checks from inside the guest/runtime itself. + +The packaged Bluetooth companion command is `redbear-bluetooth-battery-check`, which is intended to +perform the bounded Bluetooth Battery Level checks from inside the guest/runtime itself, including +repeated helper runs, daemon-restart coverage, failure-path honesty checks, and stale-state cleanup +checks within the current slice boundary. + +The packaged DRM display companion command is `redbear-drm-display-check`, which is intended to +perform bounded DRM/KMS display-side checks from inside the guest/runtime itself. It now covers +direct connector/mode enumeration and bounded direct modeset proof over the Red Bear DRM ioctl +surface, and explicitly does not claim render or hardware-accelerated graphics completion. + +The packaged evidence companion is `redbear-phase5-wifi-capture`, which collects the bounded driver, +control, profile-manager, reporting, interface-listing, and scheme-state surfaces — plus `lspci` +and active-profile contents — into a single JSON artifact. + +The packaged link-oriented companion is `redbear-phase5-wifi-link-check`, which focuses on whether +the target runtime is exposing interface/address/default-route signals in addition to the bounded +Wi-Fi lifecycle state. + +For Redox-target Rust builds of Wi-Fi components such as `redbear-wifictl`, a missing +`x86_64-unknown-redox-gcc` on `PATH` should first be treated as a host toolchain/path issue if the +repo already contains `prefix/x86_64-unknown-redox/sysroot/bin/x86_64-unknown-redox-gcc`. + +## Policy Mapping + +### Resilience / offline-first package sourcing + +Default Red Bear behavior is local-first: + +- use locally available package/source trees and release fork state for normal builds, +- treat upstream immutable archived as an explicit operator action only (`--upstream`, dedicated fetch/sync), +- do not fail policy-level expectations just because upstream network access is temporarily broken. + +This is required so builds and recovery workflows remain operable during upstream outages or +connectivity failures. + +### Upstream sync + +Use `local/scripts/provision-release.sh` when the goal is to immutable archived the top-level upstream Redox base. + +This is a repository sync operation, not a guarantee that every local subsystem release fork is already +rebased cleanly. + +### Overlay reapplication + +Use `local/scripts/apply-patches.sh` when the goal is to reconstruct Red Bear’s release fork on top of a +fresh upstream tree. + +This is the core durable-state recovery path. + +### Build execution + +Use `local/scripts/build-redbear.sh` when the goal is to build a tracked Red Bear profile from the +current upstream base plus local release fork. Add `--upstream` only when you explicitly want Redox/upstream +recipe sources immutable archived during that build. + +### Source immutable archived + +Use `scripts/fetch-all-sources.sh` and `local/scripts/fetch-sources.sh --upstream` when the goal is to +immutable archived recipe source inputs, but do not confuse fetched upstream WIP source with a trusted shipping +source. + +## WIP Rule in Script Terms + +If a subsystem is still upstream WIP, the scripts should be interpreted this way: + +- fetching upstream WIP source is allowed and useful through the explicit upstream fetch commands or + `--upstream` where a wrapper requires it, +- syncing upstream WIP source is allowed and useful through the explicit upstream sync command, +- but shipping decisions should still prefer the local release fork until upstream promotion and reevaluation happen. + +That means “script fetched it successfully” is not the same as “Red Bear should now ship upstream’s +WIP version directly.” diff --git a/local/recipes/system/cub/source/cub-cli/src/main.rs b/local/recipes/system/cub/source/cub-cli/src/main.rs index 4beaf2c0ec..f9bdbe7931 100644 --- a/local/recipes/system/cub/source/cub-cli/src/main.rs +++ b/local/recipes/system/cub/source/cub-cli/src/main.rs @@ -115,41 +115,65 @@ struct Cli { #[derive(Debug, Subcommand)] enum Commands { /// Install a package from the official repo or BUR + #[command(visible_alias = "S")] Install { package: String }, /// Search packages in the official repo, cached BUR, and AUR + #[command(visible_alias = "Ss")] Search { query: String }, /// Show AUR package details + #[command(visible_alias = "Si")] Info { package: String }, /// Refresh cached package metadata + #[command(visible_alias = "Sy")] Sync, /// Refresh metadata and update installed packages + #[command(visible_alias = "Syu")] SystemUpgrade, /// Build and install a local RBPKGBUILD directory + #[command(visible_alias = "B")] Build { dir: String }, /// Fetch a BUR recipe into the current directory + #[command(visible_alias = "G")] Get { package: String }, /// Import an AUR package into ~/.cub/recipes + #[command(visible_alias = "Ga")] GetAur { package: String }, /// Print raw PKGBUILD from AUR to stdout + #[command(visible_alias = "Gp")] GetPkgbuild { package: String }, /// Inspect an installed package or local RBPKGBUILD + #[command(visible_alias = "x")] Inspect { target: String }, /// Convert an AUR PKGBUILD into an RBPKGBUILD tree + #[command(visible_alias = "Ia")] ImportAur { target: String }, /// Update all installed packages + #[command(visible_alias = "U")] UpdateAll, /// Remove an installed package + #[command(visible_alias = "R")] Remove { package: String }, /// List installed packages + #[command(visible_alias = "Q")] QueryLocal, /// Show installed package details + #[command(visible_alias = "Qi")] QueryInfo { package: String }, /// List files installed by a package + #[command(visible_alias = "Ql")] QueryList { package: String }, /// Clean cub and pkg download caches + #[command(visible_alias = "Sc")] CleanCache, /// Clean all caches including build artifacts (~/.cub/tmp/) + #[command(visible_alias = "Scc")] CleanAll, + /// Check for available updates without installing + #[command(visible_alias = "Qu")] + UpdatesAvailable, + /// Find which package owns a file + #[command(visible_alias = "Qo")] + OwnsFile { path: String }, } struct AppContext { @@ -259,6 +283,8 @@ fn run_command(context: &AppContext, command: Commands, force: bool, noconfirm: Commands::QueryList { package } => query_local_files(context, &package)?, Commands::CleanCache => clean_cache()?, Commands::CleanAll => clean_all()?, + Commands::UpdatesAvailable => updates_available(context)?, + Commands::OwnsFile { path } => owns_file(context, &path)?, } Ok(()) @@ -283,7 +309,7 @@ fn rewrite_shortcut_args( }; let known = ["-S", "-B", "-G", "-R", "-Q", "-Ss", "-Si", "-Sy", "-Syu", "-Sf", - "-Sua", "-Sc", "-Scc", "-Pi", "-Qi", "-Ql", "-Gp", + "-Sua", "-Sc", "-Scc", "-Pi", "-Qi", "-Ql", "-Qo", "-Qu", "-Gp", "--import-aur"]; if flag.starts_with('-') && flag.len() > 2 && !flag.starts_with("--") && !known.contains(&flag) { @@ -348,6 +374,8 @@ fn rewrite_shortcut_args( "-Sc" => rewrite_flag("clean-cache"), "-Scc" => rewrite_flag("clean-all"), "-Gp" => rewrite_value("get-pkgbuild", "package"), + "-Qu" => rewrite_flag("updates-available"), + "-Qo" => rewrite_value("owns-file", "path"), _ => Ok(collected), } } @@ -1124,6 +1152,108 @@ fn clean_all() -> Result<(), Box> { Ok(()) } +fn updates_available( + context: &AppContext, +) -> Result<(), Box> { + host_only_notice("updates-available")?; + let library = context.open_library()?; + let installed_packages = library.get_installed_packages()?; + let package_state = PackageState::from_sysroot(&context.install_path)?; + + let mut count = 0u32; + for pkg_name in &installed_packages { + let Some(install_state) = package_state.installed.get(pkg_name) else { + continue; + }; + + let remote_path = context + .install_path + .join(PACKAGES_HEAD_DIR) + .join(format!("{}.pkgar_head", install_state.remote)); + if !remote_path.exists() { + continue; + } + + let local_path = context + .install_path + .join(PACKAGES_HEAD_DIR) + .join(format!("{}.pkgar_head", pkg_name.as_str())); + if !local_path.exists() { + continue; + } + + let remote_meta = fs::metadata(&remote_path)?; + let local_meta = fs::metadata(&local_path)?; + if remote_meta.modified()? > local_meta.modified()? { + println!("{} (remote newer)", pkg_name.as_str()); + count += 1; + } + } + + if count == 0 { + println!("All packages are up to date."); + } else { + println!("{count} package(s) have available updates."); + } + + Ok(()) +} + +fn owns_file( + context: &AppContext, + target_path: &str, +) -> Result<(), Box> { + host_only_notice("owns-file")?; + let library = context.open_library()?; + let installed_packages = library.get_installed_packages()?; + let package_state = PackageState::from_sysroot(&context.install_path)?; + + let search = target_path.trim_start_matches('/'); + let mut found = Vec::new(); + + for pkg_name in &installed_packages { + let Some(install_state) = package_state.installed.get(pkg_name) else { + continue; + }; + let Some(repo_key) = package_state.pubkeys.get(&install_state.remote) else { + continue; + }; + + let head_path = context + .install_path + .join(PACKAGES_HEAD_DIR) + .join(format!("{}.pkgar_head", pkg_name.as_str())); + + let Ok(mut package_file) = PackageFile::new(&head_path, &repo_key.pkey) else { + continue; + }; + + let Ok(entries) = package_file.read_entries() else { + continue; + }; + + for entry in entries { + let Ok(relative) = entry.check_path() else { + continue; + }; + if relative.display().to_string() == search { + found.push(pkg_name.as_str().to_string()); + break; + } + } + } + + if found.is_empty() { + println!("No package owns '{}'.", target_path); + } else { + for pkg in &found { + println!("{} is owned by {}", target_path, pkg); + } + } + + Ok(()) +} + fn get_pkgbuild_stdout(package: &str) -> Result<(), Box> { validate_git_target(package)?; let repo_url = aur_repo_url(package); diff --git a/local/recipes/system/cub/source/cub-tui/src/app.rs b/local/recipes/system/cub/source/cub-tui/src/app.rs index ad5f40e0bf..1447be44d9 100644 --- a/local/recipes/system/cub/source/cub-tui/src/app.rs +++ b/local/recipes/system/cub/source/cub-tui/src/app.rs @@ -16,7 +16,7 @@ use ratatui::prelude::TermionBackend; use ratatui::style::Style; use ratatui::widgets::Block; use ratatui::Terminal; -use termion::event::Key; +use termion::event::{Event, Key}; use termion::input::TermRead; use termion::raw::IntoRawMode; use termion::screen::IntoAlternateScreen; @@ -27,6 +27,7 @@ const DEFAULT_TARGET: &str = "x86_64-unknown-redox"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum View { + Home, Search, PackageInfo, Install, @@ -47,6 +48,16 @@ pub(crate) struct QueryEntry { kind: QueryEntryKind, } +impl QueryEntry { + pub(crate) fn is_recipe(&self) -> bool { + self.kind == QueryEntryKind::Recipe + } + + pub(crate) fn is_package(&self) -> bool { + self.kind == QueryEntryKind::Package + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum QueryEntryKind { Recipe, @@ -79,6 +90,8 @@ pub struct CubApp { action_receiver: Option>, active_action: Option, tick: usize, + show_help: bool, + last_sync: Option, } impl CubApp { @@ -102,9 +115,9 @@ impl CubApp { search_query: String::new(), search_results: Vec::new(), selected_index: 0, - current_view: View::Search, + current_view: View::Home, status_message: if aur_client.is_some() { - "Type a query, press Enter to search AUR, Tab to change views.".into() + "Welcome to cub. Tab to change views, ? for help.".into() } else { "AUR offline — Query view available, Tab to change views.".into() }, @@ -125,6 +138,8 @@ impl CubApp { action_receiver: None, active_action: None, tick: 0, + show_help: false, + last_sync: None, }; let _ = app.refresh_query_view(); app @@ -137,14 +152,14 @@ impl CubApp { let mut terminal = Terminal::new(backend).map_err(terminal_error)?; terminal.clear().map_err(terminal_error)?; - let mut events = termion::async_stdin().keys(); + let mut events = termion::async_stdin().events(); self.run_inner(&mut terminal, &mut events) } pub fn run_inner( &mut self, terminal: &mut Terminal>>>, - events: &mut impl Iterator>, + events: &mut impl Iterator>, ) -> Result<(), CubError> { let run_result = (|| -> Result<(), CubError> { while self.running { @@ -156,7 +171,8 @@ impl CubApp { if let Some(event) = events.next() { match event { - Ok(key) => self.handle_key(key), + Ok(Event::Key(key)) => self.handle_key(key), + Ok(_) => {} Err(error) => { self.status_message = format!("Input error: {error}"); self.running = false; @@ -185,31 +201,51 @@ impl CubApp { let layout = Layout::default() .direction(Direction::Vertical) .constraints([ + Constraint::Length(1), Constraint::Length(3), Constraint::Min(8), Constraint::Length(2), ]) .split(area); - crate::views::render_tabs(frame, layout[0], self, &theme); + let title_bar = crate::widgets::styled_title_bar(&theme, self.tick); + frame.render_widget(title_bar, layout[0]); + + crate::views::render_tabs(frame, layout[1], self, &theme); match self.current_view { - View::Search => crate::views::search::render(frame, layout[1], self, &theme), - View::PackageInfo => crate::views::info::render(frame, layout[1], self, &theme), - View::Install => crate::views::install::render(frame, layout[1], self, &theme), - View::Build => crate::views::build::render(frame, layout[1], self, &theme), - View::Query => crate::views::query::render(frame, layout[1], self, &theme), + View::Home => crate::views::home::render(frame, layout[2], self, &theme), + View::Search => crate::views::search::render(frame, layout[2], self, &theme), + View::PackageInfo => crate::views::info::render(frame, layout[2], self, &theme), + View::Install => crate::views::install::render(frame, layout[2], self, &theme), + View::Build => crate::views::build::render(frame, layout[2], self, &theme), + View::Query => crate::views::query::render(frame, layout[2], self, &theme), } - crate::views::render_status(frame, layout[2], self, &theme); + crate::views::render_status(frame, layout[3], self, &theme); + + if self.show_help { + crate::widgets::help_overlay(frame, area, &theme); + } } pub fn handle_key(&mut self, key: Key) { + if self.show_help { + if matches!(key, Key::Char('?') | Key::Esc) { + self.show_help = false; + } + return; + } + match key { Key::Char('q') | Key::Esc => { self.running = false; return; } + Key::Char('?') => { + self.show_help = true; + return; + } Key::Char('/') => { self.current_view = View::Search; self.selected_index = self @@ -230,6 +266,7 @@ impl CubApp { } match self.current_view { + View::Home => {} View::Search => crate::views::search::handle_key(self, key), View::PackageInfo => crate::views::info::handle_key(self, key), View::Install => crate::views::install::handle_key(self, key), @@ -266,9 +303,24 @@ impl CubApp { self.build_running } - pub(crate) fn spinner_frame(&self) -> char { - const FRAMES: [char; 4] = ['-', '\\', '|', '/']; - FRAMES[self.tick % FRAMES.len()] + pub fn last_sync_display(&self) -> String { + match self.last_sync { + Some(instant) => { + let elapsed = instant.elapsed(); + if elapsed.as_secs() < 60 { + format!("{}s ago", elapsed.as_secs()) + } else if elapsed.as_secs() < 3600 { + format!("{}m ago", elapsed.as_secs() / 60) + } else { + format!("{}h ago", elapsed.as_secs() / 3600) + } + } + None => "never".to_string(), + } + } + + pub(crate) fn tick(&self) -> usize { + self.tick } pub fn search(&mut self) { @@ -289,6 +341,7 @@ impl CubApp { Ok(results) => { self.search_results = results; self.selected_index = 0; + self.last_sync = Some(std::time::Instant::now()); self.status_message = format!( "Found {} AUR package(s) for {:?}.", self.search_results.len(), @@ -405,6 +458,7 @@ impl CubApp { pub fn move_selection(&mut self, delta: isize) { let len = match self.current_view { + View::Home => 0, View::Search | View::PackageInfo => self.search_results.len(), View::Query => self.query_entries.len(), View::Install | View::Build => 0, @@ -471,12 +525,14 @@ impl CubApp { fn cycle_view(&mut self, forward: bool) { self.current_view = match (self.current_view, forward) { + (View::Home, true) => View::Search, (View::Search, true) => View::PackageInfo, (View::PackageInfo, true) => View::Install, (View::Install, true) => View::Build, (View::Build, true) => View::Query, - (View::Query, true) => View::Search, - (View::Search, false) => View::Query, + (View::Query, true) => View::Home, + (View::Home, false) => View::Query, + (View::Search, false) => View::Home, (View::PackageInfo, false) => View::Search, (View::Install, false) => View::PackageInfo, (View::Build, false) => View::Install, diff --git a/local/recipes/system/cub/source/cub-tui/src/theme.rs b/local/recipes/system/cub/source/cub-tui/src/theme.rs index 4c6293bfd6..97c5687aa7 100644 --- a/local/recipes/system/cub/source/cub-tui/src/theme.rs +++ b/local/recipes/system/cub/source/cub-tui/src/theme.rs @@ -10,6 +10,11 @@ pub struct RedBearTheme { pub success: Color, pub warning: Color, pub error: Color, + pub title_bg: Color, + pub title_accent: Color, + pub gauge_bg: Color, + pub gauge_fg: Color, + pub overlay_bg: Color, } impl Default for RedBearTheme { @@ -23,6 +28,11 @@ impl Default for RedBearTheme { success: Color::Rgb(116, 199, 147), warning: Color::Rgb(255, 191, 87), error: Color::Rgb(239, 83, 80), + title_bg: Color::Rgb(20, 20, 26), + title_accent: Color::Rgb(200, 50, 60), + gauge_bg: Color::Rgb(36, 36, 42), + gauge_fg: Color::Rgb(181, 36, 48), + overlay_bg: Color::Rgb(18, 18, 24), } } } @@ -36,6 +46,27 @@ impl RedBearTheme { Style::default().bg(self.background).fg(self.muted) } + pub fn dim_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.muted) + .add_modifier(Modifier::DIM) + } + + pub fn bold_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.text) + .add_modifier(Modifier::BOLD) + } + + pub fn italic_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.muted) + .add_modifier(Modifier::ITALIC) + } + pub fn status_style(self) -> Style { Style::default().bg(self.surface).fg(self.text) } @@ -56,4 +87,72 @@ impl RedBearTheme { .fg(self.text) .add_modifier(Modifier::BOLD) } + + pub fn success_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.success) + .add_modifier(Modifier::BOLD) + } + + pub fn warning_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.warning) + .add_modifier(Modifier::BOLD) + } + + pub fn error_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.error) + .add_modifier(Modifier::BOLD) + } + + pub fn title_bar_style(self) -> Style { + Style::default() + .bg(self.title_bg) + .fg(self.title_accent) + .add_modifier(Modifier::BOLD) + } + + pub fn title_text_style(self) -> Style { + Style::default() + .bg(self.title_bg) + .fg(self.text) + .add_modifier(Modifier::BOLD) + } + + pub fn gauge_style(self) -> Style { + Style::default() + .bg(self.gauge_bg) + .fg(self.gauge_fg) + .add_modifier(Modifier::BOLD) + } + + pub fn overlay_style(self) -> Style { + Style::default() + .bg(self.overlay_bg) + .fg(self.text) + } + + pub fn overlay_border_style(self) -> Style { + Style::default() + .fg(self.accent) + .add_modifier(Modifier::BOLD) + } + + pub fn overlay_title_style(self) -> Style { + Style::default() + .bg(self.accent) + .fg(self.text) + .add_modifier(Modifier::BOLD) + } + + pub fn logo_accent_style(self) -> Style { + Style::default() + .bg(self.background) + .fg(self.accent) + .add_modifier(Modifier::BOLD) + } } diff --git a/local/recipes/system/cub/source/cub-tui/src/views/build.rs b/local/recipes/system/cub/source/cub-tui/src/views/build.rs index 881a6fc938..6bc07ee654 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/build.rs +++ b/local/recipes/system/cub/source/cub-tui/src/views/build.rs @@ -1,4 +1,7 @@ -use ratatui::layout::Rect; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Gauge, Paragraph, Wrap}; use termion::event::Key; use crate::app::CubApp; @@ -6,13 +9,86 @@ use crate::theme::RedBearTheme; use crate::widgets; pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { - let title = if app.build_running() { - format!("Build {} running", app.spinner_frame()) + let running = app.build_running(); + let braille = crate::widgets::braille_frame(app.tick()); + + let title = if running { + format!(" Build {} running ", braille) } else { - "Build Progress".to_string() + " Build Progress ".to_string() }; - let body = widgets::log_paragraph(app.build_log(), &title, theme, true); - frame.render_widget(body, area); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(4), + Constraint::Length(3), + Constraint::Length(2), + ]) + .split(area); + + let log_block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(theme.base_style()) + .border_style(if running { + theme.focused_border_style() + } else { + theme.border_style() + }); + + let log_lines = widgets::semantic_log_lines(app.build_log(), theme); + let body = Paragraph::new(log_lines) + .wrap(Wrap { trim: false }) + .block(log_block); + frame.render_widget(body, layout[0]); + + if running { + let pulse = ((app.tick() as f64 * 0.03).sin() * 0.5 + 0.5).clamp(0.0, 1.0); + let gauge_label = format!("{} building...", braille); + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.border_style()), + ) + .gauge_style(theme.gauge_style()) + .ratio(pulse) + .label(gauge_label); + frame.render_widget(gauge, layout[1]); + + let hints = Paragraph::new(Line::from(vec![ + Span::styled( + " b", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" retry ", theme.dim_style()), + Span::styled( + "←", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" back", theme.dim_style()), + ])) + .style(theme.base_style()); + frame.render_widget(hints, layout[2]); + } else { + let hints = Paragraph::new(Line::from(vec![ + Span::styled( + " b", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" build ", theme.dim_style()), + Span::styled( + "←", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" back", theme.dim_style()), + ])) + .style(theme.base_style()); + frame.render_widget(hints, layout[2]); + } } pub fn handle_key(app: &mut CubApp, key: Key) { diff --git a/local/recipes/system/cub/source/cub-tui/src/views/home.rs b/local/recipes/system/cub/source/cub-tui/src/views/home.rs new file mode 100644 index 0000000000..c7ed563810 --- /dev/null +++ b/local/recipes/system/cub/source/cub-tui/src/views/home.rs @@ -0,0 +1,125 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Wrap}; +use termion::event::Key; + +use crate::app::CubApp; +use crate::theme::RedBearTheme; +use crate::widgets; + +pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(14), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(area); + + let logo = build_logo(theme); + let logo_block = widgets::block(" Dashboard", theme, true); + let logo_para = Paragraph::new(logo) + .block(logo_block) + .wrap(Wrap { trim: false }); + frame.render_widget(logo_para, layout[0]); + + let stats = build_stats(app, theme); + let stats_block = widgets::block(" Statistics", theme, false); + let stats_para = Paragraph::new(stats) + .block(stats_block) + .wrap(Wrap { trim: false }); + frame.render_widget(stats_para, layout[1]); + + let hints = Paragraph::new("Tab next view • ? help") + .style(theme.dim_style()); + frame.render_widget(hints, layout[2]); +} + +pub fn handle_key(_app: &mut CubApp, _key: Key) { + // Home view has no special key handling — navigation is global +} + +fn build_logo(theme: &RedBearTheme) -> Text<'_> { + let accent = Style::default() + .bg(theme.background) + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + let text = Style::default() + .bg(theme.background) + .fg(theme.text); + let dim = Style::default() + .bg(theme.background) + .fg(theme.muted); + + let lines = vec![ + Line::from(vec![ + Span::styled(" ___ ", accent), + ]), + Line::from(vec![ + Span::styled(" / \\ ", accent), + Span::styled(" Red Bear OS", text), + ]), + Line::from(vec![ + Span::styled(" / ◆ \\ ", accent), + Span::styled(" Package Builder", dim), + ]), + Line::from(vec![ + Span::styled(" / \\ ", accent), + ]), + Line::from(vec![ + Span::styled(" / \\ ", accent), + Span::styled(" v0.2.3", dim), + ]), + Line::from(vec![ + Span::styled(" /___________\\ ", accent), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" ◆ ", accent), + Span::styled("cub", text), + Span::styled(" — AUR helper + local recipe build system", dim), + ]), + ]; + Text::from(lines) +} + +fn build_stats(app: &CubApp, theme: &RedBearTheme) -> Text<'static> { + let label = Style::default() + .bg(theme.background) + .fg(theme.muted); + let value = Style::default() + .bg(theme.background) + .fg(theme.text) + .add_modifier(Modifier::BOLD); + let accent_val = Style::default() + .bg(theme.background) + .fg(theme.accent) + .add_modifier(Modifier::BOLD); + + let store_path = app.store.root_dir.display().to_string(); + let recipe_count = app.query_entries().iter().filter(|e| e.is_recipe()).count(); + let package_count = app.query_entries().iter().filter(|e| e.is_package()).count(); + let last_sync = app.last_sync_display(); + + let lines = vec![ + Line::from(vec![ + Span::styled(" Store ", label), + Span::styled(store_path, value), + ]), + Line::from(vec![ + Span::styled(" Recipes ", label), + Span::styled(format!("{recipe_count}"), accent_val), + ]), + Line::from(vec![ + Span::styled(" Packages ", label), + Span::styled(format!("{package_count}"), accent_val), + ]), + Line::from(vec![ + Span::styled(" Last sync ", label), + Span::styled(last_sync, value), + ]), + ]; + Text::from(lines) +} diff --git a/local/recipes/system/cub/source/cub-tui/src/views/info.rs b/local/recipes/system/cub/source/cub-tui/src/views/info.rs index 64edf481c7..82da683875 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/info.rs +++ b/local/recipes/system/cub/source/cub-tui/src/views/info.rs @@ -1,5 +1,6 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::text::Line; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; use ratatui::widgets::{List, ListItem, Paragraph}; use termion::event::Key; @@ -14,36 +15,81 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: & .split(area); let Some(package) = app.selected_package() else { - let empty = Paragraph::new("Select a package in Search to inspect its details.") - .style(theme.base_style()) - .block(widgets::block("Package Info", theme, true)); + let empty = Paragraph::new(Line::from(Span::styled( + "Select a package in Search to inspect its details.", + theme.italic_style(), + ))) + .style(theme.base_style()) + .block(widgets::block(" Package Info", theme, true)); frame.render_widget(empty, layout[0]); return; }; let items = vec![ - ListItem::new(Line::from(format!("Name: {}", package.name))), - ListItem::new(Line::from(format!("Version: {}", package.version))), - ListItem::new(Line::from(format!("Description: {}", package.description))), - ListItem::new(Line::from(format!( - "Depends: {}", - join_or_none(&package.depends) - ))), - ListItem::new(Line::from(format!( - "Make depends: {}", - join_or_none(&package.makedepends) - ))), - ListItem::new(Line::from(format!( - "Optional depends: {}", - join_or_none(&package.optdepends) - ))), + ListItem::new(Line::from(vec![ + Span::styled(" Name ", theme.dim_style()), + Span::styled( + package.name.clone(), + Style::default() + .fg(theme.text) + .add_modifier(Modifier::BOLD), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled(" Version ", theme.dim_style()), + Span::styled(package.version.clone(), theme.success_style()), + ])), + ListItem::new(Line::from(vec![ + Span::styled(" Description ", theme.dim_style()), + Span::styled( + package.description.clone(), + theme.italic_style(), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled(" Depends ", theme.dim_style()), + Span::styled( + join_or_none(&package.depends), + Style::default().fg(theme.text), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled(" Make depends ", theme.dim_style()), + Span::styled( + join_or_none(&package.makedepends), + Style::default().fg(theme.warning), + ), + ])), + ListItem::new(Line::from(vec![ + Span::styled(" Opt depends ", theme.dim_style()), + Span::styled( + join_or_none(&package.optdepends), + Style::default().fg(theme.muted), + ), + ])), ]; - let list = List::new(items).block(widgets::block("Package Info", theme, true)); + let list = List::new(items).block(widgets::block(" Package Info", theme, true)); frame.render_widget(list, layout[0]); - let hints = Paragraph::new("i install • b build local recipe • ← return to search") - .style(theme.muted_style()); + let hints = Paragraph::new(Line::from(vec![ + Span::styled( + " i", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" install ", theme.dim_style()), + Span::styled( + "b", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" build ", theme.dim_style()), + Span::styled( + "←", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" back", theme.dim_style()), + ])) + .style(theme.base_style()); frame.render_widget(hints, layout[1]); } diff --git a/local/recipes/system/cub/source/cub-tui/src/views/install.rs b/local/recipes/system/cub/source/cub-tui/src/views/install.rs index 700d10a755..8ba4d8b3bc 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/install.rs +++ b/local/recipes/system/cub/source/cub-tui/src/views/install.rs @@ -1,4 +1,7 @@ -use ratatui::layout::Rect; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Gauge, Paragraph, Wrap}; use termion::event::Key; use crate::app::CubApp; @@ -6,13 +9,86 @@ use crate::theme::RedBearTheme; use crate::widgets; pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { - let title = if app.install_running() { - format!("Install {} running", app.spinner_frame()) + let running = app.install_running(); + let braille = crate::widgets::braille_frame(app.tick()); + + let title = if running { + format!(" Install {} running ", braille) } else { - "Install Progress".to_string() + " Install Progress ".to_string() }; - let body = widgets::log_paragraph(app.install_log(), &title, theme, true); - frame.render_widget(body, area); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(4), + Constraint::Length(3), + Constraint::Length(2), + ]) + .split(area); + + let log_block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(theme.base_style()) + .border_style(if running { + theme.focused_border_style() + } else { + theme.border_style() + }); + + let log_lines = widgets::semantic_log_lines(app.install_log(), theme); + let body = Paragraph::new(log_lines) + .wrap(Wrap { trim: false }) + .block(log_block); + frame.render_widget(body, layout[0]); + + if running { + let pulse = ((app.tick() as f64 * 0.03).sin() * 0.5 + 0.5).clamp(0.0, 1.0); + let gauge_label = format!("{} installing...", braille); + let gauge = Gauge::default() + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(theme.border_style()), + ) + .gauge_style(theme.gauge_style()) + .ratio(pulse) + .label(gauge_label); + frame.render_widget(gauge, layout[1]); + + let hints = Paragraph::new(Line::from(vec![ + Span::styled( + " i", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" retry ", theme.dim_style()), + Span::styled( + "←", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" back", theme.dim_style()), + ])) + .style(theme.base_style()); + frame.render_widget(hints, layout[2]); + } else { + let hints = Paragraph::new(Line::from(vec![ + Span::styled( + " i", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" install ", theme.dim_style()), + Span::styled( + "←", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" back", theme.dim_style()), + ])) + .style(theme.base_style()); + frame.render_widget(hints, layout[2]); + } } pub fn handle_key(app: &mut CubApp, key: Key) { diff --git a/local/recipes/system/cub/source/cub-tui/src/views/mod.rs b/local/recipes/system/cub/source/cub-tui/src/views/mod.rs index 40e852ce43..794ad642f2 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/mod.rs +++ b/local/recipes/system/cub/source/cub-tui/src/views/mod.rs @@ -1,19 +1,27 @@ pub mod build; +pub mod home; pub mod info; pub mod install; pub mod query; pub mod search; use ratatui::layout::Rect; -use ratatui::text::Line; -use ratatui::widgets::{Paragraph, Tabs}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Tabs}; use crate::app::{CubApp, View}; use crate::theme::RedBearTheme; use crate::widgets; -pub fn render_tabs(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: &RedBearTheme) { +pub fn render_tabs( + frame: &mut ratatui::Frame<'_>, + area: Rect, + app: &CubApp, + theme: &RedBearTheme, +) { let titles = [ + View::Home, View::Search, View::PackageInfo, View::Install, @@ -21,24 +29,42 @@ pub fn render_tabs(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, the View::Query, ] .into_iter() - .map(view_title) - .map(Line::from) + .map(|v| { + let title = view_title(v); + if v == app.current_view { + Line::from(Span::styled( + title, + Style::default() + .fg(theme.text) + .add_modifier(Modifier::BOLD), + )) + } else { + Line::from(Span::styled(title, Style::default().fg(theme.muted))) + } + }) .collect::>(); let selected = match app.current_view { - View::Search => 0, - View::PackageInfo => 1, - View::Install => 2, - View::Build => 3, - View::Query => 4, + View::Home => 0, + View::Search => 1, + View::PackageInfo => 2, + View::Install => 3, + View::Build => 4, + View::Query => 5, }; + let tab_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(Style::default().bg(theme.background).fg(theme.text)) + .border_style(theme.focused_border_style()); + let tabs = Tabs::new(titles) - .block(widgets::block("cub-tui", theme, true)) + .block(tab_block) .style(theme.base_style()) .highlight_style(theme.selected_style()) .select(selected) - .divider("|"); + .divider("│"); frame.render_widget(tabs, area); } @@ -48,16 +74,33 @@ pub fn render_status( app: &CubApp, theme: &RedBearTheme, ) { - let text = format!( - "{} [q/esc quit] [Tab views] [/ search]", - app.status_message - ); - let status = Paragraph::new(text).style(theme.status_style()); + let spinner = widgets::braille_frame(app.tick()); + let text = Line::from(vec![ + Span::styled( + format!(" {} ", spinner), + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {} ", app.status_message), + Style::default().bg(theme.surface).fg(theme.text), + ), + Span::styled( + " [q quit] [Tab views] [/ search] [? help] ", + Style::default() + .bg(theme.surface) + .fg(theme.muted) + .add_modifier(Modifier::DIM), + ), + ]); + let status = Paragraph::new(text).style(Style::default().bg(theme.surface)); frame.render_widget(status, area); } fn view_title(view: View) -> &'static str { match view { + View::Home => " Home ", View::Search => " Search ", View::PackageInfo => " Info ", View::Install => " Install ", diff --git a/local/recipes/system/cub/source/cub-tui/src/views/query.rs b/local/recipes/system/cub/source/cub-tui/src/views/query.rs index 70689064f4..1eeb336c2d 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/query.rs +++ b/local/recipes/system/cub/source/cub-tui/src/views/query.rs @@ -1,4 +1,6 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; use ratatui::widgets::{List, ListItem, ListState}; use termion::event::Key; @@ -13,11 +15,25 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: & .split(area); let items = if app.query_entries().is_empty() { - vec![ListItem::new("No local recipes or cached pkgars.")] + vec![ListItem::new(Line::from(Span::styled( + "No local recipes or cached pkgars.", + theme.italic_style(), + )))] } else { app.query_entries() .iter() - .map(|entry| ListItem::new(entry.title.clone())) + .map(|entry| { + let icon = if entry.is_recipe() { "◇" } else { "◈" }; + let style = if entry.is_recipe() { + Style::default().fg(theme.text) + } else { + Style::default().fg(theme.success) + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{} ", icon), Style::default().fg(theme.accent)), + Span::styled(entry.title.clone(), style), + ])) + }) .collect() }; @@ -30,12 +46,12 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: & } let list = List::new(items) - .block(widgets::block("Local Query", theme, true)) + .block(widgets::block(" Local Query", theme, true)) .highlight_style(theme.selected_style()) - .highlight_symbol("» "); + .highlight_symbol("◆ "); frame.render_stateful_widget(list, layout[0], &mut state); - let details = widgets::log_paragraph(app.query_details(), "Details", theme, false); + let details = widgets::log_paragraph(app.query_details(), " Details", theme, false); frame.render_widget(details, layout[1]); } diff --git a/local/recipes/system/cub/source/cub-tui/src/views/search.rs b/local/recipes/system/cub/source/cub-tui/src/views/search.rs index 16f2192e26..83351c76bb 100644 --- a/local/recipes/system/cub/source/cub-tui/src/views/search.rs +++ b/local/recipes/system/cub/source/cub-tui/src/views/search.rs @@ -1,5 +1,6 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::text::Line; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; use ratatui::widgets::{List, ListItem, ListState, Paragraph}; use termion::event::Key; @@ -17,15 +18,31 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: & ]) .split(area); - let search_bar = Paragraph::new(app.search_query.as_str()) - .style(theme.base_style()) - .block(widgets::block("AUR Search", theme, true)); + let search_bar = Paragraph::new(Line::from(vec![ + Span::styled( + " ⌕ ", + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + app.search_query.clone(), + Style::default().fg(theme.text), + ), + Span::styled( + "▎", + Style::default().fg(theme.accent), + ), + ])) + .style(theme.base_style()) + .block(widgets::block(" AUR Search", theme, true)); frame.render_widget(search_bar, layout[0]); let items = if app.search_results.is_empty() { - vec![ListItem::new( + vec![ListItem::new(Line::from(Span::styled( "No AUR results yet. Type a query and press Enter.", - )] + theme.italic_style(), + )))] } else { app.search_results .iter() @@ -36,8 +53,24 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: & package.description.as_str() }; ListItem::new(vec![ - Line::from(format!("{} {}", package.name, package.version)), - Line::from(description.to_string()), + Line::from(vec![ + Span::styled( + format!("{} ", package.name), + Style::default() + .fg(theme.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + package.version.clone(), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::DIM), + ), + ]), + Line::from(Span::styled( + format!(" {}", description), + theme.italic_style(), + )), ]) }) .collect() @@ -52,13 +85,39 @@ pub fn render(frame: &mut ratatui::Frame<'_>, area: Rect, app: &CubApp, theme: & } let results = List::new(items) - .block(widgets::block("Results", theme, false)) + .block(widgets::block(" Results", theme, false)) .highlight_style(theme.selected_style()) - .highlight_symbol("» "); + .highlight_symbol("◆ "); frame.render_stateful_widget(results, layout[1], &mut state); - let hints = Paragraph::new("Enter search • ↑/↓ move • i info • I install • b build") - .style(theme.muted_style()); + let hints = Paragraph::new(Line::from(vec![ + Span::styled( + " Enter", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" search ", theme.dim_style()), + Span::styled( + "↑/↓", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" move ", theme.dim_style()), + Span::styled( + "i", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" info ", theme.dim_style()), + Span::styled( + "I", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" install ", theme.dim_style()), + Span::styled( + "b", + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" build", theme.dim_style()), + ])) + .style(theme.base_style()); frame.render_widget(hints, layout[2]); } diff --git a/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs b/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs index e147654d56..e4dcd8b935 100644 --- a/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs +++ b/local/recipes/system/cub/source/cub-tui/src/widgets/mod.rs @@ -1,6 +1,7 @@ -use ratatui::style::Style; -use ratatui::text::Text; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Gauge, Paragraph, Wrap}; use crate::theme::RedBearTheme; @@ -14,6 +15,7 @@ pub fn block<'a>(title: &'a str, theme: &RedBearTheme, focused: bool) -> Block<' Block::default() .title(title) .borders(Borders::ALL) + .border_type(BorderType::Rounded) .style(Style::default().bg(theme.background).fg(theme.text)) .border_style(border_style) } @@ -35,3 +37,184 @@ pub fn log_paragraph<'a>( .style(theme.base_style()) .block(block(title, theme, focused)) } + +pub fn styled_title_bar(theme: &RedBearTheme, tick: usize) -> Paragraph<'_> { + let braille = braille_frame(tick); + let title = Line::from(vec![ + Span::styled( + " ◆ ", + Style::default() + .bg(theme.accent) + .fg(theme.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "RED BEAR OS", + Style::default() + .bg(theme.title_bg) + .fg(theme.title_accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " cub ", + Style::default() + .bg(theme.title_bg) + .fg(theme.text) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {} ", braille), + Style::default().bg(theme.title_bg).fg(theme.muted), + ), + ]); + Paragraph::new(title).style(Style::default().bg(theme.title_bg)) +} + +pub fn progress_block<'a>( + title: &'a str, + theme: &RedBearTheme, + focused: bool, + running: bool, + tick: usize, +) -> (Block<'a>, Option>) { + let block = block(title, theme, focused); + if running { + let pulse = ((tick as f64 * 0.03).sin() * 0.5 + 0.5).clamp(0.0, 1.0); + let gauge = Gauge::default() + .gauge_style(theme.gauge_style()) + .ratio(pulse) + .label(""); + (block, Some(gauge)) + } else { + (block, None) + } +} + +pub fn help_overlay(frame: &mut ratatui::Frame<'_>, area: Rect, theme: &RedBearTheme) { + let help_width = 52u16; + let help_height = 22u16; + let x = area + .width + .saturating_sub(help_width) + .saturating_sub(2) + / 2; + let y = area + .height + .saturating_sub(help_height) + .saturating_sub(2) + / 2; + let overlay_area = Rect::new( + x, + y, + help_width.min(area.width.saturating_sub(4)), + help_height.min(area.height.saturating_sub(4)), + ); + + frame.render_widget(Clear, overlay_area); + + let keybinds = vec![ + Line::from(""), + Line::from(vec![Span::styled( + " KEYBINDINGS", + theme.overlay_title_style(), + )]), + Line::from(""), + Line::from(vec![ + Span::styled(" Tab / Shift+Tab ", theme.bold_style()), + Span::styled("Cycle views", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" / ", theme.bold_style()), + Span::styled("Focus search", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" ↑/k ↓/j ", theme.bold_style()), + Span::styled("Move selection", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" Enter ", theme.bold_style()), + Span::styled("Execute search", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" i ", theme.bold_style()), + Span::styled("Open package info", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" I ", theme.bold_style()), + Span::styled("Install selected", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" b ", theme.bold_style()), + Span::styled("Build local recipe", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" r ", theme.bold_style()), + Span::styled("Refresh query view", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" ← ", theme.bold_style()), + Span::styled("Go back", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" ? ", theme.bold_style()), + Span::styled("Toggle this help", theme.muted_style()), + ]), + Line::from(vec![ + Span::styled(" q / Esc ", theme.bold_style()), + Span::styled("Quit", theme.muted_style()), + ]), + Line::from(""), + Line::from(vec![Span::styled( + " Press ? or Esc to dismiss", + theme.dim_style(), + )]), + ]; + + let help_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(theme.overlay_style()) + .border_style(theme.overlay_border_style()); + + let paragraph = Paragraph::new(Text::from(keybinds)) + .block(help_block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); +} + +pub fn braille_frame(tick: usize) -> char { + const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + FRAMES[tick % FRAMES.len()] +} + +pub fn semantic_log_lines<'a>(lines: &[String], theme: &RedBearTheme) -> Vec> { + lines + .iter() + .map(|line| { + let lower = line.to_lowercase(); + if lower.contains("completed successfully") + || lower.contains("success") + || lower.contains("installed") + { + Line::from(Span::styled(line.clone(), theme.success_style())) + } else if lower.contains("warning") + || lower.contains("partial") + || lower.contains("skipped") + { + Line::from(Span::styled(line.clone(), theme.warning_style())) + } else if lower.contains("failed") + || lower.contains("error") + || lower.contains("fatal") + { + Line::from(Span::styled(line.clone(), theme.error_style())) + } else if lower.starts_with("command:") + || lower.starts_with("stdout:") + || lower.starts_with("stderr:") + { + Line::from(Span::styled(line.clone(), theme.dim_style())) + } else { + Line::from(Span::styled(line.clone(), theme.base_style())) + } + }) + .collect() +}