Add breadgreet session picker for installed Wayland sessions
List .desktop sessions from the existing scan, pre-select bos when present (else the first entry), and pass the chosen Exec= argv to greetd StartSession.
This commit is contained in:
parent
80caedc7fd
commit
495fe1aaad
6 changed files with 151 additions and 50 deletions
|
|
@ -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.
|
- **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.
|
- **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
|
## Config
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ family = "Varela Round"
|
||||||
# Directories scanned for .desktop session entries, in order.
|
# Directories scanned for .desktop session entries, in order.
|
||||||
wayland_dirs = ["/usr/share/wayland-sessions"]
|
wayland_dirs = ["/usr/share/wayland-sessions"]
|
||||||
xsessions_dirs = ["/usr/share/xsessions"]
|
xsessions_dirs = ["/usr/share/xsessions"]
|
||||||
# .desktop file stem (without extension) to auto-select. Falls back to the
|
# .desktop file stem (without extension) pre-selected in the picker.
|
||||||
# first entry found if this isn't present. v1 has no session picker UI —
|
# Falls back to the first entry found if this isn't present. BOS ships
|
||||||
# BOS only ships one session (Hyprland) today.
|
# bos.desktop (Exec=bos-session); leaving this as "bos" is what the ISO
|
||||||
default = "hyprland"
|
# config expects so Hyprland's own hyprland.desktop is not picked first.
|
||||||
|
default = "bos"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ pub struct Config {
|
||||||
pub struct Sessions {
|
pub struct Sessions {
|
||||||
pub wayland_dirs: Vec<String>,
|
pub wayland_dirs: Vec<String>,
|
||||||
pub xsessions_dirs: Vec<String>,
|
pub xsessions_dirs: Vec<String>,
|
||||||
/// `.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,
|
pub default: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ impl Default for Sessions {
|
||||||
Self {
|
Self {
|
||||||
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
|
wayland_dirs: vec!["/usr/share/wayland-sessions".to_string()],
|
||||||
xsessions_dirs: vec!["/usr/share/xsessions".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();
|
let s = Sessions::default();
|
||||||
assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]);
|
assert_eq!(s.wayland_dirs, vec!["/usr/share/wayland-sessions"]);
|
||||||
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
|
assert_eq!(s.xsessions_dirs, vec!["/usr/share/xsessions"]);
|
||||||
assert_eq!(s.default, "hyprland");
|
assert_eq!(s.default, "bos");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ enum AppInput {
|
||||||
Outcome(Outcome),
|
Outcome(Outcome),
|
||||||
Error(String),
|
Error(String),
|
||||||
SessionStarted,
|
SessionStarted,
|
||||||
|
/// Picker changed; `u32::MAX` (`INVALID_LIST_POSITION`) is ignored.
|
||||||
|
SessionSelected(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
|
|
@ -45,7 +47,8 @@ struct App {
|
||||||
entry: gtk4::Entry,
|
entry: gtk4::Entry,
|
||||||
stage: Stage,
|
stage: Stage,
|
||||||
username: String,
|
username: String,
|
||||||
session: Option<sessions::Session>,
|
sessions: Vec<sessions::Session>,
|
||||||
|
selected: usize,
|
||||||
clock_format: String,
|
clock_format: String,
|
||||||
cmd_tx: mpsc::UnboundedSender<GreetdCommand>,
|
cmd_tx: mpsc::UnboundedSender<GreetdCommand>,
|
||||||
}
|
}
|
||||||
|
|
@ -78,11 +81,19 @@ impl SimpleComponent for App {
|
||||||
root.fullscreen();
|
root.fullscreen();
|
||||||
|
|
||||||
let config = config::load();
|
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.wayland_dirs,
|
||||||
&config.sessions.xsessions_dirs,
|
&config.sessions.xsessions_dirs,
|
||||||
&config.sessions.default,
|
&config.sessions.default,
|
||||||
);
|
)
|
||||||
|
.and_then(|chosen| sessions.iter().position(|s| s.stem == chosen.stem))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let clock_lbl = gtk4::Label::new(None);
|
let clock_lbl = gtk4::Label::new(None);
|
||||||
clock_lbl.add_css_class("login-clock");
|
clock_lbl.add_css_class("login-clock");
|
||||||
|
|
@ -99,17 +110,33 @@ impl SimpleComponent for App {
|
||||||
let status_lbl = gtk4::Label::new(None);
|
let status_lbl = gtk4::Label::new(None);
|
||||||
status_lbl.add_css_class("login-status");
|
status_lbl.add_css_class("login-status");
|
||||||
|
|
||||||
let session_lbl = gtk4::Label::new(session.as_ref().map(|s| s.name.as_str()));
|
let session_widget: gtk4::Widget = if sessions.is_empty() {
|
||||||
session_lbl.add_css_class("login-session");
|
let session_lbl = gtk4::Label::new(Some("No session found — cannot log in"));
|
||||||
if session.is_none() {
|
session_lbl.add_css_class("login-session");
|
||||||
session_lbl.set_label("No session found — cannot log in");
|
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);
|
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||||
card.add_css_class("login-card");
|
card.add_css_class("login-card");
|
||||||
card.append(&entry);
|
card.append(&entry);
|
||||||
card.append(&status_lbl);
|
card.append(&status_lbl);
|
||||||
card.append(&session_lbl);
|
card.append(&session_widget);
|
||||||
|
|
||||||
let widgets = view_output!();
|
let widgets = view_output!();
|
||||||
widgets.root_box.append(&clock_lbl);
|
widgets.root_box.append(&clock_lbl);
|
||||||
|
|
@ -127,13 +154,15 @@ impl SimpleComponent for App {
|
||||||
entry,
|
entry,
|
||||||
stage: Stage::Username,
|
stage: Stage::Username,
|
||||||
username: String::new(),
|
username: String::new(),
|
||||||
session,
|
sessions,
|
||||||
|
selected,
|
||||||
clock_format: config.appearance.clock.format.clone(),
|
clock_format: config.appearance.clock.format.clone(),
|
||||||
cmd_tx,
|
cmd_tx,
|
||||||
};
|
};
|
||||||
model
|
model
|
||||||
.clock_lbl
|
.clock_lbl
|
||||||
.set_label(¤t_time(&model.clock_format));
|
.set_label(¤t_time(&model.clock_format));
|
||||||
|
model.entry.grab_focus();
|
||||||
|
|
||||||
ComponentParts { model, widgets }
|
ComponentParts { model, widgets }
|
||||||
}
|
}
|
||||||
|
|
@ -161,6 +190,12 @@ impl SimpleComponent for App {
|
||||||
// nothing left for the greeter to do.
|
// nothing left for the greeter to do.
|
||||||
self.status_lbl.set_label("Starting session…");
|
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) {
|
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.set_label("No session available to start");
|
||||||
self.status_lbl.add_css_class("error");
|
self.status_lbl.add_css_class("error");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,45 @@
|
||||||
//! Session discovery: scans the standard greetd-greeter session directories
|
//! Session discovery: scans the standard greetd-greeter session directories
|
||||||
//! for `.desktop` entries. BOS effectively ships one session (Hyprland via
|
//! for `.desktop` entries, lists them for the picker, and resolves the
|
||||||
//! `bos-session`), so v1 has no picker UI — it just auto-selects the
|
//! chosen entry's `Exec=` line for `greetd`'s `StartSession`.
|
||||||
//! configured default (or the only entry found) and resolves its `Exec=`
|
//!
|
||||||
//! line to hand to `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;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
|
/// `.desktop` file stem (`bos` for `bos.desktop`) — used to match
|
||||||
|
/// `[sessions].default`.
|
||||||
|
pub stem: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub exec: Vec<String>,
|
pub exec: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Session> {
|
||||||
|
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
|
/// Scans `wayland_dirs` then `xsessions_dirs` (in that order) and returns
|
||||||
/// the entry matching `default` (by `.desktop` file stem), falling back to
|
/// the entry matching `default` (by `.desktop` file stem), falling back to
|
||||||
/// the first entry found in either directory. `None` if nothing is
|
/// the first entry found in either directory. `None` if nothing is
|
||||||
|
|
@ -21,20 +49,9 @@ pub fn discover(
|
||||||
xsessions_dirs: &[String],
|
xsessions_dirs: &[String],
|
||||||
default: &str,
|
default: &str,
|
||||||
) -> Option<Session> {
|
) -> Option<Session> {
|
||||||
let mut all: Vec<(String, DesktopEntry)> = Vec::new();
|
let all = list(wayland_dirs, xsessions_dirs);
|
||||||
for dir in wayland_dirs.iter().chain(xsessions_dirs) {
|
let idx = default_index(&all, default);
|
||||||
all.extend(scan_dir(Path::new(dir)));
|
all.into_iter().nth(idx)
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain
|
/// Splits a `.desktop` `Exec=` line into an argv. Only handles plain
|
||||||
|
|
@ -69,26 +86,72 @@ mod tests {
|
||||||
.is_none());
|
.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]
|
#[test]
|
||||||
fn discover_prefers_configured_default_over_first_entry() {
|
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::create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(
|
write_fixture(&dir, "aaa", "A", "a-cmd");
|
||||||
dir.join("aaa.desktop"),
|
write_fixture(&dir, "hyprland", "Hyprland", "Hyprland");
|
||||||
"[Desktop Entry]\nName=A\nExec=a-cmd\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
dir.join("hyprland.desktop"),
|
|
||||||
"[Desktop Entry]\nName=Hyprland\nExec=Hyprland\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let dir_str = dir.to_str().unwrap().to_string();
|
let dir_str = dir.to_str().unwrap().to_string();
|
||||||
let session = discover(&[dir_str], &[], "hyprland").unwrap();
|
let session = discover(&[dir_str], &[], "hyprland").unwrap();
|
||||||
|
assert_eq!(session.stem, "hyprland");
|
||||||
assert_eq!(session.name, "Hyprland");
|
assert_eq!(session.name, "Hyprland");
|
||||||
assert_eq!(session.exec, vec!["Hyprland"]);
|
assert_eq!(session.exec, vec!["Hyprland"]);
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ fn load_css() -> String {
|
||||||
.login-entry {{ font-size: 14px; }}\
|
.login-entry {{ font-size: 14px; }}\
|
||||||
.login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\
|
.login-status {{ font-size: 12px; opacity: 0.75; margin-top: 8px; }}\
|
||||||
.login-status.error {{ color: {red}; opacity: 1; }}\
|
.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,
|
bg = p.background,
|
||||||
surface = p.color0,
|
surface = p.color0,
|
||||||
red = p.color1,
|
red = p.color1,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue