max / shop
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 files changed,
+578 insertions,
-252 deletions
| @@ -457,6 +457,18 @@ | |||
| 457 | 457 | "thiserror", | |
| 458 | 458 | ] | |
| 459 | 459 | ||
| 460 | + | [[package]] | |
| 461 | + | name = "nix" | |
| 462 | + | version = "0.31.3" | |
| 463 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 464 | + | checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" | |
| 465 | + | dependencies = [ | |
| 466 | + | "bitflags", | |
| 467 | + | "cfg-if", | |
| 468 | + | "cfg_aliases", | |
| 469 | + | "libc", | |
| 470 | + | ] | |
| 471 | + | ||
| 460 | 472 | [[package]] | |
| 461 | 473 | name = "nu-ansi-term" | |
| 462 | 474 | version = "0.50.3" | |
| @@ -798,14 +810,28 @@ | |||
| 798 | 810 | version = "0.0.1" | |
| 799 | 811 | dependencies = [ | |
| 800 | 812 | "anyhow", | |
| 813 | + | "calloop", | |
| 814 | + | "calloop-wayland-source", | |
| 801 | 815 | "pollster", | |
| 816 | + | "shop-pty", | |
| 802 | 817 | "shop-render", | |
| 803 | 818 | "shop-wayland", | |
| 819 | + | "smithay-client-toolkit", | |
| 804 | 820 | "tracing", | |
| 805 | 821 | "tracing-subscriber", | |
| 822 | + | "wayland-client", | |
| 806 | 823 | "wgpu", | |
| 807 | 824 | ] | |
| 808 | 825 | ||
| 826 | + | [[package]] | |
| 827 | + | name = "shop-pty" | |
| 828 | + | version = "0.0.0" | |
| 829 | + | dependencies = [ | |
| 830 | + | "anyhow", | |
| 831 | + | "nix", | |
| 832 | + | "tracing", | |
| 833 | + | ] | |
| 834 | + | ||
| 809 | 835 | [[package]] | |
| 810 | 836 | name = "shop-render" | |
| 811 | 837 | version = "0.0.0" |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | "crates/kitty-graphics", | |
| 6 | 6 | "crates/shop-wayland", | |
| 7 | 7 | "crates/shop-render", | |
| 8 | + | "crates/shop-pty", | |
| 8 | 9 | ] | |
| 9 | 10 | ||
| 10 | 11 | [workspace.dependencies] | |
| @@ -21,6 +22,7 @@ | |||
| 21 | 22 | swash = "0.2" | |
| 22 | 23 | guillotiere = "0.7" | |
| 23 | 24 | bytemuck = { version = "1", features = ["derive"] } | |
| 25 | + | nix = { version = "0.31", features = ["term", "process", "fs", "ioctl", "signal"] } | |
| 24 | 26 | ||
| 25 | 27 | [workspace.package] | |
| 26 | 28 | edition = "2024" |
| @@ -18,6 +18,11 @@ | |||
| 18 | 18 | [dependencies] | |
| 19 | 19 | shop-wayland = { path = "../shop-wayland" } | |
| 20 | 20 | shop-render = { path = "../shop-render" } | |
| 21 | + | shop-pty = { path = "../shop-pty" } | |
| 22 | + | smithay-client-toolkit.workspace = true | |
| 23 | + | wayland-client.workspace = true | |
| 24 | + | calloop.workspace = true | |
| 25 | + | calloop-wayland-source.workspace = true | |
| 21 | 26 | wgpu.workspace = true | |
| 22 | 27 | pollster.workspace = true | |
| 23 | 28 | anyhow.workspace = true |
| @@ -1,24 +1,22 @@ | |||
| 1 | - | //! Wayland window + event queue for shop. | |
| 1 | + | //! Wayland primitives for shop. | |
| 2 | 2 | //! | |
| 3 | - | //! Owns the SCTK client-side state (compositor, xdg-shell, output, seat, | |
| 4 | - | //! registry) and exposes a [`Window`] that drives the event queue and reports | |
| 5 | - | //! pending changes via a drain-on-dispatch queue. Renderer-agnostic — no wgpu, | |
| 6 | - | //! no image decoding, no fonts. The binary owns the render loop. | |
| 3 | + | //! Owns the SCTK client-side objects (compositor, xdg-shell, output, seat, | |
| 4 | + | //! registry, top-level window). Does NOT own the wayland event queue or | |
| 5 | + | //! implement any SCTK Handler traits — those live in the binary so a single | |
| 6 | + | //! app state can be dispatched from a shared event loop (calloop), which is | |
| 7 | + | //! also where the PTY fd and any other sources land. | |
| 7 | 8 | //! | |
| 8 | - | //! Bootstrapped against SCTK 0.21's `examples/wgpu.rs` structure. See | |
| 9 | - | //! `~/Wiki/shop-overview.md` for the design. | |
| 9 | + | //! Use [`init`] to construct the chrome against a caller-provided queue | |
| 10 | + | //! handle; the caller keeps the [`EventQueue`] to hand to a wayland source. | |
| 10 | 11 | ||
| 11 | 12 | use std::{collections::VecDeque, ptr::NonNull}; | |
| 12 | 13 | ||
| 13 | - | use raw_window_handle::{ | |
| 14 | - | RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, | |
| 15 | - | }; | |
| 16 | - | use smithay_client_toolkit::{ | |
| 14 | + | pub use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; | |
| 15 | + | use raw_window_handle::{WaylandDisplayHandle, WaylandWindowHandle}; | |
| 16 | + | pub use smithay_client_toolkit::{ | |
| 17 | 17 | compositor::{CompositorHandler, CompositorState}, | |
| 18 | - | delegate_dispatch2, delegate_registry, | |
| 19 | 18 | output::{OutputHandler, OutputState}, | |
| 20 | 19 | registry::{ProvidesRegistryState, RegistryState}, | |
| 21 | - | registry_handlers, | |
| 22 | 20 | seat::{Capability, SeatHandler, SeatState}, | |
| 23 | 21 | shell::{ | |
| 24 | 22 | WaylandSurface, | |
| @@ -28,17 +26,16 @@ | |||
| 28 | 26 | }, | |
| 29 | 27 | }, | |
| 30 | 28 | }; | |
| 31 | - | use wayland_client::{ | |
| 32 | - | Connection, EventQueue, Proxy, QueueHandle, | |
| 33 | - | globals::registry_queue_init, | |
| 34 | - | protocol::{wl_output, wl_seat, wl_surface}, | |
| 29 | + | pub use wayland_client::{ | |
| 30 | + | Connection, Dispatch, EventQueue, Proxy, QueueHandle, | |
| 31 | + | globals::{GlobalList, GlobalListContents, registry_queue_init}, | |
| 32 | + | protocol::wl_registry::WlRegistry, | |
| 35 | 33 | }; | |
| 36 | 34 | ||
| 37 | 35 | /// Change reported by the compositor since the last dispatch. | |
| 38 | 36 | #[derive(Debug, Clone)] | |
| 39 | 37 | pub enum Pending { | |
| 40 | - | /// The compositor gave us a new size (may equal the previous size — treat | |
| 41 | - | /// the first Resized after startup as "now safe to draw"). | |
| 38 | + | /// The compositor gave us a new size. | |
| 42 | 39 | Resized { width: u32, height: u32 }, | |
| 43 | 40 | /// User asked to close (xdg-toplevel close). | |
| 44 | 41 | CloseRequested, | |
| @@ -52,195 +49,36 @@ | |||
| 52 | 49 | pub initial_size: (u32, u32), | |
| 53 | 50 | } | |
| 54 | 51 | ||
| 55 | - | /// Owns the SCTK state and the wayland event queue. | |
| 56 | - | pub struct Window { | |
| 57 | - | conn: Connection, | |
| 58 | - | queue: EventQueue<State>, | |
| 59 | - | state: State, | |
| 52 | + | /// Chrome (SCTK client-side state) for one top-level window. Composed into | |
| 53 | + | /// the binary's app state so SCTK Handler traits can be implemented once at | |
| 54 | + | /// the app layer and dispatched by calloop. | |
| 55 | + | pub struct WaylandChrome { | |
| 56 | + | pub registry: RegistryState, | |
| 57 | + | pub seat: SeatState, | |
| 58 | + | pub output: OutputState, | |
| 59 | + | pub compositor: CompositorState, | |
| 60 | + | pub xdg_shell: XdgShell, | |
| 61 | + | pub xdg_window: XdgWindow, | |
| 62 | + | pub pending: VecDeque<Pending>, | |
| 63 | + | pub width: u32, | |
| 64 | + | pub height: u32, | |
| 65 | + | pub exit: bool, | |
| 60 | 66 | } | |
| 61 | 67 | ||
| 62 | - | struct State { | |
| 63 | - | registry: RegistryState, | |
| 64 | - | seat: SeatState, | |
| 65 | - | output: OutputState, | |
| 66 | - | _compositor: CompositorState, | |
| 67 | - | _xdg_shell: XdgShell, | |
| 68 | - | xdg_window: XdgWindow, | |
| 69 | - | pending: VecDeque<Pending>, | |
| 70 | - | width: u32, | |
| 71 | - | height: u32, | |
| 72 | - | exit: bool, | |
| 68 | + | // Chrome construction lives in the binary because the SCTK `bind` calls need | |
| 69 | + | // all the Dispatch impls that `delegate_dispatch2!(App)` provides, and those | |
| 70 | + | // impls are only visible in the crate that invokes the macro. | |
| 71 | + | ||
| 72 | + | ||
| 73 | + | /// Raw handles for the compositor connection and the top-level surface. | |
| 74 | + | /// Both reference wayland pointers owned by `conn`/`chrome`; do not use | |
| 75 | + | /// after either is dropped. | |
| 76 | + | pub fn raw_handles(conn: &Connection, chrome: &WaylandChrome) -> (RawDisplayHandle, RawWindowHandle) { | |
| 77 | + | let display_ptr = | |
| 78 | + | NonNull::new(conn.backend().display_ptr().cast()).expect("wayland display ptr"); | |
| 79 | + | let surface_ptr = NonNull::new(chrome.xdg_window.wl_surface().id().as_ptr().cast()) | |
| 80 | + | .expect("wl_surface ptr"); | |
| 81 | + | let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr)); | |
| 82 | + | let window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr)); | |
| 83 | + | (display, window) | |
| 73 | 84 | } | |
| 74 | - | ||
| 75 | - | impl Window { | |
| 76 | - | /// Connect to the compositor and create the top-level window. The first | |
| 77 | - | /// `dispatch()` call after this will typically yield a `Pending::Resized` | |
| 78 | - | /// carrying the compositor's chosen initial size. | |
| 79 | - | pub fn new(spec: WindowSpec<'_>) -> anyhow::Result<Self> { | |
| 80 | - | let conn = Connection::connect_to_env()?; | |
| 81 | - | let (globals, queue) = registry_queue_init(&conn)?; | |
| 82 | - | let qh = queue.handle(); | |
| 83 | - | ||
| 84 | - | let compositor = CompositorState::bind(&globals, &qh)?; | |
| 85 | - | let xdg_shell = XdgShell::bind(&globals, &qh)?; | |
| 86 | - | ||
| 87 | - | let surface = compositor.create_surface(&qh); | |
| 88 | - | let xdg_window = xdg_shell.create_window(surface, WindowDecorations::ServerDefault, &qh); | |
| 89 | - | xdg_window.set_title(spec.title.to_string()); | |
| 90 | - | xdg_window.set_app_id(spec.app_id.to_string()); | |
| 91 | - | xdg_window.set_min_size(Some(spec.min_size)); | |
| 92 | - | xdg_window.commit(); | |
| 93 | - | ||
| 94 | - | let state = State { | |
| 95 | - | registry: RegistryState::new(&globals), | |
| 96 | - | seat: SeatState::new(&globals, &qh), | |
| 97 | - | output: OutputState::new(&globals, &qh), | |
| 98 | - | _compositor: compositor, | |
| 99 | - | _xdg_shell: xdg_shell, | |
| 100 | - | xdg_window, | |
| 101 | - | pending: VecDeque::new(), | |
| 102 | - | width: spec.initial_size.0, | |
| 103 | - | height: spec.initial_size.1, | |
| 104 | - | exit: false, | |
| 105 | - | }; | |
| 106 | - | ||
| 107 | - | Ok(Self { conn, queue, state }) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | /// Raw display + window handles for a graphics API (wgpu, GL). | |
| 111 | - | /// | |
| 112 | - | /// Returned handles reference wayland pointers owned by this window; do | |
| 113 | - | /// not use them after the `Window` is dropped. | |
| 114 | - | pub fn raw_handles(&self) -> (RawDisplayHandle, RawWindowHandle) { | |
| 115 | - | let display_ptr = | |
| 116 | - | NonNull::new(self.conn.backend().display_ptr().cast()).expect("wayland display ptr"); | |
| 117 | - | let surface_ptr = NonNull::new(self.state.xdg_window.wl_surface().id().as_ptr().cast()) | |
| 118 | - | .expect("wl_surface ptr"); | |
| 119 | - | let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new(display_ptr)); | |
| 120 | - | let window = RawWindowHandle::Wayland(WaylandWindowHandle::new(surface_ptr)); | |
| 121 | - | (display, window) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | /// Blocking-dispatch one round of wayland events and drain accumulated | |
| 125 | - | /// pending changes. | |
| 126 | - | pub fn dispatch(&mut self) -> anyhow::Result<Vec<Pending>> { | |
| 127 | - | self.queue.blocking_dispatch(&mut self.state)?; | |
| 128 | - | Ok(self.state.pending.drain(..).collect()) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | pub fn should_exit(&self) -> bool { | |
| 132 | - | self.state.exit | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | pub fn size(&self) -> (u32, u32) { | |
| 136 | - | (self.state.width, self.state.height) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | impl CompositorHandler for State { | |
| 141 | - | fn scale_factor_changed( | |
| 142 | - | &mut self, | |
| 143 | - | _: &Connection, | |
| 144 | - | _: &QueueHandle<Self>, | |
| 145 | - | _: &wl_surface::WlSurface, | |
| 146 | - | _: i32, | |
| 147 | - | ) { | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | fn transform_changed( | |
| 151 | - | &mut self, | |
| 152 | - | _: &Connection, | |
| 153 | - | _: &QueueHandle<Self>, | |
| 154 | - | _: &wl_surface::WlSurface, | |
| 155 | - | _: wl_output::Transform, | |
| 156 | - | ) { | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {} | |
| 160 | - | ||
| 161 | - | fn surface_enter( | |
| 162 | - | &mut self, | |
| 163 | - | _: &Connection, | |
| 164 | - | _: &QueueHandle<Self>, | |
| 165 | - | _: &wl_surface::WlSurface, | |
| 166 | - | _: &wl_output::WlOutput, | |
| 167 | - | ) { | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | fn surface_leave( | |
| 171 | - | &mut self, | |
| 172 | - | _: &Connection, | |
| 173 | - | _: &QueueHandle<Self>, | |
| 174 | - | _: &wl_surface::WlSurface, | |
| 175 | - | _: &wl_output::WlOutput, | |
| 176 | - | ) { | |
| 177 | - | } | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | impl OutputHandler for State { | |
| 181 | - | fn output_state(&mut self) -> &mut OutputState { | |
| 182 | - | &mut self.output | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {} | |
| 186 | - | fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {} | |
| 187 | - | fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {} | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | impl SeatHandler for State { | |
| 191 | - | fn seat_state(&mut self) -> &mut SeatState { | |
| 192 | - | &mut self.seat | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {} | |
| 196 | - | fn new_capability( | |
| 197 | - | &mut self, | |
| 198 | - | _: &Connection, | |
| 199 | - | _: &QueueHandle<Self>, | |
| 200 | - | _: wl_seat::WlSeat, | |
| 201 | - | _: Capability, | |
| 202 | - | ) { | |
| 203 | - | } | |
| 204 | - | fn remove_capability( | |
| 205 | - | &mut self, | |
| 206 | - | _: &Connection, | |
| 207 | - | _: &QueueHandle<Self>, | |
| 208 | - | _: wl_seat::WlSeat, | |
| 209 | - | _: Capability, | |
| 210 | - | ) { | |
| 211 | - | } | |
| 212 | - | fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {} | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | impl WindowHandler for State { | |
| 216 | - | fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &XdgWindow) { | |
| 217 | - | self.exit = true; | |
| 218 | - | self.pending.push_back(Pending::CloseRequested); | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | fn configure( | |
| 222 | - | &mut self, | |
| 223 | - | _: &Connection, | |
| 224 | - | _: &QueueHandle<Self>, | |
| 225 | - | _: &XdgWindow, | |
| 226 | - | configure: WindowConfigure, | |
| 227 | - | _serial: u32, | |
| 228 | - | ) { | |
| 229 | - | let (new_w, new_h) = configure.new_size; | |
| 230 | - | let width = new_w.map_or(self.width, std::num::NonZeroU32::get); | |
| 231 | - | let height = new_h.map_or(self.height, std::num::NonZeroU32::get); | |
| 232 | - | self.width = width; | |
| 233 | - | self.height = height; | |
| 234 | - | self.pending.push_back(Pending::Resized { width, height }); | |
| 235 | - | } | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | impl ProvidesRegistryState for State { | |
| 239 | - | fn registry(&mut self) -> &mut RegistryState { | |
| 240 | - | &mut self.registry | |
| 241 | - | } | |
| 242 | - | registry_handlers![OutputState]; | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | delegate_registry!(State); | |
| 246 | - | delegate_dispatch2!(State); |
| @@ -1,22 +1,36 @@ | |||
| 1 | 1 | //! shop — Wayland terminal emulator. | |
| 2 | 2 | //! | |
| 3 | - | //! Milestone: glyph on screen. Loads DejaVu Sans Mono, shapes a hardcoded | |
| 4 | - | //! string with swash (ligatures on), rasterizes into a guillotiere-managed | |
| 5 | - | //! wgpu atlas, draws it with an instanced text pipeline. | |
| 6 | - | //! | |
| 7 | - | //! Run on a Wayland compositor: | |
| 8 | - | //! ```text | |
| 9 | - | //! RUST_LOG=info cargo run -p shop | |
| 10 | - | //! ``` | |
| 3 | + | //! Milestone: PTY loop. calloop-driven event loop with two sources — the | |
| 4 | + | //! wayland event queue and the PTY master fd. Bytes read from the shell are | |
| 5 | + | //! appended to a rolling buffer and rendered as raw text (no VT parser yet, | |
| 6 | + | //! so escape sequences will show as garbage — vim/htop won't look right, but | |
| 7 | + | //! `ls`, `echo`, `printf` will). | |
| 11 | 8 | ||
| 9 | + | use std::os::fd::{AsFd, AsRawFd}; | |
| 10 | + | use std::sync::Arc; | |
| 11 | + | ||
| 12 | + | use calloop::EventLoop; | |
| 13 | + | use calloop::generic::{FdWrapper, Generic}; | |
| 14 | + | use calloop_wayland_source::WaylandSource; | |
| 15 | + | use shop_pty::{Pty, PtySize}; | |
| 12 | 16 | use shop_render::TextRenderer; | |
| 13 | - | use shop_wayland::{Pending, Window, WindowSpec}; | |
| 17 | + | use shop_wayland::{ | |
| 18 | + | Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState, | |
| 19 | + | Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState, | |
| 20 | + | WaylandChrome, WaylandSurface, WindowConfigure, WindowDecorations, WindowHandler, WindowSpec, | |
| 21 | + | XdgShell, XdgWindow, registry_queue_init, | |
| 22 | + | }; | |
| 23 | + | use smithay_client_toolkit::{delegate_dispatch2, delegate_registry, registry_handlers}; | |
| 24 | + | use std::collections::VecDeque; | |
| 14 | 25 | use tracing::{info, warn}; | |
| 26 | + | use wayland_client::protocol::{wl_output, wl_seat, wl_surface}; | |
| 15 | 27 | ||
| 16 | 28 | const INITIAL: (u32, u32) = (960, 540); | |
| 17 | 29 | const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"; | |
| 18 | - | const FONT_PX: f32 = 42.0; | |
| 19 | - | const HEADLINE: &str = "shop — first glyphs"; | |
| 30 | + | const FONT_PX: f32 = 18.0; | |
| 31 | + | const CELL_ADVANCE: f32 = 11.0; | |
| 32 | + | const CELL_HEIGHT: f32 = 22.0; | |
| 33 | + | const SCROLLBACK_BYTES: usize = 64 * 1024; | |
| 20 | 34 | const CLEAR: wgpu::Color = wgpu::Color { | |
| 21 | 35 | r: 0.043, | |
| 22 | 36 | g: 0.055, | |
| @@ -36,20 +50,60 @@ | |||
| 36 | 50 | let font_data = std::fs::read(FONT_PATH) | |
| 37 | 51 | .map_err(|e| anyhow::anyhow!("read font {FONT_PATH}: {e}"))?; | |
| 38 | 52 | ||
| 39 | - | let mut window = Window::new(WindowSpec { | |
| 53 | + | let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into()); | |
| 54 | + | let cols_initial: u16 = (INITIAL.0 as f32 / CELL_ADVANCE) as u16; | |
| 55 | + | let rows_initial: u16 = (INITIAL.1 as f32 / CELL_HEIGHT) as u16; | |
| 56 | + | let pty = Pty::spawn( | |
| 57 | + | &shell, | |
| 58 | + | &[], | |
| 59 | + | PtySize { | |
| 60 | + | cols: cols_initial, | |
| 61 | + | rows: rows_initial, | |
| 62 | + | cell_width: CELL_ADVANCE.round() as u16, | |
| 63 | + | cell_height: CELL_HEIGHT.round() as u16, | |
| 64 | + | }, | |
| 65 | + | "xterm-256color", | |
| 66 | + | )?; | |
| 67 | + | info!(shell = %shell, "spawned shell"); | |
| 68 | + | ||
| 69 | + | let conn = Connection::connect_to_env()?; | |
| 70 | + | let spec = WindowSpec { | |
| 40 | 71 | title: "shop", | |
| 41 | 72 | app_id: "dev.makecreative.shop", | |
| 42 | 73 | min_size: (320, 240), | |
| 43 | 74 | initial_size: INITIAL, | |
| 44 | - | })?; | |
| 75 | + | }; | |
| 76 | + | let (globals, event_queue) = registry_queue_init::<App>(&conn)?; | |
| 77 | + | let qh = event_queue.handle(); | |
| 78 | + | let compositor = CompositorState::bind(&globals, &qh)?; | |
| 79 | + | let xdg_shell = XdgShell::bind(&globals, &qh)?; | |
| 80 | + | let wl_surface = compositor.create_surface(&qh); | |
| 81 | + | let xdg_window = | |
| 82 | + | xdg_shell.create_window(wl_surface, WindowDecorations::ServerDefault, &qh); | |
| 83 | + | xdg_window.set_title(spec.title.to_string()); | |
| 84 | + | xdg_window.set_app_id(spec.app_id.to_string()); | |
| 85 | + | xdg_window.set_min_size(Some(spec.min_size)); | |
| 86 | + | xdg_window.commit(); | |
| 87 | + | let chrome = WaylandChrome { | |
| 88 | + | registry: RegistryState::new(&globals), | |
| 89 | + | seat: SeatState::new(&globals, &qh), | |
| 90 | + | output: OutputState::new(&globals, &qh), | |
| 91 | + | compositor, | |
| 92 | + | xdg_shell, | |
| 93 | + | xdg_window, | |
| 94 | + | pending: VecDeque::new(), | |
| 95 | + | width: spec.initial_size.0, | |
| 96 | + | height: spec.initial_size.1, | |
| 97 | + | exit: false, | |
| 98 | + | }; | |
| 45 | 99 | ||
| 46 | - | let (raw_display, raw_window) = window.raw_handles(); | |
| 100 | + | let (raw_display, raw_window) = shop_wayland::raw_handles(&conn, &chrome); | |
| 47 | 101 | let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle(); | |
| 48 | 102 | instance_desc.backends = wgpu::Backends::VULKAN; | |
| 49 | 103 | let instance = wgpu::Instance::new(instance_desc); | |
| 50 | 104 | ||
| 51 | 105 | // SAFETY: raw_display / raw_window reference wayland pointers owned by | |
| 52 | - | // `window`; both stay alive until `window` is dropped at the end of main. | |
| 106 | + | // `conn` / `chrome`, both of which live for the rest of main. | |
| 53 | 107 | #[allow(unsafe_code)] | |
| 54 | 108 | let surface = unsafe { | |
| 55 | 109 | instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { | |
| @@ -70,7 +124,7 @@ | |||
| 70 | 124 | ||
| 71 | 125 | let caps = surface.get_capabilities(&adapter); | |
| 72 | 126 | let format = caps.formats[0]; | |
| 73 | - | let mut config = wgpu::SurfaceConfiguration { | |
| 127 | + | let surface_config = wgpu::SurfaceConfiguration { | |
| 74 | 128 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT, | |
| 75 | 129 | format, | |
| 76 | 130 | color_space: wgpu::SurfaceColorSpace::Auto, | |
| @@ -81,48 +135,134 @@ | |||
| 81 | 135 | desired_maximum_frame_latency: 2, | |
| 82 | 136 | present_mode: wgpu::PresentMode::Mailbox, | |
| 83 | 137 | }; | |
| 138 | + | let text = TextRenderer::new(&device, format, font_data, FONT_PX)?; | |
| 84 | 139 | ||
| 85 | - | let mut text = TextRenderer::new(&device, format, font_data, FONT_PX)?; | |
| 140 | + | let mut app = App { | |
| 141 | + | chrome, | |
| 142 | + | surface, | |
| 143 | + | surface_config, | |
| 144 | + | device: Arc::new(device), | |
| 145 | + | queue: Arc::new(queue), | |
| 146 | + | text, | |
| 147 | + | pty, | |
| 148 | + | scrollback: Vec::with_capacity(SCROLLBACK_BYTES), | |
| 149 | + | pending_redraw: true, | |
| 150 | + | }; | |
| 151 | + | app.surface.configure(&app.device, &app.surface_config); | |
| 152 | + | app.text | |
| 153 | + | .resize(&app.queue, app.surface_config.width, app.surface_config.height); | |
| 86 | 154 | ||
| 87 | - | info!("entering event loop"); | |
| 88 | - | while !window.should_exit() { | |
| 89 | - | for change in window.dispatch()? { | |
| 90 | - | match change { | |
| 91 | - | Pending::Resized { width, height } => { | |
| 92 | - | config.width = width; | |
| 93 | - | config.height = height; | |
| 94 | - | surface.configure(&device, &config); | |
| 95 | - | text.resize(&queue, width, height); | |
| 96 | - | if let Err(e) = draw(&surface, &device, &queue, &mut text) { | |
| 97 | - | warn!("draw failed: {e:?}"); | |
| 155 | + | // calloop event loop with wayland + PTY sources. | |
| 156 | + | let mut event_loop: EventLoop<App> = EventLoop::try_new()?; | |
| 157 | + | let loop_handle = event_loop.handle(); | |
| 158 | + | ||
| 159 | + | WaylandSource::new(conn, event_queue) | |
| 160 | + | .insert(loop_handle.clone()) | |
| 161 | + | .map_err(|e| anyhow::anyhow!("insert wayland source: {e}"))?; | |
| 162 | + | ||
| 163 | + | // Register the PTY fd. Level-triggered so we drain in the callback. | |
| 164 | + | // SAFETY: `raw_fd` refers to `app.pty`'s master fd, which outlives the | |
| 165 | + | // event loop (both live until end of main). | |
| 166 | + | #[allow(unsafe_code)] | |
| 167 | + | let pty_source = { | |
| 168 | + | let raw_fd = app.pty.as_fd().as_raw_fd(); | |
| 169 | + | let wrapper = unsafe { FdWrapper::new(raw_fd) }; | |
| 170 | + | Generic::new(wrapper, calloop::Interest::READ, calloop::Mode::Level) | |
| 171 | + | }; | |
| 172 | + | loop_handle | |
| 173 | + | .insert_source(pty_source, |_readiness, _fd, app| { | |
| 174 | + | let mut buf = [0u8; 4096]; | |
| 175 | + | loop { | |
| 176 | + | match app.pty.read(&mut buf) { | |
| 177 | + | Ok(0) => { | |
| 178 | + | app.chrome.exit = true; | |
| 179 | + | break; | |
| 180 | + | } | |
| 181 | + | Ok(n) => { | |
| 182 | + | push_scrollback(&mut app.scrollback, &buf[..n]); | |
| 183 | + | app.pending_redraw = true; | |
| 184 | + | } | |
| 185 | + | Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, | |
| 186 | + | Err(e) => { | |
| 187 | + | warn!("pty read: {e}"); | |
| 188 | + | break; | |
| 98 | 189 | } | |
| 99 | 190 | } | |
| 100 | - | Pending::CloseRequested => info!("close requested"), | |
| 101 | 191 | } | |
| 192 | + | Ok(calloop::PostAction::Continue) | |
| 193 | + | }) | |
| 194 | + | .map_err(|e| anyhow::anyhow!("insert pty source: {e}"))?; | |
| 195 | + | ||
| 196 | + | info!("entering event loop"); | |
| 197 | + | while !app.chrome.exit { | |
| 198 | + | event_loop.dispatch(None, &mut app)?; | |
| 199 | + | ||
| 200 | + | for change in app.chrome.pending.drain(..).collect::<Vec<_>>() { | |
| 201 | + | match change { | |
| 202 | + | Pending::Resized { width, height } => { | |
| 203 | + | app.surface_config.width = width; | |
| 204 | + | app.surface_config.height = height; | |
| 205 | + | app.surface.configure(&app.device, &app.surface_config); | |
| 206 | + | app.text.resize(&app.queue, width, height); | |
| 207 | + | let cols = (width as f32 / CELL_ADVANCE).max(1.0) as u16; | |
| 208 | + | let rows = (height as f32 / CELL_HEIGHT).max(1.0) as u16; | |
| 209 | + | let _ = app.pty.resize(PtySize { | |
| 210 | + | cols, | |
| 211 | + | rows, | |
| 212 | + | cell_width: CELL_ADVANCE.round() as u16, | |
| 213 | + | cell_height: CELL_HEIGHT.round() as u16, | |
| 214 | + | }); | |
| 215 | + | app.pending_redraw = true; | |
| 216 | + | } | |
| 217 | + | Pending::CloseRequested => app.chrome.exit = true, | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | if app.pending_redraw && !app.chrome.exit { | |
| 222 | + | if let Err(e) = draw(&mut app) { | |
| 223 | + | warn!("draw: {e:?}"); | |
| 224 | + | } | |
| 225 | + | app.pending_redraw = false; | |
| 102 | 226 | } | |
| 103 | 227 | } | |
| 104 | 228 | ||
| 105 | - | drop(surface); | |
| 106 | - | drop(window); | |
| 107 | 229 | Ok(()) | |
| 108 | 230 | } | |
| 109 | 231 | ||
| 110 | - | fn draw( | |
| 111 | - | surface: &wgpu::Surface<'_>, | |
| 112 | - | device: &wgpu::Device, | |
| 113 | - | queue: &wgpu::Queue, | |
| 114 | - | text: &mut TextRenderer, | |
| 115 | - | ) -> anyhow::Result<()> { | |
| 116 | - | let frame = match surface.get_current_texture() { | |
| 232 | + | /// Append bytes and trim from the left when past the scrollback cap. | |
| 233 | + | fn push_scrollback(buf: &mut Vec<u8>, bytes: &[u8]) { | |
| 234 | + | buf.extend_from_slice(bytes); | |
| 235 | + | if buf.len() > SCROLLBACK_BYTES { | |
| 236 | + | let drop = buf.len() - SCROLLBACK_BYTES; | |
| 237 | + | buf.drain(..drop); | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | struct App { | |
| 242 | + | chrome: WaylandChrome, | |
| 243 | + | surface: wgpu::Surface<'static>, | |
| 244 | + | surface_config: wgpu::SurfaceConfiguration, | |
| 245 | + | device: Arc<wgpu::Device>, | |
| 246 | + | queue: Arc<wgpu::Queue>, | |
| 247 | + | text: TextRenderer, | |
| 248 | + | pty: Pty, | |
| 249 | + | scrollback: Vec<u8>, | |
| 250 | + | pending_redraw: bool, | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | fn draw(app: &mut App) -> anyhow::Result<()> { | |
| 254 | + | let frame = match app.surface.get_current_texture() { | |
| 117 | 255 | wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, | |
| 118 | 256 | other => anyhow::bail!("surface acquire: {other:?}"), | |
| 119 | 257 | }; | |
| 120 | 258 | let view = frame | |
| 121 | 259 | .texture | |
| 122 | 260 | .create_view(&wgpu::TextureViewDescriptor::default()); | |
| 123 | - | let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { | |
| 124 | - | label: Some("shop.frame"), | |
| 125 | - | }); | |
| 261 | + | let mut encoder = | |
| 262 | + | app.device | |
| 263 | + | .create_command_encoder(&wgpu::CommandEncoderDescriptor { | |
| 264 | + | label: Some("shop.frame"), | |
| 265 | + | }); | |
| 126 | 266 | { | |
| 127 | 267 | let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 128 | 268 | label: Some("shop.clear"), | |
| @@ -138,8 +278,134 @@ | |||
| 138 | 278 | ..Default::default() | |
| 139 | 279 | }); | |
| 140 | 280 | } | |
| 141 | - | text.draw(device, queue, &view, &mut encoder, HEADLINE, 48.0, 40.0, TEXT_COLOR); | |
| 142 | - | queue.submit(std::iter::once(encoder.finish())); | |
| 143 | - | queue.present(frame); | |
| 281 | + | // Naive layout: split scrollback into lines, keep the last N that fit, | |
| 282 | + | // strip control bytes, draw each. The VT parser milestone replaces this. | |
| 283 | + | let text = String::from_utf8_lossy(&app.scrollback); | |
| 284 | + | let visible_rows = ((app.surface_config.height as f32 / CELL_HEIGHT) as usize).max(1); | |
| 285 | + | let lines: Vec<&str> = text.lines().collect(); | |
| 286 | + | let start = lines.len().saturating_sub(visible_rows); | |
| 287 | + | let mut y = 4.0; | |
| 288 | + | for line in &lines[start..] { | |
| 289 | + | let sanitized: String = line.chars().filter(|c| !c.is_control()).collect(); | |
| 290 | + | if !sanitized.is_empty() { | |
| 291 | + | app.text.draw( | |
| 292 | + | &app.device, | |
| 293 | + | &app.queue, | |
| 294 | + | &view, | |
| 295 | + | &mut encoder, | |
| 296 | + | &sanitized, | |
| 297 | + | 4.0, | |
| 298 | + | y, | |
| 299 | + | TEXT_COLOR, | |
| 300 | + | ); | |
| 301 | + | } | |
| 302 | + | y += CELL_HEIGHT; | |
| 303 | + | } | |
| 304 | + | app.queue.submit(std::iter::once(encoder.finish())); | |
| 305 | + | app.queue.present(frame); | |
| 144 | 306 | Ok(()) | |
| 145 | 307 | } | |
| 308 | + | ||
| 309 | + | // -- SCTK Handler impls on the composed App state -------------------------- | |
| 310 | + | ||
| 311 | + | impl CompositorHandler for App { | |
| 312 | + | fn scale_factor_changed( | |
| 313 | + | &mut self, | |
| 314 | + | _: &Connection, | |
| 315 | + | _: &QueueHandle<Self>, | |
| 316 | + | _: &wl_surface::WlSurface, | |
| 317 | + | _: i32, | |
| 318 | + | ) { | |
| 319 | + | } | |
| 320 | + | fn transform_changed( | |
| 321 | + | &mut self, | |
| 322 | + | _: &Connection, | |
| 323 | + | _: &QueueHandle<Self>, | |
| 324 | + | _: &wl_surface::WlSurface, | |
| 325 | + | _: wl_output::Transform, | |
| 326 | + | ) { | |
| 327 | + | } | |
| 328 | + | fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {} | |
| 329 | + | fn surface_enter( | |
| 330 | + | &mut self, | |
| 331 | + | _: &Connection, | |
| 332 | + | _: &QueueHandle<Self>, | |
| 333 | + | _: &wl_surface::WlSurface, | |
| 334 | + | _: &wl_output::WlOutput, | |
| 335 | + | ) { | |
| 336 | + | } | |
| 337 | + | fn surface_leave( | |
| 338 | + | &mut self, | |
| 339 | + | _: &Connection, | |
| 340 | + | _: &QueueHandle<Self>, | |
| 341 | + | _: &wl_surface::WlSurface, | |
| 342 | + | _: &wl_output::WlOutput, | |
| 343 | + | ) { | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | impl OutputHandler for App { | |
| 348 | + | fn output_state(&mut self) -> &mut OutputState { | |
| 349 | + | &mut self.chrome.output | |
| 350 | + | } | |
| 351 | + | fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {} | |
| 352 | + | fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {} | |
| 353 | + | fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {} | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | impl SeatHandler for App { | |
| 357 | + | fn seat_state(&mut self) -> &mut SeatState { | |
| 358 | + | &mut self.chrome.seat | |
| 359 | + | } | |
| 360 | + | fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {} | |
| 361 | + | fn new_capability( | |
| 362 | + | &mut self, | |
| 363 | + | _: &Connection, | |
| 364 | + | _: &QueueHandle<Self>, | |
| 365 | + | _: wl_seat::WlSeat, | |
| 366 | + | _: Capability, | |
| 367 | + | ) { | |
| 368 | + | } | |
| 369 | + | fn remove_capability( | |
| 370 | + | &mut self, | |
| 371 | + | _: &Connection, | |
| 372 | + | _: &QueueHandle<Self>, | |
| 373 | + | _: wl_seat::WlSeat, | |
| 374 | + | _: Capability, | |
| 375 | + | ) { | |
| 376 | + | } | |
| 377 | + | fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {} | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | impl WindowHandler for App { | |
| 381 | + | fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &XdgWindow) { | |
| 382 | + | self.chrome.exit = true; | |
| 383 | + | self.chrome.pending.push_back(Pending::CloseRequested); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | fn configure( | |
| 387 | + | &mut self, | |
| 388 | + | _: &Connection, | |
| 389 | + | _: &QueueHandle<Self>, | |
| 390 | + | _: &XdgWindow, | |
| 391 | + | configure: WindowConfigure, | |
| 392 | + | _serial: u32, | |
| 393 | + | ) { | |
| 394 | + | let (new_w, new_h) = configure.new_size; | |
| 395 | + | let width = new_w.map_or(self.chrome.width, std::num::NonZeroU32::get); | |
| 396 | + | let height = new_h.map_or(self.chrome.height, std::num::NonZeroU32::get); | |
| 397 | + | self.chrome.width = width; | |
| 398 | + | self.chrome.height = height; | |
| 399 | + | self.chrome.pending.push_back(Pending::Resized { width, height }); | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | impl ProvidesRegistryState for App { | |
| 404 | + | fn registry(&mut self) -> &mut RegistryState { | |
| 405 | + | &mut self.chrome.registry | |
| 406 | + | } | |
| 407 | + | registry_handlers![OutputState]; | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | delegate_registry!(App); | |
| 411 | + | delegate_dispatch2!(App); |
| @@ -1,0 +1,18 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "shop-pty" | |
| 3 | + | version = "0.0.0" | |
| 4 | + | description = "PTY spawning + I/O for shop (Linux)" | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | authors.workspace = true | |
| 8 | + | repository.workspace = true | |
| 9 | + | license.workspace = true | |
| 10 | + | publish = false | |
| 11 | + | ||
| 12 | + | [lints] | |
| 13 | + | workspace = true | |
| 14 | + | ||
| 15 | + | [dependencies] | |
| 16 | + | nix.workspace = true | |
| 17 | + | anyhow.workspace = true | |
| 18 | + | tracing.workspace = true |
| @@ -1,0 +1,171 @@ | |||
| 1 | + | //! PTY spawning + I/O for shop. | |
| 2 | + | //! | |
| 3 | + | //! Opens a Linux pseudo-terminal, forks, execs a shell in the child with the | |
| 4 | + | //! slave as controlling terminal, and hands the parent a nonblocking master | |
| 5 | + | //! fd for read/write. The master fd implements [`AsFd`] so callers can plug | |
| 6 | + | //! it into any Unix event loop (calloop, mio, epoll). | |
| 7 | + | //! | |
| 8 | + | //! Reference: rio's teletypewriter/src/unix/mod.rs (MIT). Not a line-for-line | |
| 9 | + | //! port — we use nix's safe wrappers rather than raw libc, and skip the | |
| 10 | + | //! corcovado (mio 0.6) coupling entirely. | |
| 11 | + | ||
| 12 | + | use std::ffi::CString; | |
| 13 | + | use std::io; | |
| 14 | + | use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; | |
| 15 | + | ||
| 16 | + | use nix::fcntl::{FcntlArg, OFlag, fcntl}; | |
| 17 | + | use nix::libc; | |
| 18 | + | use nix::pty::{Winsize, openpty}; | |
| 19 | + | use nix::sys::signal::{Signal, kill}; | |
| 20 | + | use nix::unistd::{ForkResult, Pid, execvp, fork, setsid}; | |
| 21 | + | ||
| 22 | + | /// Dimensions of the terminal surface reported to the kernel via TIOCSWINSZ. | |
| 23 | + | #[derive(Debug, Clone, Copy)] | |
| 24 | + | pub struct PtySize { | |
| 25 | + | pub cols: u16, | |
| 26 | + | pub rows: u16, | |
| 27 | + | pub cell_width: u16, | |
| 28 | + | pub cell_height: u16, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl PtySize { | |
| 32 | + | fn to_winsize(self) -> Winsize { | |
| 33 | + | Winsize { | |
| 34 | + | ws_row: self.rows, | |
| 35 | + | ws_col: self.cols, | |
| 36 | + | ws_xpixel: self.cols.saturating_mul(self.cell_width), | |
| 37 | + | ws_ypixel: self.rows.saturating_mul(self.cell_height), | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// A running shell attached to a PTY. Dropping this sends SIGHUP to the | |
| 43 | + | /// child. | |
| 44 | + | pub struct Pty { | |
| 45 | + | master: OwnedFd, | |
| 46 | + | child: Pid, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | impl Pty { | |
| 50 | + | /// Fork a child running `command`, hand the child an already-set-up | |
| 51 | + | /// controlling terminal, and return the parent-side master fd. | |
| 52 | + | /// | |
| 53 | + | /// `term` controls the value of the `TERM` env var for the child; the | |
| 54 | + | /// shell/programs use it to select escape sequences. Start with | |
| 55 | + | /// `"xterm-256color"` until we have a terminfo entry for shop. | |
| 56 | + | pub fn spawn( | |
| 57 | + | command: &str, | |
| 58 | + | args: &[&str], | |
| 59 | + | size: PtySize, | |
| 60 | + | term: &str, | |
| 61 | + | ) -> anyhow::Result<Self> { | |
| 62 | + | let ws = size.to_winsize(); | |
| 63 | + | let pair = openpty(Some(&ws), None)?; | |
| 64 | + | ||
| 65 | + | // SAFETY: post-fork, the child only calls async-signal-safe libc | |
| 66 | + | // calls (setsid, dup2, ioctl, execvp) plus setenv (not strictly | |
| 67 | + | // signal-safe, tolerated by GLIBC for terminal setup). | |
| 68 | + | #[allow(unsafe_code)] | |
| 69 | + | match unsafe { fork() }? { | |
| 70 | + | ForkResult::Parent { child } => { | |
| 71 | + | drop(pair.slave); | |
| 72 | + | let flags = fcntl(pair.master.as_fd(), FcntlArg::F_GETFL)?; | |
| 73 | + | let nb = OFlag::from_bits_truncate(flags) | OFlag::O_NONBLOCK; | |
| 74 | + | fcntl(pair.master.as_fd(), FcntlArg::F_SETFL(nb))?; | |
| 75 | + | Ok(Self { | |
| 76 | + | master: pair.master, | |
| 77 | + | child, | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ForkResult::Child => { | |
| 81 | + | run_child(pair.slave, command, args, term); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Send SIGWINCH to the child after updating the master's window size. | |
| 87 | + | pub fn resize(&self, size: PtySize) -> anyhow::Result<()> { | |
| 88 | + | let ws = size.to_winsize(); | |
| 89 | + | // SAFETY: master.as_raw_fd() is a valid fd for our process; ioctl | |
| 90 | + | // TIOCSWINSZ reads Winsize by pointer. | |
| 91 | + | #[allow(unsafe_code)] | |
| 92 | + | let rc = unsafe { | |
| 93 | + | libc::ioctl( | |
| 94 | + | self.master.as_raw_fd(), | |
| 95 | + | libc::TIOCSWINSZ, | |
| 96 | + | std::ptr::from_ref(&ws), | |
| 97 | + | ) | |
| 98 | + | }; | |
| 99 | + | if rc != 0 { | |
| 100 | + | anyhow::bail!(io::Error::last_os_error()); | |
| 101 | + | } | |
| 102 | + | Ok(()) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Nonblocking read. Returns `Ok(0)` on EOF, `WouldBlock` when there's | |
| 106 | + | /// nothing to read yet. | |
| 107 | + | pub fn read(&self, buf: &mut [u8]) -> io::Result<usize> { | |
| 108 | + | nix::unistd::read(&self.master, buf).map_err(io::Error::from) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Write bytes to the shell (e.g. keystrokes). | |
| 112 | + | pub fn write(&self, buf: &[u8]) -> io::Result<usize> { | |
| 113 | + | nix::unistd::write(&self.master, buf).map_err(io::Error::from) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | pub fn child(&self) -> Pid { | |
| 117 | + | self.child | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | impl AsFd for Pty { | |
| 122 | + | fn as_fd(&self) -> BorrowedFd<'_> { | |
| 123 | + | self.master.as_fd() | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | impl Drop for Pty { | |
| 128 | + | fn drop(&mut self) { | |
| 129 | + | let _ = kill(self.child, Signal::SIGHUP); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | fn run_child(slave: OwnedFd, command: &str, args: &[&str], term: &str) -> ! { | |
| 134 | + | // New session detaches us from the parent's controlling terminal. | |
| 135 | + | let _ = setsid(); | |
| 136 | + | ||
| 137 | + | // Make the slave our controlling terminal. | |
| 138 | + | #[allow(unsafe_code)] | |
| 139 | + | unsafe { | |
| 140 | + | libc::ioctl(slave.as_raw_fd(), libc::TIOCSCTTY as _, 0); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Duplicate slave onto stdin/stdout/stderr. libc::dup2 rather than nix's | |
| 144 | + | // typed version, which needs an owned destination (unusable for stdio). | |
| 145 | + | // SAFETY: slave.as_raw_fd() is valid; STDIN/OUT/ERR_FILENO are constants. | |
| 146 | + | let slave_raw = slave.as_raw_fd(); | |
| 147 | + | #[allow(unsafe_code)] | |
| 148 | + | unsafe { | |
| 149 | + | libc::dup2(slave_raw, libc::STDIN_FILENO); | |
| 150 | + | libc::dup2(slave_raw, libc::STDOUT_FILENO); | |
| 151 | + | libc::dup2(slave_raw, libc::STDERR_FILENO); | |
| 152 | + | } | |
| 153 | + | drop(slave); | |
| 154 | + | ||
| 155 | + | // TERM is the classic escape-set selector; SHELL is informational. | |
| 156 | + | // SAFETY: single-threaded child process, no other threads to race. | |
| 157 | + | #[allow(unsafe_code)] | |
| 158 | + | unsafe { | |
| 159 | + | std::env::set_var("TERM", term); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | let cmd_c = CString::new(command).expect("command has NUL"); | |
| 163 | + | let mut arg_cstrs: Vec<CString> = Vec::with_capacity(args.len() + 1); | |
| 164 | + | arg_cstrs.push(cmd_c.clone()); | |
| 165 | + | for a in args { | |
| 166 | + | arg_cstrs.push(CString::new(*a).expect("arg has NUL")); | |
| 167 | + | } | |
| 168 | + | let _ = execvp(&cmd_c, &arg_cstrs); | |
| 169 | + | // execvp only returns on failure. | |
| 170 | + | std::process::exit(127); | |
| 171 | + | } |