diff --git a/README.md b/README.md index 230f94d..663f279 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ breadlock/ - **Protocol**: [`greetd_ipc`](https://crates.io/crates/greetd_ipc) (greetd's own crate) over the Unix socket at `$GREETD_SOCK`: `CreateSession` → answer each `AuthMessage` via `PostAuthMessageResponse` → `StartSession` hands the resolved session command to `greetd`, which execs it and owns the VT switch away. - **UI**: GTK4 + [relm4](https://relm4.org/), matching breadbar's stack — **without** `gtk4-layer-shell`. `greetd` hosts the greeter under a single-client kiosk compositor (`cage -s`), which already fullscreens its one client, so layer-shell's multi-surface/anchor semantics don't apply. Confirmed against ReGreet's real dependency list, which has no layer-shell dependency either. -- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and auto-selects the configured default (or the only one found). BOS ships one session today, so there's no picker UI in v1 — a natural v2 addition if that changes. +- **Sessions**: scans `/usr/share/wayland-sessions` and `/usr/share/xsessions` for `.desktop` entries and shows a keyboard-accessible picker. The configured default (compiled-in: `bos`) is pre-selected when that stem exists; otherwise the first discovered session. `StartSession` is the chosen entry's `Exec=` argv. ## Config diff --git a/breadgreet.example.toml b/breadgreet.example.toml index c5f2093..ae0af95 100644 --- a/breadgreet.example.toml +++ b/breadgreet.example.toml @@ -20,7 +20,8 @@ family = "Varela Round" # Directories scanned for .desktop session entries, in order. wayland_dirs = ["/usr/share/wayland-sessions"] xsessions_dirs = ["/usr/share/xsessions"] -# .desktop file stem (without extension) to auto-select. Falls back to the -# first entry found if this isn't present. v1 has no session picker UI — -# BOS only ships one session (Hyprland) today. -default = "hyprland" +# .desktop file stem (without extension) pre-selected in the picker. +# Falls back to the first entry found if this isn't present. BOS ships +# bos.desktop (Exec=bos-session); leaving this as "bos" is what the ISO +# config expects so Hyprland's own hyprland.desktop is not picked first. +default = "bos" diff --git a/breadgreet/src/config.rs b/breadgreet/src/config.rs index a560945..bd9aa32 100644 --- a/breadgreet/src/config.rs +++ b/breadgreet/src/config.rs @@ -15,7 +15,8 @@ pub struct Config { pub struct Sessions { pub wayland_dirs: Vec, pub xsessions_dirs: Vec, - /// `.desktop` file stem (without extension) to auto-select. + /// `.desktop` file stem (without extension) to pre-select in the picker. + /// Falls back to the first discovered session if this stem is missing. pub default: String, } @@ -24,7 +25,7 @@ impl Default for Sessions { Self { wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()], xsessions_dirs: vec!["/usr/share/xsessions".to_string()], - default: "hyprland".to_string(), + default: "bos".to_string(), } } } @@ -58,6 +59,6 @@ mod tests { let s = Sessions::default(); assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]); assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]); - assert_eq!(s.default, "hyprland"); + assert_eq!(s.default, "bos"); } } diff --git a/breadgreet/src/main.rs b/breadgreet/src/main.rs index 8666e1d..231c26f 100644 --- a/breadgreet/src/main.rs +++ b/breadgreet/src/main.rs @@ -37,6 +37,8 @@ enum AppInput { Outcome(Outcome), Error(String), SessionStarted, + /// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored. + SessionSelected(u32), } struct App { @@ -45,7 +47,8 @@ struct App { entry: gtk4::Entry, stage: Stage, username: String, - session: Option, + sessions: Vec, + selected: usize, clock_format: String, cmd_tx: mpsc::UnboundedSender, } @@ -78,11 +81,19 @@ impl SimpleComponent for App { root.fullscreen(); let config = config::load(); - let session = sessions::discover( + let sessions = sessions::list( + &config.sessions.wayland_dirs, + &config.sessions.xsessions_dirs, + ); + // Same default rule as `discover()`: configured stem (compiled-in + // `bos`), else the first listed session. + let selected = sessions::discover( &config.sessions.wayland_dirs, &config.sessions.xsessions_dirs, &config.sessions.default, - ); + ) + .and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem)) + .unwrap_or(0); let clock_lbl = gtk4::Label::new(None); clock_lbl.add_css_class("login-clock"); @@ -99,17 +110,33 @@ impl SimpleComponent for App { let status_lbl = gtk4::Label::new(None); status_lbl.add_css_class("login-status"); - let session_lbl = gtk4::Label::new(session.as_ref().map(|s| s.name.as_str())); - session_lbl.add_css_class("login-session"); - if session.is_none() { - session_lbl.set_label("No session found — cannot log in"); - } + let session_widget: gtk4::Widget = if sessions.is_empty() { + let session_lbl = gtk4::Label::new(Some("No session found — cannot log in")); + session_lbl.add_css_class("login-session"); + session_lbl.upcast() + } else { + let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect(); + let dropdown = gtk4::DropDown::from_strings(&names); + dropdown.add_css_class("login-session"); + dropdown.set_hexpand(true); + dropdown.set_focusable(true); + dropdown.set_tooltip_text(Some("Session")); + dropdown.update_property(&[gtk4::accessible::Property::Label("Session")]); + dropdown.set_selected(selected as u32); + { + let sender = sender.clone(); + dropdown.connect_selected_notify(move |dd| { + sender.input(AppInput::SessionSelected(dd.selected())); + }); + } + dropdown.upcast() + }; let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8); card.add_css_class("login-card"); card.append(&entry); card.append(&status_lbl); - card.append(&session_lbl); + card.append(&session_widget); let widgets = view_output!(); widgets.root_box.append(&clock_lbl); @@ -127,13 +154,15 @@ impl SimpleComponent for App { entry, stage: Stage::Username, username: String::new(), - session, + sessions, + selected, clock_format: config.appearance.clock.format.clone(), cmd_tx, }; model .clock_lbl .set_label(¤t_time(&model.clock_format)); + model.entry.grab_focus(); ComponentParts { model, widgets } } @@ -161,6 +190,12 @@ impl SimpleComponent for App { // nothing left for the greeter to do. self.status_lbl.set_label("Starting session…"); } + AppInput::SessionSelected(idx) => { + let idx = idx as usize; + if idx < self.sessions.len() { + self.selected = idx; + } + } } } } @@ -225,7 +260,7 @@ impl App { } fn start_session(&mut self) { - let Some(session) = &self.session else { + let Some(session) = self.sessions.get(self.selected) else { self.status_lbl.set_label("No session available to start"); self.status_lbl.add_css_class("error"); return; diff --git a/breadgreet/src/sessions.rs b/breadgreet/src/sessions.rs index d7d9dcc..3fe4d72 100644 --- a/breadgreet/src/sessions.rs +++ b/breadgreet/src/sessions.rs @@ -1,17 +1,45 @@ //! Session discovery: scans the standard greetd-greeter session directories -//! for `.desktop` entries. BOS effectively ships one session (Hyprland via -//! `bos-session`), so v1 has no picker UI — it just auto-selects the -//! configured default (or the only entry found) and resolves its `Exec=` -//! line to hand to `greetd`'s `StartSession`. +//! for `.desktop` entries, lists them for the picker, and resolves the +//! chosen entry's `Exec=` line for `greetd`'s `StartSession`. +//! +//! Default selection matches by `.desktop` file stem (`bos` compiled-in, +//! overridable via `[sessions].default`). If that stem is missing, the +//! first entry from `wayland_dirs` then `xsessions_dirs` is used. -use breadlock_ui::desktop_entry::{scan_dir, DesktopEntry}; +use breadlock_ui::desktop_entry::scan_dir; use std::path::Path; +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { + /// `.desktop` file stem (`bos` for `bos.desktop`) — used to match + /// `[sessions].default`. + pub stem: String, pub name: String, pub exec: Vec, } +/// Every installed session, `wayland_dirs` first then `xsessions_dirs`. +/// Each directory is sorted by stem (see [`scan_dir`]). +pub fn list(wayland_dirs: &[String], xsessions_dirs: &[String]) -> Vec { + let mut all = Vec::new(); + for dir in wayland_dirs.iter().chain(xsessions_dirs) { + for (stem, entry) in scan_dir(Path::new(dir)) { + all.push(Session { + stem, + name: entry.name, + exec: split_exec(&entry.exec), + }); + } + } + all +} + +/// Index of the configured default stem, or `0` if it is absent. Callers +/// with an empty list should not use this as a subscript. +pub fn default_index(sessions: &[Session], default: &str) -> usize { + sessions.iter().position(|s| s.stem == default).unwrap_or(0) +} + /// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns /// the entry matching `default` (by `.desktop` file stem), falling back to /// the first entry found in either directory. `None` if nothing is @@ -21,20 +49,9 @@ pub fn discover( xsessions_dirs: &[String], default: &str, ) -> Option { - let mut all: Vec<(String, DesktopEntry)> = Vec::new(); - for dir in wayland_dirs.iter().chain(xsessions_dirs) { - all.extend(scan_dir(Path::new(dir))); - } - - let chosen = all - .iter() - .find(|(stem, _)| stem == default) - .or_else(|| all.first())?; - - Some(Session { - name: chosen.1.name.clone(), - exec: split_exec(&chosen.1.exec), - }) + let all = list(wayland_dirs, xsessions_dirs); + let idx = default_index(&all, default); + all.into_iter().nth(idx) } /// Splits a `.desktop` `Exec=` line into an argv. Only handles plain @@ -69,26 +86,72 @@ mod tests { .is_none()); } + fn write_fixture(dir: &std::path::Path, stem: &str, name: &str, exec: &str) { + std::fs::write( + dir.join(format!("{stem}.desktop")), + format!("[Desktop Entry]\nName={name}\nExec={exec}\n"), + ) + .unwrap(); + } + #[test] fn discover_prefers_configured_default_over_first_entry() { - let dir = std::env::temp_dir().join("breadgreet-test-sessions-discover"); + let dir = std::env::temp_dir().join(format!( + "breadgreet-test-sessions-discover-{}", + std::process::id() + )); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("aaa.desktop"), - "[Desktop Entry]\nName=A\nExec=a-cmd\n", - ) - .unwrap(); - std::fs::write( - dir.join("hyprland.desktop"), - "[Desktop Entry]\nName=Hyprland\nExec=Hyprland\n", - ) - .unwrap(); + write_fixture(&dir, "aaa", "A", "a-cmd"); + write_fixture(&dir, "hyprland", "Hyprland", "Hyprland"); let dir_str = dir.to_str().unwrap().to_string(); let session = discover(&[dir_str], &[], "hyprland").unwrap(); + assert_eq!(session.stem, "hyprland"); assert_eq!(session.name, "Hyprland"); assert_eq!(session.exec, vec!["Hyprland"]); std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn list_returns_all_sessions_wayland_then_x() { + let pid = std::process::id(); + let wayland = std::env::temp_dir().join(format!("breadgreet-test-sessions-list-w-{pid}")); + let x11 = std::env::temp_dir().join(format!("breadgreet-test-sessions-list-x-{pid}")); + std::fs::create_dir_all(&wayland).unwrap(); + std::fs::create_dir_all(&x11).unwrap(); + write_fixture(&wayland, "bos", "BOS", "/usr/local/bin/bos-session"); + write_fixture(&wayland, "hyprland", "Hyprland", "Hyprland"); + write_fixture(&x11, "openbox", "Openbox", "openbox-session"); + + let listed = list( + &[wayland.to_str().unwrap().to_string()], + &[x11.to_str().unwrap().to_string()], + ); + let stems: Vec<&str> = listed.iter().map(|s| s.stem.as_str()).collect(); + assert_eq!(stems, vec!["bos", "hyprland", "openbox"]); + assert_eq!(listed[0].exec, vec!["/usr/local/bin/bos-session"]); + + std::fs::remove_dir_all(&wayland).ok(); + std::fs::remove_dir_all(&x11).ok(); + } + + #[test] + fn default_index_prefers_bos_then_first() { + let sessions = vec![ + Session { + stem: "aaa".into(), + name: "A".into(), + exec: vec!["a".into()], + }, + Session { + stem: "bos".into(), + name: "BOS".into(), + exec: vec!["bos-session".into()], + }, + ]; + assert_eq!(default_index(&sessions, "bos"), 1); + assert_eq!(default_index(&sessions, "missing"), 0); + assert_eq!(default_index(&[], "bos"), 0); + } } diff --git a/breadgreet/src/theme.rs b/breadgreet/src/theme.rs index 30a8aa2..155e333 100644 --- a/breadgreet/src/theme.rs +++ b/breadgreet/src/theme.rs @@ -16,7 +16,8 @@ fn load_css() -> String { .login-entry {{ font-size: 14px; }}\ .login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\ .login-status.error {{ color: {red}; opacity: 1; }}\ - .login-session {{ font-size: 12px; opacity: 0.6; margin-top: 12px; }}", + .login-session {{ font-size: 12px; opacity: 0.85; margin-top: 12px; }}\ + dropdown.login-session {{ min-height: 32px; }}", bg = p.background, surface = p.color0, red = p.color1,