├── contrib ├── fixtures │ ├── group │ └── passwd ├── git-version.sh ├── screenshot.png ├── screenshot-themed.png ├── locales │ ├── pt-BR │ │ └── tuigreet.ftl │ ├── it-IT │ │ └── tuigreet.ftl │ ├── ca-ES │ │ └── tuigreet.ftl │ ├── de-DE │ │ └── tuigreet.ftl │ ├── ru-RU │ │ └── tuigreet.ftl │ ├── en-US │ │ └── tuigreet.ftl │ ├── es-CL │ │ └── tuigreet.ftl │ ├── uk-UA │ │ └── tuigreet.ftl │ ├── pl-PL │ │ └── tuigreet.ftl │ └── fr-FR │ │ └── tuigreet.ftl └── man │ └── tuigreet-1.scd ├── .gitignore ├── src ├── ui │ ├── common │ │ ├── mod.rs │ │ ├── masked.rs │ │ ├── menu.rs │ │ └── style.rs │ ├── power.rs │ ├── users.rs │ ├── i18n.rs │ ├── processing.rs │ ├── command.rs │ ├── prompt.rs │ ├── mod.rs │ ├── sessions.rs │ └── util.rs ├── integration │ ├── mod.rs │ ├── common │ │ ├── output.rs │ │ ├── mod.rs │ │ └── backend.rs │ ├── exit.rs │ ├── remember.rs │ ├── movement.rs │ ├── display.rs │ ├── auth.rs │ └── menus.rs ├── macros.rs ├── event.rs ├── power.rs ├── main.rs ├── info.rs ├── ipc.rs ├── keyboard.rs └── greeter.rs ├── i18n.toml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── pr.yml │ ├── dev.yml │ ├── tip.yml │ └── release.yml ├── .rustfmt.toml ├── Cargo.toml └── README.md /contrib/fixtures/group: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /contrib/man/*.roff 3 | 4 | .vscode/ 5 | -------------------------------------------------------------------------------- /src/ui/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod masked; 2 | pub mod menu; 3 | pub mod style; 4 | -------------------------------------------------------------------------------- /contrib/git-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | git describe --long | sed 's/-/.r/;s/-/./' 4 | -------------------------------------------------------------------------------- /contrib/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apognu/tuigreet/HEAD/contrib/screenshot.png -------------------------------------------------------------------------------- /i18n.toml: -------------------------------------------------------------------------------- 1 | fallback_language = "en-US" 2 | 3 | [fluent] 4 | assets_dir = "contrib/locales" 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ "apognu" ] 2 | custom: [ 3 | "https://www.paypal.me/apognu" 4 | ] 5 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | fn_args_density = "Compressed" 2 | merge_imports = true 3 | max_width = 200 4 | tab_spaces = 2 5 | -------------------------------------------------------------------------------- /contrib/screenshot-themed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apognu/tuigreet/HEAD/contrib/screenshot-themed.png -------------------------------------------------------------------------------- /src/integration/mod.rs: -------------------------------------------------------------------------------- 1 | mod common; 2 | 3 | mod auth; 4 | mod display; 5 | mod exit; 6 | mod menus; 7 | mod movement; 8 | mod remember; 9 | -------------------------------------------------------------------------------- /contrib/fixtures/passwd: -------------------------------------------------------------------------------- 1 | root:x:0:0::/root:/bin/bash 2 | joe:x:1000:1000:Joe:/home/joe:/bin/bash 3 | bob:x:1500:1500::/home/bob:/bin/zsh 4 | postgres:x:2100:2100::/srv/postgresql:/usr/bin/nologin 5 | -------------------------------------------------------------------------------- /src/ui/power.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use crate::{power::PowerOption, ui::common::menu::MenuItem}; 4 | 5 | #[derive(SmartDefault, Clone)] 6 | pub struct Power { 7 | pub action: PowerOption, 8 | pub label: String, 9 | pub command: Option, 10 | } 11 | 12 | impl MenuItem for Power { 13 | fn format(&self) -> Cow<'_, str> { 14 | Cow::Borrowed(&self.label) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/users.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use super::common::menu::MenuItem; 4 | 5 | #[derive(Default, Clone)] 6 | pub struct User { 7 | pub username: String, 8 | pub name: Option, 9 | } 10 | 11 | impl MenuItem for User { 12 | fn format(&self) -> Cow<'_, str> { 13 | match &self.name { 14 | Some(name) => Cow::Owned(format!("{name} ({})", self.username)), 15 | None => Cow::Borrowed(&self.username), 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /contrib/locales/pt-BR/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Autenticar em {$hostname} 2 | title_command = Mudar comando da sessão 3 | title_power = Opções de energia 4 | title_session = Mudar sessão 5 | 6 | action_reset = Reset 7 | action_command = Mudar comando 8 | action_session = Escolher sessão 9 | action_power = Energia 10 | 11 | date = %a, %d %h %Y - %H:%M 12 | 13 | username = Nome de usuário: 14 | wait = Por favor, aguarde... 15 | 16 | new_command = Novo comando: 17 | 18 | shutdown = Desligar 19 | reboot = Reiniciar 20 | 21 | status_command = CMD 22 | status_caps = CAPS LOCK 23 | -------------------------------------------------------------------------------- /contrib/locales/it-IT/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Accedi a {$hostname} 2 | title_command = Cambia comando sessione 3 | title_power = Opzioni alimentazione 4 | title_session = Cambia sessione 5 | 6 | action_reset = Reset 7 | action_command = Cambia comando 8 | action_session = Scegli sessione 9 | action_power = Alimentazione 10 | 11 | date = %a, %d %h %Y - %H:%M 12 | 13 | username = Nome utente: 14 | wait = Attendere... 15 | failed = Autenticazione fallita, riprova. 16 | 17 | new_command = Nuovo comando: 18 | 19 | shutdown = Spegni 20 | reboot = Riavvia 21 | 22 | status_command = CMD 23 | status_caps = BLC MAIUSC 24 | -------------------------------------------------------------------------------- /src/integration/common/output.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | pub(in crate::integration) struct Output(pub String); 4 | 5 | impl Deref for Output { 6 | type Target = String; 7 | 8 | fn deref(&self) -> &Self::Target { 9 | &self.0 10 | } 11 | } 12 | 13 | #[allow(dead_code)] 14 | impl Output { 15 | pub fn debug_print(&self) { 16 | for line in self.lines() { 17 | println!("{}", line); 18 | } 19 | } 20 | 21 | pub fn debug_inspect(&self) { 22 | for line in self.lines() { 23 | println!("{:?}", line.as_bytes().iter().map(|c| *c as char).collect::>()); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /contrib/locales/ca-ES/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Autenticació a {$hostname} 2 | title_command = Canvi d'ordre de sessió 3 | title_power = Opcions d'engegada 4 | title_session = Canvi de sessió 5 | 6 | action_reset = Reinicialitza 7 | action_command = Canvia l'ordre 8 | action_session = Tria una sessió 9 | action_power = Engegada 10 | 11 | date = %a %d %h %Y - %H:%M 12 | 13 | username = Nom d'usuari: 14 | wait = Espereu... 15 | failed = Error d'autenticació, torneu-ho a provar. 16 | 17 | new_command = Ordre nova: 18 | 19 | shutdown = Atura 20 | reboot = Reinicia 21 | 22 | status_command = CMD 23 | status_caps = BLOQ MAJ 24 | -------------------------------------------------------------------------------- /contrib/locales/de-DE/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Bei {$hostname} authentifizieren 2 | title_command = Sitzungsbefehl ändern 3 | title_power = Energieeinstellungen 4 | title_session = Sitzung ändern 5 | 6 | action_reset = Zurücksetzen 7 | action_command = Befehl ändern 8 | action_session = Sitzung auswählen 9 | action_power = Energie 10 | 11 | date = %a, %d %h %Y - %H:%M 12 | 13 | username = Benutzername: 14 | wait = Bitte warten... 15 | failed = Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut. 16 | 17 | new_command = Neuer Befehl: 18 | 19 | shutdown = Herunterfahren 20 | reboot = Neustart 21 | 22 | status_command = CMD 23 | status_caps = FESTSTELLTASTE 24 | -------------------------------------------------------------------------------- /src/ui/i18n.rs: -------------------------------------------------------------------------------- 1 | use i18n_embed::{ 2 | fluent::{fluent_language_loader, FluentLanguageLoader}, 3 | DesktopLanguageRequester, LanguageLoader, 4 | }; 5 | use lazy_static::lazy_static; 6 | use rust_embed::RustEmbed; 7 | 8 | #[derive(RustEmbed)] 9 | #[folder = "contrib/locales"] 10 | struct Localizations; 11 | 12 | lazy_static! { 13 | pub static ref MESSAGES: FluentLanguageLoader = { 14 | let locales = Localizations; 15 | let loader = fluent_language_loader!(); 16 | loader.load_languages(&locales, &[loader.fallback_language()]).unwrap(); 17 | 18 | let _ = i18n_embed::select(&loader, &locales, &DesktopLanguageRequester::requested_languages()); 19 | 20 | loader 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/integration/exit.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyModifiers}; 2 | use libgreetd_stub::SessionOptions; 3 | 4 | use super::common::IntegrationRunner; 5 | 6 | #[tokio::test] 7 | async fn exit() { 8 | let opts = SessionOptions { 9 | username: "apognu".to_string(), 10 | password: "password".to_string(), 11 | mfa: false, 12 | }; 13 | 14 | let mut runner = IntegrationRunner::new(opts, None).await; 15 | 16 | let events = tokio::task::spawn({ 17 | let mut runner = runner.clone(); 18 | 19 | async move { 20 | runner.send_modified_key(KeyCode::Char('x'), KeyModifiers::CONTROL).await; 21 | runner.wait_for_render().await; 22 | } 23 | }); 24 | 25 | runner.join_until_client_exit(events).await; 26 | } 27 | -------------------------------------------------------------------------------- /contrib/locales/ru-RU/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Авторизация в {$hostname} 2 | title_command = Изменить команду сеанса 3 | title_power = Опции питания 4 | title_session = Изменить сеанс 5 | title_users = Выбрать пользователя 6 | 7 | action_reset = Перезагрузка 8 | action_command = Изменить команду 9 | action_session = Выбрать сеанс 10 | action_power = Питание 11 | 12 | date = %a, %d %h %Y - %H:%M 13 | 14 | select_user = Нажмите Enter для выбора пользователя или начните печатать... 15 | username = Имя пользователя: 16 | wait = Пожалуйста подождите... 17 | failed = Ошибка аутентификации, попробуйте снова. 18 | 19 | new_command = Новая команда: 20 | 21 | shutdown = Выключение 22 | reboot = Перезагрузка 23 | 24 | command_exited = Команда завершилась с 25 | command_failed = Команда не выполнена 26 | 27 | status_command = CMD 28 | status_caps = CAPS LOCK 29 | -------------------------------------------------------------------------------- /contrib/locales/en-US/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Authenticate into {$hostname} 2 | title_command = Change session command 3 | title_power = Power options 4 | title_session = Change session 5 | title_users = Select a user 6 | 7 | action_reset = Reset 8 | action_command = Change command 9 | action_session = Choose session 10 | action_power = Power 11 | 12 | date = %a, %d %h %Y - %H:%M 13 | 14 | select_user = Press Enter to select a user or start typing... 15 | username = Username: 16 | wait = Please wait... 17 | failed = Authentication failed, please try again. 18 | 19 | new_command = New command: 20 | 21 | shutdown = Shut down 22 | reboot = Reboot 23 | 24 | command_missing = No command configured 25 | command_exited = Command exited with 26 | command_failed = Command failed 27 | 28 | status_command = CMD 29 | status_session = SESS 30 | status_caps = CAPS LOCK 31 | -------------------------------------------------------------------------------- /contrib/locales/es-CL/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Autenticación en {$hostname} 2 | title_command = Cambiar el comando de sesión 3 | title_power = Opciones de energía 4 | title_session = Cambiar sesión 5 | title_users = Seleccionar usuario 6 | 7 | action_reset = Reiniciar 8 | action_command = Cambiar comando 9 | action_session = Escoger sesión 10 | action_power = Energía 11 | 12 | date = %a, %d %h %Y - %H:%M 13 | 14 | select_user = Presiona Intro para seleccionar un usuario o comenzar a escribir... 15 | username = Usuario: 16 | wait = Por favor espera... 17 | failed = Autenticación fallida, por favor vuelve a intentarlo. 18 | 19 | new_command = Nuevo comando: 20 | 21 | shutdown = Apagar 22 | reboot = Reiniciar 23 | 24 | command_exited = El comando terminó con 25 | command_failed = El comando falló 26 | 27 | status_command = CMD 28 | status_caps = BLOQ MAYÚS 29 | -------------------------------------------------------------------------------- /contrib/locales/uk-UA/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Вхід до {$hostname} 2 | title_command = Змінити команду для сесії 3 | title_power = Живлення 4 | title_session = Виберість сесію 5 | title_users = Виберіть користувача 6 | 7 | action_reset = Скинути 8 | action_command = Змінити команду 9 | action_session = Вибрати сесію 10 | action_power = Живлення 11 | 12 | date = %a, %d %h %Y - %H:%M 13 | 14 | select_user = Натисніть Enter щоб вибрати користувача або введіть його ім'я... 15 | username = Ім'я користувача: 16 | wait = Будь ласка, зачекайте... 17 | failed = Помилка входу, будь ласка, спробуйте знову. 18 | 19 | new_command = Нова команда: 20 | 21 | shutdown = Завершити роботу 22 | reboot = Перезавантажити 23 | 24 | command_exited = Команда завершила роботу з кодом 25 | command_failed = Під час виконання команди виникла помилка 26 | 27 | status_command = CMD 28 | status_caps = CAPS LOCK 29 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | use greetd_ipc::Request; 2 | 3 | pub trait SafeDebug { 4 | fn safe_repr(&self) -> String; 5 | } 6 | 7 | impl SafeDebug for Request { 8 | fn safe_repr(&self) -> String { 9 | match self { 10 | msg @ &Request::CancelSession => format!("{:?}", msg), 11 | msg @ &Request::CreateSession { .. } => format!("{:?}", msg), 12 | &Request::PostAuthMessageResponse { .. } => "PostAuthMessageResponse".to_string(), 13 | msg @ &Request::StartSession { .. } => format!("{:?}", msg), 14 | } 15 | } 16 | } 17 | 18 | macro_rules! fl { 19 | ($message_id:literal) => {{ 20 | i18n_embed_fl::fl!($crate::ui::MESSAGES, $message_id).replace(&['\u{2068}', '\u{2069}'], "") 21 | }}; 22 | 23 | ($message_id:literal, $($args:expr),*) => {{ 24 | i18n_embed_fl::fl!($crate::ui::MESSAGES, $message_id, $($args),*).replace(&['\u{2068}', '\u{2069}'], "") 25 | }}; 26 | } 27 | -------------------------------------------------------------------------------- /contrib/locales/pl-PL/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Uwierzytelnianie dla urządzenia {$hostname} 2 | title_command = Zmiana polecenia dla sesji 3 | title_power = Opcje zasilania 4 | title_session = Zmiana sesji 5 | title_users = Wybór użytkownika 6 | 7 | 8 | action_reset = Reset 9 | action_command = Zmiana polecenia 10 | action_session = Wybór sesji 11 | action_power = Zasilanie 12 | 13 | date = %a, %d %h %Y - %H:%M 14 | 15 | select_user = Naciśnij Enter, by wybrać użytkownika, lub zacznij pisać... 16 | username = Nazwa użytkownika: 17 | wait = Proszę czekać... 18 | failed = Uwierzytelnianie nie powiodło się. Spróbuj ponownie. 19 | 20 | new_command = Nowe polecenie: 21 | 22 | shutdown = Wyłącz 23 | reboot = Uruchom ponownie 24 | 25 | command_exited = Polecenie zakończone z kodem 26 | command_failed = Polecenie zakończone niepowodzeniem 27 | 28 | status_command = CMD 29 | status_caps = CAPS LOCK 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **System information:** 24 | - Distribution: 25 | - `greetd` version: 26 | - `tuigreet` version: 27 | - Installation method (from source, package, binary, etc.): 28 | - `tuigreet` command line: 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | 33 | **Debug log** 34 | Run `tuigreet` with the `-d [FILE]` option to log tracing information into the 35 | specified file, to append here. 36 | -------------------------------------------------------------------------------- /contrib/locales/fr-FR/tuigreet.ftl: -------------------------------------------------------------------------------- 1 | title_authenticate = Se connecter à {$hostname} 2 | title_command = Changer la commande de session 3 | title_power = Options d'alimentation 4 | title_session = Changer la session 5 | title_users = Choisissez un utilisateur 6 | 7 | action_reset = Réinitialiser 8 | action_command = Changer la commande 9 | action_session = Choisir la session 10 | action_power = Alimentation 11 | 12 | date = %a %d %h %Y - %H:%M 13 | 14 | select_user = Appuyez sur Entrée pour choisir un utilisateur ou tapez son nom... 15 | username = Nom d'utilisateur : 16 | wait = Veuillez patienter... 17 | failed = Erreur d'authentification, veuillez réessayer. 18 | 19 | command = Nouvelle commande : 20 | 21 | shutdown = Éteindre 22 | reboot = Redémarrer 23 | 24 | command_missing = Aucune commande configurée 25 | command_exited = La commande a retourné 26 | command_failed = Échec de la commande 27 | 28 | status_command = CMD 29 | status_caps = VERR. MAJ. 30 | -------------------------------------------------------------------------------- /src/ui/common/masked.rs: -------------------------------------------------------------------------------- 1 | use zeroize::Zeroize; 2 | 3 | #[derive(Default)] 4 | pub struct MaskedString { 5 | pub value: String, 6 | pub mask: Option, 7 | } 8 | 9 | impl MaskedString { 10 | pub fn from(value: String, mask: Option) -> MaskedString { 11 | MaskedString { value, mask } 12 | } 13 | 14 | pub fn get(&self) -> &str { 15 | match self.mask { 16 | Some(ref mask) => mask, 17 | None => &self.value, 18 | } 19 | } 20 | 21 | pub fn zeroize(&mut self) { 22 | self.value.zeroize(); 23 | 24 | if let Some(ref mut mask) = self.mask { 25 | mask.zeroize(); 26 | } 27 | 28 | self.mask = None; 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::MaskedString; 35 | 36 | #[test] 37 | fn get_value_when_unmasked() { 38 | let masked = MaskedString::from("value".to_string(), None); 39 | 40 | assert_eq!(masked.get(), "value"); 41 | } 42 | 43 | #[test] 44 | fn get_mask_when_masked() { 45 | let masked = MaskedString::from("value".to_string(), Some("mask".to_string())); 46 | 47 | assert_eq!(masked.get(), "mask"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/integration/remember.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::KeyCode; 2 | use libgreetd_stub::SessionOptions; 3 | 4 | use crate::ui::common::masked::MaskedString; 5 | 6 | use super::common::IntegrationRunner; 7 | 8 | #[tokio::test] 9 | async fn remember_username() { 10 | let opts = SessionOptions { 11 | username: "apognu".to_string(), 12 | password: "password".to_string(), 13 | mfa: false, 14 | }; 15 | 16 | let mut runner = IntegrationRunner::new( 17 | opts, 18 | Some(|greeter| { 19 | greeter.remember = true; 20 | greeter.username = MaskedString::from("apognu".to_string(), None); 21 | }), 22 | ) 23 | .await; 24 | 25 | let events = tokio::task::spawn({ 26 | let mut runner = runner.clone(); 27 | 28 | async move { 29 | runner.wait_until_buffer_contains("Username:").await; 30 | 31 | assert!(runner.output().await.contains("Username: apognu")); 32 | 33 | runner.wait_until_buffer_contains("Password:").await; 34 | runner.send_key(KeyCode::Esc).await; 35 | runner.wait_for_render().await; 36 | 37 | assert!(runner.output().await.contains("Username: ")); 38 | assert!(!runner.output().await.contains("Password:")); 39 | } 40 | }); 41 | 42 | runner.join_until_end(events).await; 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/processing.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use tui::{ 4 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 5 | text::Span, 6 | widgets::{Block, BorderType, Borders, Paragraph}, 7 | }; 8 | 9 | use crate::{ 10 | ui::{util::*, Frame}, 11 | Greeter, 12 | }; 13 | 14 | pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { 15 | let size = f.size(); 16 | 17 | let width = greeter.width(); 18 | let height: u16 = get_height(greeter) + 1; 19 | let x = (size.width - width) / 2; 20 | let y = (size.height - height) / 2; 21 | 22 | let container = Rect::new(x, y, width, height); 23 | let container_padding = greeter.container_padding(); 24 | let frame = Rect::new(x + container_padding, y + container_padding, width - (2 * container_padding), height - (2 * container_padding)); 25 | 26 | let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain); 27 | 28 | let constraints = [Constraint::Length(1)]; 29 | 30 | let chunks = Layout::default().direction(Direction::Vertical).constraints(constraints.as_ref()).split(frame); 31 | let text = Span::from(fl!("wait")); 32 | let paragraph = Paragraph::new(text).alignment(Alignment::Center); 33 | 34 | f.render_widget(paragraph, chunks[0]); 35 | f.render_widget(block, container); 36 | 37 | Ok((1, 1)) 38 | } 39 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tuigreet" 3 | version = "0.9.1" 4 | authors = ["Antoine POPINEAU "] 5 | edition = "2018" 6 | build = "build.rs" 7 | 8 | [features] 9 | default = [] 10 | nsswrapper = [] 11 | 12 | [dependencies] 13 | ansi-to-tui = "5.0.0-rc.1" 14 | chrono = { version = "^0.4", features = ["unstable-locales"] } 15 | crossterm = { version = "^0.27", features = ["event-stream"] } 16 | futures = "0.3" 17 | getopts = "^0.2" 18 | greetd_ipc = { version = "^0.10", features = ["tokio-codec"] } 19 | i18n-embed = { version = "^0.14", features = [ 20 | "desktop-requester", 21 | "fluent-system", 22 | ] } 23 | i18n-embed-fl = "^0.8" 24 | lazy_static = "^1.4" 25 | nix = { version = "^0.28", features = ["feature"] } 26 | tui = { package = "ratatui", version = "^0.27", default-features = false, features = [ 27 | "crossterm", 28 | "unstable" 29 | ] } 30 | rust-embed = "^8.0" 31 | rust-ini = "^0.21" 32 | smart-default = "^0.7" 33 | tokio = { version = "^1.2", default-features = false, features = [ 34 | "macros", 35 | "rt-multi-thread", 36 | "net", 37 | "sync", 38 | "time", 39 | "process", 40 | ] } 41 | unic-langid = "^0.9" 42 | zeroize = "^1.3" 43 | uzers = "0.12" 44 | rand = "0.8.5" 45 | tracing-appender = "0.2.3" 46 | tracing-subscriber = "0.3.18" 47 | tracing = "0.1.40" 48 | utmp-rs = "0.3.0" 49 | 50 | [profile.release] 51 | lto = true 52 | 53 | [dev-dependencies] 54 | greetd-stub = "0.3.0" 55 | tempfile = "3.10.1" 56 | unicode-width = "0.1.12" 57 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull request build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | clippy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dtolnay/rust-toolchain@stable 14 | - uses: Swatinem/rust-cache@v2 15 | - name: Lint 16 | run: | 17 | cargo clippy -- -D warnings 18 | cargo clippy --release -- -D warnings 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@stable 25 | - run: | 26 | sudo apt update && sudo apt install -y libnss-wrapper scdoc 27 | - uses: Swatinem/rust-cache@v2 28 | - name: Test 29 | env: 30 | NSS_WRAPPER_PASSWD: contrib/fixtures/passwd 31 | NSS_WRAPPER_GROUP: contrib/fixtures/group 32 | run: | 33 | cargo test 34 | LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_ 35 | - name: Generate manpage 36 | run: | 37 | scdoc < contrib/man/tuigreet-1.scd > /dev/null 38 | 39 | build: 40 | strategy: 41 | matrix: 42 | arch: 43 | - { name: "x86_64", os: "ubuntu-latest", target: "x86_64-unknown-linux-gnu", cross: false } 44 | runs-on: ${{ matrix.arch.os }} 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: dtolnay/rust-toolchain@stable 48 | - uses: Swatinem/rust-cache@v2 49 | with: 50 | shared-key: cargo-cache-${{ matrix.arch.target }} 51 | - name: Build 52 | run: | 53 | cargo build --release --target=${{ matrix.arch.target }} 54 | -------------------------------------------------------------------------------- /src/integration/movement.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyModifiers}; 2 | use libgreetd_stub::SessionOptions; 3 | 4 | use super::common::IntegrationRunner; 5 | 6 | #[tokio::test] 7 | async fn keyboard_movement() { 8 | let opts = SessionOptions { 9 | username: "apognu".to_string(), 10 | password: "password".to_string(), 11 | mfa: false, 12 | }; 13 | 14 | let mut runner = IntegrationRunner::new(opts, None).await; 15 | 16 | let events = tokio::task::spawn({ 17 | let mut runner = runner.clone(); 18 | 19 | async move { 20 | runner.wait_until_buffer_contains("Username:").await; 21 | for char in "apognu".chars() { 22 | runner.send_key(KeyCode::Char(char)).await; 23 | } 24 | runner.wait_for_render().await; 25 | 26 | assert!(runner.output().await.contains("Username: apognu")); 27 | 28 | runner.send_key(KeyCode::Left).await; 29 | runner.send_key(KeyCode::Char('l')).await; 30 | runner.send_key(KeyCode::Right).await; 31 | runner.send_key(KeyCode::Char('r')).await; 32 | runner.send_modified_key(KeyCode::Char('a'), KeyModifiers::CONTROL).await; 33 | runner.send_key(KeyCode::Char('a')).await; 34 | runner.send_modified_key(KeyCode::Char('e'), KeyModifiers::CONTROL).await; 35 | runner.send_key(KeyCode::Char('e')).await; 36 | runner.wait_for_render().await; 37 | 38 | assert!(runner.output().await.contains("Username: aapognlure")); 39 | 40 | runner.send_key(KeyCode::Left).await; 41 | runner.send_modified_key(KeyCode::Char('u'), KeyModifiers::CONTROL).await; 42 | runner.wait_for_render().await; 43 | 44 | assert!(runner.output().await.contains("Username: ")); 45 | } 46 | }); 47 | 48 | runner.join_until_end(events).await; 49 | } 50 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crossterm::event::{Event as TermEvent, KeyEvent}; 4 | use futures::{future::FutureExt, StreamExt}; 5 | use tokio::{ 6 | process::Command, 7 | sync::mpsc::{self, Sender}, 8 | }; 9 | 10 | #[cfg(not(test))] 11 | use crossterm::event::EventStream; 12 | 13 | use crate::AuthStatus; 14 | 15 | const FRAME_RATE: f64 = 2.0; 16 | 17 | pub enum Event { 18 | Key(KeyEvent), 19 | Render, 20 | PowerCommand(Command), 21 | Exit(AuthStatus), 22 | } 23 | 24 | pub struct Events { 25 | rx: mpsc::Receiver, 26 | tx: mpsc::Sender, 27 | } 28 | 29 | impl Events { 30 | pub async fn new() -> Events { 31 | let (tx, rx) = mpsc::channel(10); 32 | 33 | tokio::task::spawn({ 34 | let tx = tx.clone(); 35 | 36 | async move { 37 | #[cfg(not(test))] 38 | let mut stream = EventStream::new(); 39 | 40 | // In tests, we are not capturing events from the terminal, so we need 41 | // to replace the crossterm::EventStream with a dummy pending stream. 42 | #[cfg(test)] 43 | let mut stream = futures::stream::pending::>(); 44 | 45 | let mut render_interval = tokio::time::interval(Duration::from_secs_f64(1.0 / FRAME_RATE)); 46 | 47 | loop { 48 | let render = render_interval.tick(); 49 | let event = stream.next().fuse(); 50 | 51 | tokio::select! { 52 | event = event => { 53 | if let Some(Ok(TermEvent::Key(event))) = event { 54 | let _ = tx.send(Event::Key(event)).await; 55 | let _ = tx.send(Event::Render).await; 56 | } 57 | } 58 | 59 | _ = render => { let _ = tx.send(Event::Render).await; }, 60 | } 61 | } 62 | } 63 | }); 64 | 65 | Events { rx, tx } 66 | } 67 | 68 | pub async fn next(&mut self) -> Option { 69 | self.rx.recv().await 70 | } 71 | 72 | pub fn sender(&self) -> Sender { 73 | self.tx.clone() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: Development build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "dev/*" 7 | 8 | jobs: 9 | clippy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dtolnay/rust-toolchain@stable 14 | - uses: Swatinem/rust-cache@v2 15 | - name: Lint 16 | run: | 17 | cargo clippy -- -D warnings 18 | cargo clippy --release -- -D warnings 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@stable 25 | - run: | 26 | sudo apt update && sudo apt install -y libnss-wrapper scdoc 27 | - uses: Swatinem/rust-cache@v2 28 | - name: Test 29 | env: 30 | NSS_WRAPPER_PASSWD: contrib/fixtures/passwd 31 | NSS_WRAPPER_GROUP: contrib/fixtures/group 32 | run: | 33 | cargo test 34 | LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_ 35 | - name: Generate manpage 36 | run: | 37 | scdoc < contrib/man/tuigreet-1.scd > /dev/null 38 | 39 | build: 40 | strategy: 41 | matrix: 42 | arch: 43 | - { name: "x86_64", os: "ubuntu-latest", target: "x86_64-unknown-linux-gnu", cross: false } 44 | runs-on: ${{ matrix.arch.os }} 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: dtolnay/rust-toolchain@stable 48 | - uses: Swatinem/rust-cache@v2 49 | with: 50 | shared-key: cargo-cache-${{ matrix.arch.target }} 51 | - name: Build 52 | run: | 53 | cargo build --release --target=${{ matrix.arch.target }} 54 | - name: Rename artifact 55 | run: mv target/${{ matrix.arch.target }}/release/tuigreet target/${{ matrix.arch.target }}/release/tuigreet-dev-${{ matrix.arch.name }} 56 | - name: Upload artifact 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: tuigreet-dev-${{ matrix.arch.name }} 60 | path: target/${{ matrix.arch.target }}/release/tuigreet-dev-${{ matrix.arch.name }} 61 | -------------------------------------------------------------------------------- /src/ui/common/menu.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, error::Error}; 2 | 3 | use tui::{ 4 | prelude::Rect, 5 | style::{Modifier, Style}, 6 | text::Span, 7 | widgets::{Block, BorderType, Borders, Paragraph}, 8 | }; 9 | 10 | use crate::{ 11 | ui::{ 12 | util::{get_rect_bounds, titleize}, 13 | Frame, 14 | }, 15 | Greeter, 16 | }; 17 | 18 | use super::style::Themed; 19 | 20 | pub trait MenuItem { 21 | fn format(&self) -> Cow<'_, str>; 22 | } 23 | 24 | #[derive(Default)] 25 | pub struct Menu 26 | where 27 | T: MenuItem, 28 | { 29 | pub title: String, 30 | pub options: Vec, 31 | pub selected: usize, 32 | } 33 | 34 | impl Menu 35 | where 36 | T: MenuItem, 37 | { 38 | pub fn draw(&self, greeter: &Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { 39 | let theme = &greeter.theme; 40 | 41 | let size = f.size(); 42 | let (x, y, width, height) = get_rect_bounds(greeter, size, self.options.len()); 43 | 44 | let container = Rect::new(x, y, width, height); 45 | 46 | let title = Span::from(titleize(&self.title)); 47 | let block = Block::default() 48 | .title(title) 49 | .title_style(theme.of(&[Themed::Title])) 50 | .style(theme.of(&[Themed::Container])) 51 | .borders(Borders::ALL) 52 | .border_type(BorderType::Plain) 53 | .border_style(theme.of(&[Themed::Border])); 54 | 55 | for (index, option) in self.options.iter().enumerate() { 56 | let name = option.format(); 57 | let name = format!("{:1$}", name, greeter.width() as usize - 4); 58 | 59 | let frame = Rect::new(x + 2, y + 2 + index as u16, width - 4, 1); 60 | let option_text = self.get_option(name, index); 61 | let option = Paragraph::new(option_text); 62 | 63 | f.render_widget(option, frame); 64 | } 65 | 66 | f.render_widget(block, container); 67 | 68 | Ok((1, 1)) 69 | } 70 | 71 | fn get_option<'g, S>(&self, name: S, index: usize) -> Span<'g> 72 | where 73 | S: Into, 74 | { 75 | if self.selected == index { 76 | Span::styled(name.into(), Style::default().add_modifier(Modifier::REVERSED)) 77 | } else { 78 | Span::from(name.into()) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ui/command.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use tui::{ 4 | layout::{Constraint, Direction, Layout, Rect}, 5 | text::Span, 6 | widgets::{Block, BorderType, Borders, Paragraph}, 7 | }; 8 | 9 | use crate::{ 10 | ui::util::*, 11 | ui::{prompt_value, Frame}, 12 | Greeter, 13 | }; 14 | 15 | use super::common::style::Themed; 16 | 17 | pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { 18 | let theme = &greeter.theme; 19 | 20 | let size = f.size(); 21 | let (x, y, width, height) = get_rect_bounds(greeter, size, 0); 22 | 23 | let container_padding = greeter.container_padding(); 24 | 25 | let container = Rect::new(x, y, width, height); 26 | let frame = Rect::new(x + container_padding, y + container_padding, width - container_padding, height - container_padding); 27 | 28 | let block = Block::default() 29 | .title(titleize(&fl!("title_command"))) 30 | .title_style(theme.of(&[Themed::Title])) 31 | .style(theme.of(&[Themed::Container])) 32 | .borders(Borders::ALL) 33 | .border_type(BorderType::Plain) 34 | .border_style(theme.of(&[Themed::Border])); 35 | 36 | f.render_widget(block, container); 37 | 38 | let constraints = [ 39 | Constraint::Length(1), // Username 40 | ]; 41 | 42 | let chunks = Layout::default().direction(Direction::Vertical).constraints(constraints.as_ref()).split(frame); 43 | let cursor = chunks[0]; 44 | 45 | let command_label_text = prompt_value(theme, Some(fl!("new_command"))); 46 | let command_label = Paragraph::new(command_label_text).style(theme.of(&[Themed::Prompt])); 47 | let command_value_text = Span::from(&greeter.buffer); 48 | let command_value = Paragraph::new(command_value_text).style(theme.of(&[Themed::Input])); 49 | 50 | f.render_widget(command_label, chunks[0]); 51 | f.render_widget( 52 | command_value, 53 | Rect::new( 54 | 1 + chunks[0].x + fl!("new_command").chars().count() as u16, 55 | chunks[0].y, 56 | get_input_width(greeter, width, &Some(fl!("new_command"))), 57 | 1, 58 | ), 59 | ); 60 | 61 | let new_command = greeter.buffer.clone(); 62 | let offset = get_cursor_offset(greeter, new_command.chars().count()); 63 | 64 | Ok((2 + cursor.x + fl!("new_command").chars().count() as u16 + offset as u16, cursor.y + 1)) 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/tip.yml: -------------------------------------------------------------------------------- 1 | name: Continuous master build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | clippy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dtolnay/rust-toolchain@stable 14 | - uses: Swatinem/rust-cache@v2 15 | - name: Lint 16 | run: | 17 | cargo clippy -- -D warnings 18 | cargo clippy --release -- -D warnings 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@stable 25 | - run: | 26 | sudo apt update && sudo apt install -y libnss-wrapper scdoc 27 | - uses: Swatinem/rust-cache@v2 28 | - name: Test 29 | env: 30 | NSS_WRAPPER_PASSWD: contrib/fixtures/passwd 31 | NSS_WRAPPER_GROUP: contrib/fixtures/group 32 | run: | 33 | cargo test 34 | LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_ 35 | - name: Generate manpage 36 | run: | 37 | scdoc < contrib/man/tuigreet-1.scd > /dev/null 38 | 39 | build: 40 | strategy: 41 | matrix: 42 | arch: 43 | - { name: "x86_64", os: "ubuntu-latest", target: "x86_64-unknown-linux-gnu", cross: false } 44 | runs-on: ${{ matrix.arch.os }} 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: dtolnay/rust-toolchain@stable 48 | - uses: Swatinem/rust-cache@v2 49 | with: 50 | shared-key: cargo-cache-${{ matrix.arch.target }} 51 | - name: Build 52 | run: | 53 | cargo build --release --target=${{ matrix.arch.target }} 54 | - name: Rename artifact 55 | run: mv target/${{ matrix.arch.target }}/release/tuigreet target/${{ matrix.arch.target }}/release/tuigreet-dev-${{ matrix.arch.name }} 56 | - name: Upload artifact 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: tuigreet-dev-${{ matrix.arch.name }} 60 | path: target/${{ matrix.arch.target }}/release/tuigreet-dev-${{ matrix.arch.name }} 61 | 62 | package: 63 | runs-on: ubuntu-latest 64 | needs: build 65 | steps: 66 | - name: Download artifacts 67 | uses: actions/download-artifact@v4 68 | with: 69 | path: target/out 70 | - name: Create release 71 | uses: ncipollo/release-action@v1 72 | with: 73 | token: ${{ secrets.GITHUB_TOKEN }} 74 | name: tip 75 | prerelease: true 76 | tag: tip 77 | commit: ${{ github.sha }} 78 | artifacts: target/out/*/* 79 | allowUpdates: true 80 | removeArtifacts: true 81 | -------------------------------------------------------------------------------- /src/power.rs: -------------------------------------------------------------------------------- 1 | use std::{process::Stdio, sync::Arc}; 2 | 3 | use tokio::{process::Command, sync::RwLock}; 4 | 5 | use crate::{event::Event, ui::power::Power, Greeter, Mode}; 6 | 7 | #[derive(SmartDefault, Clone, Copy, PartialEq, Eq, Hash)] 8 | pub enum PowerOption { 9 | #[default] 10 | Shutdown, 11 | Reboot, 12 | } 13 | 14 | pub async fn power(greeter: &mut Greeter, option: PowerOption) { 15 | let command = match greeter.powers.options.iter().find(|opt| opt.action == option) { 16 | None => None, 17 | 18 | Some(Power { command: Some(args), .. }) => { 19 | let command = match greeter.power_setsid { 20 | true => { 21 | let mut command = Command::new("setsid"); 22 | command.args(args.split(' ')); 23 | command 24 | } 25 | 26 | false => { 27 | let mut args = args.split(' '); 28 | 29 | let mut command = Command::new(args.next().unwrap_or_default()); 30 | command.args(args); 31 | command 32 | } 33 | }; 34 | 35 | Some(command) 36 | } 37 | 38 | Some(_) => { 39 | let mut command = Command::new("shutdown"); 40 | 41 | match option { 42 | PowerOption::Shutdown => command.arg("-h"), 43 | PowerOption::Reboot => command.arg("-r"), 44 | }; 45 | 46 | command.arg("now"); 47 | 48 | Some(command) 49 | } 50 | }; 51 | 52 | if let Some(mut command) = command { 53 | command.stdin(Stdio::null()); 54 | command.stdout(Stdio::null()); 55 | command.stderr(Stdio::null()); 56 | 57 | if let Some(ref sender) = greeter.events { 58 | let _ = sender.send(Event::PowerCommand(command)).await; 59 | } 60 | } 61 | } 62 | 63 | pub enum PowerPostAction { 64 | Noop, 65 | ClearScreen, 66 | } 67 | 68 | pub async fn run(greeter: &Arc>, mut command: Command) -> PowerPostAction { 69 | tracing::info!("executing power command: {:?}", command); 70 | 71 | greeter.write().await.mode = Mode::Processing; 72 | 73 | let message = match command.output().await { 74 | Ok(result) => match (result.status, result.stderr) { 75 | (status, _) if status.success() => None, 76 | (status, output) => { 77 | let status = format!("{} {status}", fl!("command_exited")); 78 | let output = String::from_utf8(output).unwrap_or_default(); 79 | 80 | Some(format!("{status}\n{output}")) 81 | } 82 | }, 83 | 84 | Err(err) => Some(format!("{}: {err}", fl!("command_failed"))), 85 | }; 86 | 87 | tracing::info!("power command exited with: {:?}", message); 88 | 89 | let mode = greeter.read().await.previous_mode; 90 | 91 | let mut greeter = greeter.write().await; 92 | 93 | if message.is_none() { 94 | PowerPostAction::ClearScreen 95 | } else { 96 | greeter.mode = mode; 97 | greeter.message = message; 98 | 99 | PowerPostAction::Noop 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | clippy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dtolnay/rust-toolchain@stable 14 | - uses: Swatinem/rust-cache@v2 15 | - name: Lint 16 | run: | 17 | cargo clippy -- -D warnings 18 | cargo clippy --release -- -D warnings 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@stable 25 | - run: | 26 | sudo apt update && sudo apt install -y libnss-wrapper scdoc 27 | - uses: Swatinem/rust-cache@v2 28 | - name: Test 29 | env: 30 | NSS_WRAPPER_PASSWD: contrib/fixtures/passwd 31 | NSS_WRAPPER_GROUP: contrib/fixtures/group 32 | run: | 33 | cargo test 34 | LD_PRELOAD=libnss_wrapper.so cargo test --features nsswrapper nsswrapper_ 35 | - name: Generate manpage 36 | run: | 37 | scdoc < contrib/man/tuigreet-1.scd > /dev/null 38 | 39 | build: 40 | strategy: 41 | matrix: 42 | arch: 43 | - { name: "x86_64", os: "ubuntu-latest", target: "x86_64-unknown-linux-gnu", cross: false } 44 | runs-on: ${{ matrix.arch.os }} 45 | steps: 46 | - name: Get the version 47 | id: version 48 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 49 | - uses: actions/checkout@v4 50 | - uses: dtolnay/rust-toolchain@stable 51 | - uses: Swatinem/rust-cache@v2 52 | with: 53 | shared-key: cargo-cache-${{ matrix.arch.target }} 54 | - run: rm -rf .git/ 55 | - name: Build 56 | run: | 57 | cargo build --release --target=${{ matrix.arch.target }} 58 | - name: Rename artifact 59 | run: mv target/${{ matrix.arch.target }}/release/tuigreet target/${{ matrix.arch.target }}/release/tuigreet-${{ steps.version.outputs.VERSION }}-${{ matrix.arch.name }} 60 | - name: Upload artifact 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: tuigreet-${{ steps.version.outputs.VERSION }}-${{ matrix.arch.name }} 64 | path: target/${{ matrix.arch.target }}/release/tuigreet-${{ steps.version.outputs.VERSION }}-${{ matrix.arch.name }} 65 | 66 | release: 67 | runs-on: ubuntu-latest 68 | needs: build 69 | steps: 70 | - name: Get the version 71 | id: version 72 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 73 | - name: Download artifacts 74 | uses: actions/download-artifact@v4 75 | with: 76 | path: target/out 77 | - name: Create release 78 | uses: ncipollo/release-action@v1 79 | with: 80 | token: ${{ secrets.GITHUB_TOKEN }} 81 | name: ${{ steps.version.outputs.VERSION }} 82 | prerelease: false 83 | tag: ${{ github.ref }} 84 | artifacts: target/out/*/* 85 | -------------------------------------------------------------------------------- /src/integration/display.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use chrono::Local; 4 | use libgreetd_stub::SessionOptions; 5 | 6 | use super::common::IntegrationRunner; 7 | 8 | #[tokio::test] 9 | async fn show_greet() { 10 | let opts = SessionOptions { 11 | username: "apognu".to_string(), 12 | password: "password".to_string(), 13 | mfa: false, 14 | }; 15 | 16 | let mut runner = IntegrationRunner::new( 17 | opts, 18 | Some(|greeter| { 19 | greeter.greeting = Some("Lorem ipsum dolor sit amet".to_string()); 20 | }), 21 | ) 22 | .await; 23 | 24 | let events = tokio::task::spawn({ 25 | let mut runner = runner.clone(); 26 | 27 | async move { 28 | runner.wait_for_render().await; 29 | 30 | assert!(runner.output().await.contains("Lorem ipsum dolor sit amet")); 31 | } 32 | }); 33 | 34 | runner.join_until_end(events).await; 35 | } 36 | 37 | #[tokio::test] 38 | async fn show_wrapped_greet() { 39 | let opts = SessionOptions { 40 | username: "apognu".to_string(), 41 | password: "password".to_string(), 42 | mfa: false, 43 | }; 44 | 45 | let mut runner = IntegrationRunner::new_with_size( 46 | opts, 47 | Some(|greeter| { 48 | greeter.greeting = Some("Lorem \x1b[31mipsum dolor sit amet".to_string()); 49 | }), 50 | (20, 20), 51 | ) 52 | .await; 53 | 54 | let events = tokio::task::spawn({ 55 | let mut runner = runner.clone(); 56 | 57 | async move { 58 | runner.wait_for_render().await; 59 | 60 | let output = runner.output().await; 61 | 62 | assert!(output.contains("┌ Authenticate into┐")); 63 | assert!(output.contains("│ Lorem ipsum │")); 64 | assert!(output.contains("│ dolor sit amet │")); 65 | assert!(output.contains("└──────────────────┘")); 66 | } 67 | }); 68 | 69 | runner.join_until_end(events).await; 70 | } 71 | 72 | const TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S"; 73 | 74 | // TODO 75 | // This could create a race condition if we do not mock time, because we rely on 76 | // being at the same second between the test instantiation and the tasks 77 | // running, which is not guaranteed. 78 | #[tokio::test] 79 | async fn show_time() { 80 | let opts = SessionOptions { 81 | username: "apognu".to_string(), 82 | password: "password".to_string(), 83 | mfa: false, 84 | }; 85 | 86 | let tref = Local::now().format(&TIME_FORMAT).to_string(); 87 | 88 | let mut runner = IntegrationRunner::new( 89 | opts, 90 | Some(|greeter| { 91 | greeter.time = true; 92 | greeter.time_format = Some(TIME_FORMAT.to_string()); 93 | }), 94 | ) 95 | .await; 96 | 97 | let events = tokio::task::spawn({ 98 | let mut runner = runner.clone(); 99 | 100 | async move { 101 | runner.wait_for_render().await; 102 | 103 | assert!(runner.output().await.contains(&tref)); 104 | 105 | tokio::time::sleep(Duration::from_secs(1)).await; 106 | 107 | runner.wait_for_render().await; 108 | 109 | assert_eq!(runner.output().await.contains(&tref), false); 110 | } 111 | }); 112 | 113 | runner.join_until_end(events).await; 114 | } 115 | -------------------------------------------------------------------------------- /src/ui/common/style.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use tui::style::{Color, Style}; 4 | 5 | #[derive(Clone)] 6 | enum Component { 7 | Bg, 8 | Fg, 9 | } 10 | 11 | pub enum Themed { 12 | Container, 13 | Time, 14 | Text, 15 | Border, 16 | Title, 17 | Greet, 18 | Prompt, 19 | Input, 20 | Action, 21 | ActionButton, 22 | } 23 | 24 | #[derive(Default)] 25 | pub struct Theme { 26 | container: Option<(Component, Color)>, 27 | time: Option<(Component, Color)>, 28 | text: Option<(Component, Color)>, 29 | border: Option<(Component, Color)>, 30 | title: Option<(Component, Color)>, 31 | greet: Option<(Component, Color)>, 32 | prompt: Option<(Component, Color)>, 33 | input: Option<(Component, Color)>, 34 | action: Option<(Component, Color)>, 35 | button: Option<(Component, Color)>, 36 | } 37 | 38 | impl Theme { 39 | pub fn parse(spec: &str) -> Theme { 40 | use Component::*; 41 | 42 | let directives = spec.split(';').filter_map(|directive| directive.split_once('=')); 43 | let mut style = Theme::default(); 44 | 45 | for (key, value) in directives { 46 | if let Ok(color) = Color::from_str(value) { 47 | match key { 48 | "container" => style.container = Some((Bg, color)), 49 | "time" => style.time = Some((Fg, color)), 50 | "text" => style.text = Some((Fg, color)), 51 | "border" => style.border = Some((Fg, color)), 52 | "title" => style.title = Some((Fg, color)), 53 | "greet" => style.greet = Some((Fg, color)), 54 | "prompt" => style.prompt = Some((Fg, color)), 55 | "input" => style.input = Some((Fg, color)), 56 | "action" => style.action = Some((Fg, color)), 57 | "button" => style.button = Some((Fg, color)), 58 | _ => {} 59 | } 60 | } 61 | } 62 | 63 | if style.time.is_none() { 64 | style.time.clone_from(&style.text); 65 | } 66 | if style.greet.is_none() { 67 | style.greet.clone_from(&style.text); 68 | } 69 | if style.title.is_none() { 70 | style.title.clone_from(&style.border); 71 | } 72 | if style.button.is_none() { 73 | style.button.clone_from(&style.action); 74 | } 75 | 76 | style 77 | } 78 | 79 | pub fn of(&self, targets: &[Themed]) -> Style { 80 | targets.iter().fold(Style::default(), |style, target| self.apply(style, target)) 81 | } 82 | 83 | fn apply(&self, style: Style, target: &Themed) -> Style { 84 | use Themed::*; 85 | 86 | let color = match target { 87 | Container => &self.container, 88 | Time => &self.time, 89 | Text => &self.text, 90 | Border => &self.border, 91 | Title => &self.title, 92 | Greet => &self.greet, 93 | Prompt => &self.prompt, 94 | Input => &self.input, 95 | Action => &self.action, 96 | ActionButton => &self.button, 97 | }; 98 | 99 | match color { 100 | Some((component, color)) => match component { 101 | Component::Fg => style.fg(*color), 102 | Component::Bg => style.bg(*color), 103 | }, 104 | 105 | None => style, 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/integration/auth.rs: -------------------------------------------------------------------------------- 1 | use libgreetd_stub::SessionOptions; 2 | 3 | use super::common::IntegrationRunner; 4 | 5 | #[tokio::test] 6 | async fn authentication_ok() { 7 | let opts = SessionOptions { 8 | username: "apognu".to_string(), 9 | password: "password".to_string(), 10 | mfa: false, 11 | }; 12 | 13 | let mut runner = IntegrationRunner::new(opts, None).await; 14 | 15 | let events = tokio::task::spawn({ 16 | let mut runner = runner.clone(); 17 | 18 | async move { 19 | runner.wait_until_buffer_contains("Username:").await; 20 | runner.send_text("apognu").await; 21 | runner.wait_until_buffer_contains("Password:").await; 22 | runner.send_text("password").await; 23 | } 24 | }); 25 | 26 | runner.join_until_client_exit(events).await; 27 | } 28 | 29 | #[tokio::test] 30 | async fn authentication_bad_password() { 31 | let opts = SessionOptions { 32 | username: "apognu".to_string(), 33 | password: "password".to_string(), 34 | mfa: false, 35 | }; 36 | 37 | let mut runner = IntegrationRunner::new(opts, None).await; 38 | 39 | let events = tokio::task::spawn({ 40 | let mut runner = runner.clone(); 41 | 42 | { 43 | async move { 44 | runner.wait_until_buffer_contains("Username:").await; 45 | runner.send_text("apognu").await; 46 | runner.wait_until_buffer_contains("Password:").await; 47 | runner.send_text("password2").await; 48 | runner.wait_for_render().await; 49 | 50 | assert!(runner.output().await.contains("Authentication failed")); 51 | } 52 | } 53 | }); 54 | 55 | runner.join_until_end(events).await; 56 | } 57 | 58 | #[tokio::test] 59 | async fn authentication_ok_mfa() { 60 | let opts = SessionOptions { 61 | username: "apognu".to_string(), 62 | password: "password".to_string(), 63 | mfa: true, 64 | }; 65 | 66 | let mut runner = IntegrationRunner::new(opts, None).await; 67 | 68 | let events = tokio::task::spawn({ 69 | let mut runner = runner.clone(); 70 | 71 | async move { 72 | runner.wait_until_buffer_contains("Username:").await; 73 | runner.send_text("apognu").await; 74 | runner.wait_until_buffer_contains("Password:").await; 75 | runner.send_text("password").await; 76 | runner.wait_until_buffer_contains("7 + 2 =").await; 77 | runner.send_text("9").await; 78 | } 79 | }); 80 | 81 | runner.join_until_client_exit(events).await; 82 | } 83 | 84 | #[tokio::test] 85 | async fn authentication_bad_mfa() { 86 | let opts = SessionOptions { 87 | username: "apognu".to_string(), 88 | password: "password".to_string(), 89 | mfa: true, 90 | }; 91 | 92 | let mut runner = IntegrationRunner::new(opts, None).await; 93 | 94 | let events = tokio::task::spawn({ 95 | let mut runner = runner.clone(); 96 | 97 | async move { 98 | runner.wait_until_buffer_contains("Username:").await; 99 | runner.send_text("apognu").await; 100 | runner.wait_until_buffer_contains("Password:").await; 101 | runner.send_text("password").await; 102 | runner.wait_until_buffer_contains("7 + 2 = ").await; 103 | runner.send_text("10").await; 104 | runner.wait_for_render().await; 105 | 106 | assert!(runner.output().await.contains("Authentication failed")); 107 | assert!(runner.output().await.contains("Password:")); 108 | } 109 | }); 110 | 111 | runner.join_until_end(events).await; 112 | } 113 | -------------------------------------------------------------------------------- /contrib/man/tuigreet-1.scd: -------------------------------------------------------------------------------- 1 | tuigreet(1) 2 | 3 | # NAME 4 | 5 | tuigreet - A graphical console greeter for greetd 6 | 7 | # SYNOPSIS 8 | 9 | *tuigreet* [OPTIONS]... 10 | 11 | # OPTIONS 12 | 13 | *-h, --help* 14 | Show usage and exit. 15 | 16 | *-v, --version* 17 | Print program version and exit. 18 | 19 | *-d, --debug [FILE]* 20 | Enables debug logging to the provided FILE path, or to /tmp/tuigreet.log if no 21 | file is specified. 22 | 23 | *-c, --cmd CMD* 24 | Specify which command to run on successful authentication. This can be 25 | overridden by manual selection within *tuigreet*. 26 | 27 | *--env KEY=VALUE* 28 | Environment variables to run the default session with (can appear more then once). 29 | 30 | *-s, --sessions DIR1[:DIR2]...* 31 | Location of desktop-files to be used as Wayland session definitions. By 32 | default, Wayland sessions are fetched from */usr/share/wayland-sessions*. 33 | 34 | *--session-wrapper 'CMD [ARGS]...'* 35 | Specify a wrapper command to execute instead of the session for non-X11 36 | sessions. This command will receive the session command as its arguments. 37 | 38 | *-x, --xsessions DIR1[:DIR2]...* 39 | Location of desktop-files to be used as X11 session definitions. By 40 | default, X11 sessions are fetched from */usr/share/xsessions*. 41 | 42 | *--xsession-wrapper 'CMD [ARGS]...'* 43 | Specify a wrapper command to initialize X server and launch X11 sessions. 44 | By default, *startx /usr/bin/env* will be prepended to all X11 session 45 | commands. 46 | 47 | *--no-xsession-wrapper* 48 | Do not wrap commands for X11 sessions. 49 | 50 | *-w, --width COLS* 51 | Number of columns the main prompt area should take on the screen. 52 | 53 | *-i, --issue* 54 | Print the content of */etc/issue* at the top of the prompt area. 55 | 56 | This option is mutually exclusive with *--greeting*. 57 | 58 | *-g, --greeting GREETING* 59 | Specify the text to be displayed at the top of the prompt area. 60 | 61 | This option is mutually exclusive with *--issue*. 62 | 63 | *-t, --time* 64 | Print the current date and time at the top of the screen. 65 | 66 | *--time-format FORMAT* 67 | Configure a custom strftime-compliant format string for the current date 68 | and time. 69 | 70 | *--user-menu* 71 | Allow selecting a user from a graphical menu. 72 | 73 | *--user-menu-min-uid* 74 | Minimum UID of the users to display in the selection menu. 75 | 76 | *--user-menu-max-uid* 77 | Maximum UID of the users to display in the selection menu. 78 | 79 | *-r, --remember* 80 | Remember the username of the last successfully opened session, so the 81 | username field will be pre-filled on the next run. 82 | 83 | *--remember-session* 84 | Remember the last selected session, effectively overriding the given *--cmd* 85 | option on subsequent runs. 86 | 87 | *--remember-user-session* 88 | Remember the last opened session, per user (requires *--remember*). 89 | 90 | *--theme SPEC* 91 | Define colors to be used to draw the UI components. You can find the proper 92 | syntax in the project's README. 93 | 94 | *--asterisks* 95 | Add visual feedback when typing secrets, as one asterisk character for every 96 | keystroke. By default, no feedback is given at all. 97 | 98 | *--asterisks-char CHARS* 99 | Change the default feedback character from an asterisk to a random 100 | distribution of the provided characters. 101 | 102 | *--window-padding COLS* 103 | Add spacing between the edge of the screen area the drawing area. 104 | 105 | *--container-padding COLS* 106 | Add spacing between the border of the main prompt area and its contents. 107 | 108 | *--prompt-padding ROWS* 109 | Add spacing between form fields. 110 | 111 | *--greet-align [left|center|right]* 112 | Alignment of the greeting text in the main prompt container 113 | (default: 'center'). 114 | 115 | *--power-shutdown CMD [ARGS]...* 116 | Customize the command run when instructed to shut down the machine. This must 117 | be a non-interactive command (sudo cannot prompt for a password, for example). 118 | 119 | *--power-reboot CMD [ARGS]...* 120 | Customize the command run when instructed to reboot the machine. This must be 121 | a non-interactive command (sudo cannot prompt for a password, for example). 122 | 123 | *--power-no-setsid* 124 | Do not prefix power commands with *setsid*, which is used to detach it from 125 | current TTY. 126 | 127 | *--kb-[command|sessions|power] [1-12]* 128 | change the default F-key keybindings to access the command, sessions and power 129 | menus. 130 | 131 | # AUTHORS 132 | 133 | Maintained by Antoine POPINEAU . 134 | 135 | Contributed to by great people at 136 | https://github.com/apognu/tuigreet/graphs/contributors. 137 | 138 | # DEVELOPMENT 139 | 140 | Issue reporting and development discussion should happen at 141 | https://github.com/apognu/tuigreet. 142 | 143 | # SEE ALSO 144 | 145 | *greetd*(1) 146 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate smart_default; 3 | 4 | #[macro_use] 5 | mod macros; 6 | 7 | mod event; 8 | mod greeter; 9 | mod info; 10 | mod ipc; 11 | mod keyboard; 12 | mod power; 13 | mod ui; 14 | 15 | #[cfg(test)] 16 | mod integration; 17 | 18 | use std::{error::Error, fs::OpenOptions, io, process, sync::Arc}; 19 | 20 | use crossterm::{ 21 | execute, 22 | terminal::{disable_raw_mode, LeaveAlternateScreen}, 23 | }; 24 | use event::Event; 25 | use greetd_ipc::Request; 26 | use power::PowerPostAction; 27 | use tokio::sync::RwLock; 28 | use tracing_appender::non_blocking::WorkerGuard; 29 | use tui::{backend::CrosstermBackend, Terminal}; 30 | 31 | #[cfg(not(test))] 32 | use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen}; 33 | 34 | pub use self::greeter::*; 35 | use self::{event::Events, ipc::Ipc}; 36 | 37 | #[tokio::main] 38 | async fn main() { 39 | let backend = CrosstermBackend::new(io::stdout()); 40 | let events = Events::new().await; 41 | let greeter = Greeter::new(events.sender()).await; 42 | 43 | if let Err(error) = run(backend, greeter, events).await { 44 | if let Some(AuthStatus::Success) = error.downcast_ref::() { 45 | return; 46 | } 47 | 48 | process::exit(1); 49 | } 50 | } 51 | 52 | async fn run(backend: B, mut greeter: Greeter, mut events: Events) -> Result<(), Box> 53 | where 54 | B: tui::backend::Backend, 55 | { 56 | tracing::info!("tuigreet started"); 57 | 58 | register_panic_handler(); 59 | 60 | #[cfg(not(test))] 61 | { 62 | enable_raw_mode()?; 63 | execute!(io::stdout(), EnterAlternateScreen)?; 64 | } 65 | 66 | let mut terminal = Terminal::new(backend)?; 67 | 68 | #[cfg(not(test))] 69 | terminal.clear()?; 70 | 71 | let ipc = Ipc::new(); 72 | 73 | if greeter.remember && !greeter.username.value.is_empty() { 74 | greeter.working = true; 75 | 76 | tracing::info!("creating remembered session for user {}", greeter.username.value); 77 | 78 | ipc 79 | .send(Request::CreateSession { 80 | username: greeter.username.value.clone(), 81 | }) 82 | .await; 83 | } 84 | 85 | let greeter = Arc::new(RwLock::new(greeter)); 86 | 87 | tokio::task::spawn({ 88 | let greeter = greeter.clone(); 89 | let mut ipc = ipc.clone(); 90 | 91 | async move { 92 | loop { 93 | let _ = ipc.handle(greeter.clone()).await; 94 | } 95 | } 96 | }); 97 | 98 | loop { 99 | if let Some(status) = greeter.read().await.exit { 100 | tracing::info!("exiting main loop"); 101 | 102 | return Err(status.into()); 103 | } 104 | 105 | match events.next().await { 106 | Some(Event::Render) => ui::draw(greeter.clone(), &mut terminal).await?, 107 | Some(Event::Key(key)) => keyboard::handle(greeter.clone(), key, ipc.clone()).await?, 108 | 109 | Some(Event::Exit(status)) => { 110 | crate::exit(&mut *greeter.write().await, status).await; 111 | } 112 | 113 | Some(Event::PowerCommand(command)) => { 114 | if let PowerPostAction::ClearScreen = power::run(&greeter, command).await { 115 | execute!(io::stdout(), LeaveAlternateScreen)?; 116 | terminal.set_cursor(1, 1)?; 117 | terminal.clear()?; 118 | disable_raw_mode()?; 119 | 120 | break; 121 | } 122 | } 123 | 124 | _ => {} 125 | } 126 | } 127 | 128 | Ok(()) 129 | } 130 | 131 | async fn exit(greeter: &mut Greeter, status: AuthStatus) { 132 | tracing::info!("preparing exit with status {}", status); 133 | 134 | match status { 135 | AuthStatus::Success => {} 136 | AuthStatus::Cancel | AuthStatus::Failure => Ipc::cancel(greeter).await, 137 | } 138 | 139 | #[cfg(not(test))] 140 | clear_screen(); 141 | 142 | let _ = execute!(io::stdout(), LeaveAlternateScreen); 143 | let _ = disable_raw_mode(); 144 | 145 | greeter.exit = Some(status); 146 | } 147 | 148 | fn register_panic_handler() { 149 | let hook = std::panic::take_hook(); 150 | 151 | std::panic::set_hook(Box::new(move |info| { 152 | #[cfg(not(test))] 153 | clear_screen(); 154 | 155 | let _ = execute!(io::stdout(), LeaveAlternateScreen); 156 | let _ = disable_raw_mode(); 157 | 158 | hook(info); 159 | })); 160 | } 161 | 162 | #[cfg(not(test))] 163 | pub fn clear_screen() { 164 | let backend = CrosstermBackend::new(io::stdout()); 165 | 166 | if let Ok(mut terminal) = Terminal::new(backend) { 167 | let _ = terminal.hide_cursor(); 168 | let _ = terminal.clear(); 169 | } 170 | } 171 | 172 | fn init_logger(greeter: &Greeter) -> Option { 173 | use tracing_subscriber::filter::{LevelFilter, Targets}; 174 | use tracing_subscriber::prelude::*; 175 | 176 | let logfile = OpenOptions::new().write(true).create(true).append(true).clone(); 177 | 178 | match (greeter.debug, logfile.open(&greeter.logfile)) { 179 | (true, Ok(file)) => { 180 | let (appender, guard) = tracing_appender::non_blocking(file); 181 | let target = Targets::new().with_target("tuigreet", LevelFilter::DEBUG); 182 | 183 | tracing_subscriber::registry() 184 | .with(tracing_subscriber::fmt::layer().with_writer(appender).with_line_number(true)) 185 | .with(target) 186 | .init(); 187 | 188 | Some(guard) 189 | } 190 | 191 | _ => None, 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/integration/common/mod.rs: -------------------------------------------------------------------------------- 1 | mod backend; 2 | mod output; 3 | 4 | use std::{ 5 | panic, 6 | sync::{Arc, Mutex}, 7 | time::Duration, 8 | }; 9 | 10 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 11 | use libgreetd_stub::SessionOptions; 12 | use tempfile::NamedTempFile; 13 | use tokio::{ 14 | sync::{ 15 | mpsc::{Receiver, Sender}, 16 | RwLock, 17 | }, 18 | task::{JoinError, JoinHandle}, 19 | }; 20 | use tui::buffer::Buffer; 21 | 22 | use crate::{ 23 | event::{Event, Events}, 24 | ui::sessions::SessionSource, 25 | Greeter, 26 | }; 27 | 28 | pub(super) use self::{ 29 | backend::{output, TestBackend}, 30 | output::*, 31 | }; 32 | 33 | pub(super) struct IntegrationRunner(Arc>); 34 | 35 | struct _IntegrationRunner { 36 | server: Option>, 37 | client: Option>, 38 | 39 | pub buffer: Arc>, 40 | pub sender: Sender, 41 | pub tick: Receiver, 42 | } 43 | 44 | impl Clone for IntegrationRunner { 45 | fn clone(&self) -> Self { 46 | IntegrationRunner(Arc::clone(&self.0)) 47 | } 48 | } 49 | 50 | impl IntegrationRunner { 51 | pub async fn new(opts: SessionOptions, builder: Option) -> IntegrationRunner { 52 | IntegrationRunner::new_with_size(opts, builder, (200, 40)).await 53 | } 54 | 55 | pub async fn new_with_size(opts: SessionOptions, builder: Option, size: (u16, u16)) -> IntegrationRunner { 56 | let socket = NamedTempFile::new().unwrap().into_temp_path().to_path_buf(); 57 | 58 | let (backend, buffer, tick) = TestBackend::new(size.0, size.1); 59 | let events = Events::new().await; 60 | let sender = events.sender(); 61 | 62 | let server = tokio::task::spawn({ 63 | let socket = socket.clone(); 64 | 65 | async move { 66 | libgreetd_stub::start(&socket, &opts).await; 67 | } 68 | }); 69 | 70 | let client = tokio::task::spawn(async move { 71 | let mut greeter = Greeter::new(events.sender()).await; 72 | greeter.session_source = SessionSource::Command("uname".to_string()); 73 | 74 | if let Some(builder) = builder { 75 | builder(&mut greeter); 76 | } 77 | 78 | if greeter.config.is_none() { 79 | greeter.config = Greeter::options().parse(&[""]).ok(); 80 | } 81 | 82 | greeter.logfile = "/tmp/tuigreet.log".to_string(); 83 | greeter.socket = socket.to_str().unwrap().to_string(); 84 | greeter.events = Some(events.sender()); 85 | greeter.connect().await; 86 | 87 | let _ = crate::run(backend, greeter, events).await; 88 | }); 89 | 90 | IntegrationRunner(Arc::new(RwLock::new(_IntegrationRunner { 91 | server: Some(server), 92 | client: Some(client), 93 | buffer, 94 | sender, 95 | tick, 96 | }))) 97 | } 98 | 99 | pub async fn join_until_client_exit(&mut self, mut events: JoinHandle<()>) { 100 | let (mut server, mut client) = { 101 | let mut runner = self.0.write().await; 102 | 103 | (runner.server.take().unwrap(), runner.client.take().unwrap()) 104 | }; 105 | 106 | let mut exited = false; 107 | 108 | while !exited { 109 | tokio::select! { 110 | _ = tokio::time::sleep(Duration::from_secs(5)) => break, 111 | _ = (&mut server) => {} 112 | _ = (&mut client) => { exited = true; }, 113 | ret = (&mut events), if !events.is_finished() => rethrow(ret), 114 | } 115 | } 116 | 117 | assert!(exited, "tuigreet did not exit"); 118 | } 119 | 120 | pub async fn join_until_end(&mut self, events: JoinHandle<()>) { 121 | let (server, client) = { 122 | let mut runner = self.0.write().await; 123 | 124 | (runner.server.take().unwrap(), runner.client.take().unwrap()) 125 | }; 126 | 127 | tokio::select! { 128 | _ = tokio::time::sleep(Duration::from_secs(5)) => {}, 129 | _ = server => {} 130 | _ = client => {}, 131 | ret = events => rethrow(ret), 132 | } 133 | } 134 | 135 | #[allow(unused)] 136 | pub async fn wait_until_buffer_contains(&mut self, needle: &str) { 137 | loop { 138 | if output(&self.0.read().await.buffer).contains(needle) { 139 | return; 140 | } 141 | 142 | self.wait_for_render().await; 143 | } 144 | } 145 | 146 | #[allow(unused, unused_must_use)] 147 | pub async fn send_key(&self, key: KeyCode) { 148 | self.0.write().await.sender.send(Event::Key(KeyEvent::new(key, KeyModifiers::empty()))).await; 149 | } 150 | 151 | #[allow(unused, unused_must_use)] 152 | pub async fn send_modified_key(&self, key: KeyCode, modifiers: KeyModifiers) { 153 | self.0.write().await.sender.send(Event::Key(KeyEvent::new(key, modifiers))).await; 154 | } 155 | 156 | #[allow(unused, unused_must_use)] 157 | pub async fn send_text(&self, text: &str) { 158 | for char in text.chars() { 159 | self.0.write().await.sender.send(Event::Key(KeyEvent::new(KeyCode::Char(char), KeyModifiers::empty()))).await; 160 | } 161 | 162 | self.0.write().await.sender.send(Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))).await; 163 | } 164 | 165 | #[allow(unused)] 166 | pub async fn wait_for_render(&mut self) { 167 | self.0.write().await.tick.recv().await; 168 | } 169 | 170 | pub async fn output(&self) -> Output { 171 | Output(output(&self.0.read().await.buffer)) 172 | } 173 | } 174 | 175 | fn rethrow(result: Result<(), JoinError>) { 176 | if let Err(err) = result { 177 | if let Ok(panick) = err.try_into_panic() { 178 | panic::resume_unwind(panick); 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/ui/prompt.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use rand::{prelude::StdRng, Rng, SeedableRng}; 4 | use tui::{ 5 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 6 | text::Span, 7 | widgets::{Block, BorderType, Borders, Paragraph}, 8 | }; 9 | 10 | use crate::{ 11 | info::get_hostname, 12 | ui::{prompt_value, util::*, Frame}, 13 | GreetAlign, Greeter, Mode, SecretDisplay, 14 | }; 15 | 16 | use super::common::style::Themed; 17 | 18 | const GREETING_INDEX: usize = 0; 19 | const USERNAME_INDEX: usize = 1; 20 | const ANSWER_INDEX: usize = 3; 21 | 22 | pub fn draw(greeter: &mut Greeter, f: &mut Frame) -> Result<(u16, u16), Box> { 23 | let theme = &greeter.theme; 24 | 25 | let size = f.size(); 26 | let (x, y, width, height) = get_rect_bounds(greeter, size, 0); 27 | 28 | let container_padding = greeter.container_padding(); 29 | let prompt_padding = greeter.prompt_padding(); 30 | let greeting_alignment = match greeter.greet_align() { 31 | GreetAlign::Center => Alignment::Center, 32 | GreetAlign::Left => Alignment::Left, 33 | GreetAlign::Right => Alignment::Right, 34 | }; 35 | 36 | let container = Rect::new(x, y, width, height); 37 | let frame = Rect::new(x + container_padding, y + container_padding, width - (2 * container_padding), height - (2 * container_padding)); 38 | 39 | let hostname = Span::from(titleize(&fl!("title_authenticate", hostname = get_hostname()))); 40 | let block = Block::default() 41 | .title(hostname) 42 | .title_style(theme.of(&[Themed::Title])) 43 | .style(theme.of(&[Themed::Container])) 44 | .borders(Borders::ALL) 45 | .border_type(BorderType::Plain) 46 | .border_style(theme.of(&[Themed::Border])); 47 | 48 | f.render_widget(block, container); 49 | 50 | let (message, message_height) = get_message_height(greeter, container_padding, 1); 51 | let (greeting, greeting_height) = get_greeting_height(greeter, container_padding, 0); 52 | 53 | let should_display_answer = greeter.mode == Mode::Password; 54 | 55 | let constraints = [ 56 | Constraint::Length(greeting_height), // Greeting 57 | Constraint::Length(1), // Username 58 | Constraint::Length(if should_display_answer { prompt_padding } else { 0 }), // Prompt padding 59 | Constraint::Length(if should_display_answer { 1 } else { 0 }), // Answer 60 | ]; 61 | 62 | let chunks = Layout::default().direction(Direction::Vertical).constraints(constraints.as_ref()).split(frame); 63 | let cursor = chunks[USERNAME_INDEX]; 64 | 65 | if let Some(greeting) = greeting { 66 | let greeting_label = greeting.alignment(greeting_alignment).style(theme.of(&[Themed::Greet])); 67 | 68 | f.render_widget(greeting_label, chunks[GREETING_INDEX]); 69 | } 70 | 71 | let username_label = if greeter.user_menu && greeter.username.value.is_empty() { 72 | let prompt_text = Span::from(fl!("select_user")); 73 | 74 | Paragraph::new(prompt_text).alignment(Alignment::Center) 75 | } else { 76 | let username_text = prompt_value(theme, Some(fl!("username"))); 77 | 78 | Paragraph::new(username_text) 79 | }; 80 | 81 | let username = greeter.username.get(); 82 | let username_value_text = Span::from(username); 83 | let username_value = Paragraph::new(username_value_text).style(theme.of(&[Themed::Input])); 84 | 85 | match greeter.mode { 86 | Mode::Username | Mode::Password | Mode::Action => { 87 | f.render_widget(username_label, chunks[USERNAME_INDEX]); 88 | 89 | if !greeter.user_menu || !greeter.username.value.is_empty() { 90 | f.render_widget( 91 | username_value, 92 | Rect::new( 93 | 1 + chunks[USERNAME_INDEX].x + fl!("username").chars().count() as u16, 94 | chunks[USERNAME_INDEX].y, 95 | get_input_width(greeter, width, &Some(fl!("username"))), 96 | 1, 97 | ), 98 | ); 99 | } 100 | 101 | let answer_text = if greeter.working { Span::from(fl!("wait")) } else { prompt_value(theme, greeter.prompt.as_ref()) }; 102 | 103 | let answer_label = Paragraph::new(answer_text); 104 | 105 | if greeter.mode == Mode::Password || greeter.previous_mode == Mode::Password { 106 | f.render_widget(answer_label, chunks[ANSWER_INDEX]); 107 | 108 | if !greeter.asking_for_secret || greeter.secret_display.show() { 109 | let value = match (greeter.asking_for_secret, &greeter.secret_display) { 110 | (true, SecretDisplay::Character(pool)) => { 111 | if pool.chars().count() == 1 { 112 | pool.repeat(greeter.buffer.chars().count()) 113 | } else { 114 | let mut rng = StdRng::seed_from_u64(0); 115 | 116 | greeter.buffer.chars().map(|_| pool.chars().nth(rng.gen_range(0..pool.chars().count())).unwrap()).collect() 117 | } 118 | } 119 | 120 | _ => greeter.buffer.clone(), 121 | }; 122 | 123 | let answer_value_text = Span::from(value); 124 | let answer_value = Paragraph::new(answer_value_text).style(theme.of(&[Themed::Input])); 125 | 126 | f.render_widget( 127 | answer_value, 128 | Rect::new( 129 | chunks[ANSWER_INDEX].x + greeter.prompt_width() as u16, 130 | chunks[ANSWER_INDEX].y, 131 | get_input_width(greeter, width, &greeter.prompt), 132 | 1, 133 | ), 134 | ); 135 | } 136 | } 137 | 138 | if let Some(message) = message { 139 | let message = message.alignment(Alignment::Center); 140 | 141 | f.render_widget(message, Rect::new(x, y + height, width, message_height)); 142 | } 143 | } 144 | 145 | _ => {} 146 | } 147 | 148 | match greeter.mode { 149 | Mode::Username => { 150 | let username_length = greeter.username.get().chars().count(); 151 | let offset = get_cursor_offset(greeter, username_length); 152 | 153 | Ok((2 + cursor.x + fl!("username").chars().count() as u16 + offset as u16, USERNAME_INDEX as u16 + cursor.y)) 154 | } 155 | 156 | Mode::Password => { 157 | let answer_length = greeter.buffer.chars().count(); 158 | let offset = get_cursor_offset(greeter, answer_length); 159 | 160 | if greeter.asking_for_secret && !greeter.secret_display.show() { 161 | Ok((1 + cursor.x + greeter.prompt_width() as u16, ANSWER_INDEX as u16 + prompt_padding + cursor.y - 1)) 162 | } else { 163 | Ok((1 + cursor.x + greeter.prompt_width() as u16 + offset as u16, ANSWER_INDEX as u16 + prompt_padding + cursor.y - 1)) 164 | } 165 | } 166 | 167 | _ => Ok((1, 1)), 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod command; 2 | pub mod common; 3 | mod i18n; 4 | pub mod power; 5 | mod processing; 6 | mod prompt; 7 | pub mod sessions; 8 | pub mod users; 9 | mod util; 10 | 11 | use std::{ 12 | borrow::Cow, 13 | error::Error, 14 | io::{self, Write}, 15 | sync::Arc, 16 | }; 17 | 18 | use chrono::prelude::*; 19 | use sessions::SessionSource; 20 | use tokio::sync::RwLock; 21 | use tui::{ 22 | layout::{Alignment, Constraint, Direction, Layout}, 23 | style::Modifier, 24 | text::{Line, Span}, 25 | widgets::Paragraph, 26 | Frame as CrosstermFrame, Terminal, 27 | }; 28 | use util::buttonize; 29 | 30 | use crate::{info::capslock_status, ui::util::should_hide_cursor, Greeter, Mode}; 31 | 32 | use self::common::style::{Theme, Themed}; 33 | pub use self::i18n::MESSAGES; 34 | 35 | const TITLEBAR_INDEX: usize = 1; 36 | const STATUSBAR_INDEX: usize = 3; 37 | const STATUSBAR_LEFT_INDEX: usize = 1; 38 | const STATUSBAR_RIGHT_INDEX: usize = 2; 39 | 40 | pub(super) type Frame<'a> = CrosstermFrame<'a>; 41 | 42 | enum Button { 43 | Command, 44 | Session, 45 | Power, 46 | Other, 47 | } 48 | 49 | pub async fn draw(greeter: Arc>, terminal: &mut Terminal) -> Result<(), Box> 50 | where 51 | B: tui::backend::Backend, 52 | { 53 | let mut greeter = greeter.write().await; 54 | let hide_cursor = should_hide_cursor(&greeter); 55 | 56 | terminal.draw(|f| { 57 | let theme = &greeter.theme; 58 | 59 | let size = f.size(); 60 | let chunks = Layout::default() 61 | .constraints( 62 | [ 63 | Constraint::Length(greeter.window_padding()), // Top vertical padding 64 | Constraint::Length(1), // Date and time 65 | Constraint::Min(1), // Main area 66 | Constraint::Length(1), // Status line 67 | Constraint::Length(greeter.window_padding()), // Bottom vertical padding 68 | ] 69 | .as_ref(), 70 | ) 71 | .split(size); 72 | 73 | if greeter.time { 74 | let time_text = Span::from(get_time(&greeter)); 75 | let time = Paragraph::new(time_text).alignment(Alignment::Center).style(theme.of(&[Themed::Time])); 76 | 77 | f.render_widget(time, chunks[TITLEBAR_INDEX]); 78 | } 79 | 80 | let status_block_size_right = 1 + greeter.window_padding() + fl!("status_caps").chars().count() as u16; 81 | let status_block_size_left = (size.width - greeter.window_padding()) - status_block_size_right; 82 | 83 | let status_chunks = Layout::default() 84 | .direction(Direction::Horizontal) 85 | .constraints( 86 | [ 87 | Constraint::Length(greeter.window_padding()), 88 | Constraint::Length(status_block_size_left), 89 | Constraint::Length(status_block_size_right), 90 | Constraint::Length(greeter.window_padding()), 91 | ] 92 | .as_ref(), 93 | ) 94 | .split(chunks[STATUSBAR_INDEX]); 95 | 96 | let session_source_label = match greeter.session_source { 97 | SessionSource::Session(_) => fl!("status_session"), 98 | _ => fl!("status_command"), 99 | }; 100 | 101 | let session_source = greeter.session_source.label(&greeter).unwrap_or("-"); 102 | 103 | let status_left_text = Line::from(vec![ 104 | status_label(theme, "ESC"), 105 | status_value(&greeter, theme, Button::Other, fl!("action_reset")), 106 | Span::from(" "), 107 | status_label(theme, format!("F{}", greeter.kb_command)), 108 | status_value(&greeter, theme, Button::Command, fl!("action_command")), 109 | Span::from(" "), 110 | status_label(theme, format!("F{}", greeter.kb_sessions)), 111 | status_value(&greeter, theme, Button::Session, fl!("action_session")), 112 | Span::from(" "), 113 | status_label(theme, format!("F{}", greeter.kb_power)), 114 | status_value(&greeter, theme, Button::Power, fl!("action_power")), 115 | Span::from(" "), 116 | status_label(theme, session_source_label), 117 | status_value(&greeter, theme, Button::Other, session_source), 118 | ]); 119 | let status_left = Paragraph::new(status_left_text); 120 | 121 | f.render_widget(status_left, status_chunks[STATUSBAR_LEFT_INDEX]); 122 | 123 | if capslock_status() { 124 | let status_right_text = status_label(theme, fl!("status_caps")); 125 | let status_right = Paragraph::new(status_right_text).alignment(Alignment::Right); 126 | 127 | f.render_widget(status_right, status_chunks[STATUSBAR_RIGHT_INDEX]); 128 | } 129 | 130 | let cursor = match greeter.mode { 131 | Mode::Command => self::command::draw(&mut greeter, f).ok(), 132 | Mode::Sessions => greeter.sessions.draw(&greeter, f).ok(), 133 | Mode::Power => greeter.powers.draw(&greeter, f).ok(), 134 | Mode::Users => greeter.users.draw(&greeter, f).ok(), 135 | Mode::Processing => self::processing::draw(&mut greeter, f).ok(), 136 | _ => self::prompt::draw(&mut greeter, f).ok(), 137 | }; 138 | 139 | if !hide_cursor { 140 | if let Some(cursor) = cursor { 141 | f.set_cursor(cursor.0 - 1, cursor.1 - 1); 142 | } 143 | } 144 | })?; 145 | 146 | io::stdout().flush()?; 147 | 148 | Ok(()) 149 | } 150 | 151 | fn get_time(greeter: &Greeter) -> String { 152 | let format = match &greeter.time_format { 153 | Some(format) => Cow::Borrowed(format), 154 | None => Cow::Owned(fl!("date")), 155 | }; 156 | 157 | Local::now().format_localized(&format, greeter.locale).to_string() 158 | } 159 | 160 | fn status_label<'s, S>(theme: &Theme, text: S) -> Span<'s> 161 | where 162 | S: Into, 163 | { 164 | Span::styled(text.into(), theme.of(&[Themed::ActionButton]).add_modifier(Modifier::REVERSED)) 165 | } 166 | 167 | fn status_value<'s, S>(greeter: &Greeter, theme: &Theme, button: Button, text: S) -> Span<'s> 168 | where 169 | S: Into, 170 | { 171 | let relevant_mode = match button { 172 | Button::Command => Mode::Command, 173 | Button::Session => Mode::Sessions, 174 | Button::Power => Mode::Power, 175 | 176 | _ => { 177 | return Span::from(buttonize(&text.into())).style(theme.of(&[Themed::Action])); 178 | } 179 | }; 180 | 181 | let style = match greeter.mode == relevant_mode { 182 | true => theme.of(&[Themed::ActionButton]).add_modifier(Modifier::REVERSED), 183 | false => theme.of(&[Themed::Action]), 184 | }; 185 | 186 | Span::from(buttonize(&text.into())).style(style) 187 | } 188 | 189 | fn prompt_value<'s, S>(theme: &Theme, text: Option) -> Span<'s> 190 | where 191 | S: Into, 192 | { 193 | match text { 194 | Some(text) => Span::styled(text.into(), theme.of(&[Themed::Prompt]).add_modifier(Modifier::BOLD)), 195 | None => Span::from(""), 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/integration/common/backend.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_must_use)] 2 | 3 | /* 4 | Copied and adapted from the codebase of ratatui. 5 | 6 | Repository: https://github.com/ratatui-org/ratatui 7 | License: https://github.com/ratatui-org/ratatui/blob/main/LICENSE 8 | File: https://github.com/ratatui-org/ratatui/blob/f4637d40c35e068fd60d17c9a42b9114667c9861/src/backend/test.rs 9 | 10 | The MIT License (MIT) 11 | 12 | Copyright (c) 2016-2022 Florian Dehau 13 | Copyright (c) 2023-2024 The Ratatui Developers 14 | 15 | Permission is hereby granted, free of charge, to any person obtaining a copy 16 | of this software and associated documentation files (the "Software"), to deal 17 | in the Software without restriction, including without limitation the rights 18 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 | copies of the Software, and to permit persons to whom the Software is 20 | furnished to do so, subject to the following conditions: 21 | 22 | The above copyright notice and this permission notice shall be included in all 23 | copies or substantial portions of the Software. 24 | 25 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 | SOFTWARE. 32 | */ 33 | use std::{ 34 | fmt::Write, 35 | io, 36 | sync::{Arc, Mutex}, 37 | }; 38 | 39 | use tokio::sync::mpsc; 40 | use unicode_width::UnicodeWidthStr; 41 | 42 | use tui::{ 43 | backend::{Backend, ClearType, WindowSize}, 44 | buffer::{Buffer, Cell}, 45 | layout::{Rect, Size}, 46 | }; 47 | 48 | #[derive(Clone)] 49 | pub struct TestBackend { 50 | tick: mpsc::Sender, 51 | width: u16, 52 | buffer: Arc>, 53 | height: u16, 54 | cursor: bool, 55 | pos: (u16, u16), 56 | } 57 | 58 | pub fn output(buffer: &Arc>) -> String { 59 | let buffer = buffer.lock().unwrap(); 60 | 61 | let mut view = String::with_capacity(buffer.content.len() + buffer.area.height as usize * 3); 62 | for cells in buffer.content.chunks(buffer.area.width as usize) { 63 | let mut overwritten = vec![]; 64 | let mut skip: usize = 0; 65 | for (x, c) in cells.iter().enumerate() { 66 | if skip == 0 { 67 | view.push_str(c.symbol()); 68 | } else { 69 | overwritten.push((x, c.symbol())); 70 | } 71 | skip = std::cmp::max(skip, c.symbol().width()).saturating_sub(1); 72 | } 73 | if !overwritten.is_empty() { 74 | write!(&mut view, " Hidden by multi-width symbols: {overwritten:?}").unwrap(); 75 | } 76 | view.push('\n'); 77 | } 78 | view 79 | } 80 | 81 | impl TestBackend { 82 | pub fn new(width: u16, height: u16) -> (Self, Arc>, mpsc::Receiver) { 83 | let buffer = Arc::new(Mutex::new(Buffer::empty(Rect::new(0, 0, width, height)))); 84 | let (tx, rx) = mpsc::channel::(10); 85 | 86 | let backend = Self { 87 | tick: tx, 88 | width, 89 | height, 90 | buffer: buffer.clone(), 91 | cursor: false, 92 | pos: (0, 0), 93 | }; 94 | 95 | (backend, buffer, rx) 96 | } 97 | } 98 | 99 | impl Backend for TestBackend { 100 | fn draw<'a, I>(&mut self, content: I) -> io::Result<()> 101 | where 102 | I: Iterator, 103 | { 104 | let mut buffer = self.buffer.lock().unwrap(); 105 | 106 | for (x, y, c) in content { 107 | let cell = buffer.get_mut(x, y); 108 | *cell = c.clone(); 109 | } 110 | 111 | let sender = self.tick.clone(); 112 | 113 | std::thread::spawn(move || { 114 | sender.blocking_send(true); 115 | }); 116 | 117 | Ok(()) 118 | } 119 | 120 | fn hide_cursor(&mut self) -> io::Result<()> { 121 | self.cursor = false; 122 | Ok(()) 123 | } 124 | 125 | fn show_cursor(&mut self) -> io::Result<()> { 126 | self.cursor = true; 127 | Ok(()) 128 | } 129 | 130 | fn get_cursor(&mut self) -> io::Result<(u16, u16)> { 131 | Ok(self.pos) 132 | } 133 | 134 | fn set_cursor(&mut self, x: u16, y: u16) -> io::Result<()> { 135 | self.pos = (x, y); 136 | Ok(()) 137 | } 138 | 139 | fn clear(&mut self) -> io::Result<()> { 140 | self.buffer.lock().unwrap().reset(); 141 | Ok(()) 142 | } 143 | 144 | fn clear_region(&mut self, clear_type: tui::backend::ClearType) -> io::Result<()> { 145 | let buffer = self.buffer.clone(); 146 | let mut buffer = buffer.lock().unwrap(); 147 | 148 | match clear_type { 149 | ClearType::All => self.clear()?, 150 | ClearType::AfterCursor => { 151 | let index = buffer.index_of(self.pos.0, self.pos.1) + 1; 152 | buffer.content[index..].fill(Cell::default()); 153 | } 154 | ClearType::BeforeCursor => { 155 | let index = buffer.index_of(self.pos.0, self.pos.1); 156 | buffer.content[..index].fill(Cell::default()); 157 | } 158 | ClearType::CurrentLine => { 159 | let line_start_index = buffer.index_of(0, self.pos.1); 160 | let line_end_index = buffer.index_of(self.width - 1, self.pos.1); 161 | buffer.content[line_start_index..=line_end_index].fill(Cell::default()); 162 | } 163 | ClearType::UntilNewLine => { 164 | let index = buffer.index_of(self.pos.0, self.pos.1); 165 | let line_end_index = buffer.index_of(self.width - 1, self.pos.1); 166 | buffer.content[index..=line_end_index].fill(Cell::default()); 167 | } 168 | } 169 | Ok(()) 170 | } 171 | 172 | fn append_lines(&mut self, n: u16) -> io::Result<()> { 173 | let (cur_x, cur_y) = self.get_cursor()?; 174 | 175 | let new_cursor_x = cur_x.saturating_add(1).min(self.width.saturating_sub(1)); 176 | 177 | let max_y = self.height.saturating_sub(1); 178 | let lines_after_cursor = max_y.saturating_sub(cur_y); 179 | if n > lines_after_cursor { 180 | let rotate_by = n.saturating_sub(lines_after_cursor).min(max_y); 181 | 182 | if rotate_by == self.height - 1 { 183 | self.clear()?; 184 | } 185 | 186 | self.set_cursor(0, rotate_by)?; 187 | self.clear_region(ClearType::BeforeCursor)?; 188 | self.buffer.lock().unwrap().content.rotate_left((self.width * rotate_by).into()); 189 | } 190 | 191 | let new_cursor_y = cur_y.saturating_add(n).min(max_y); 192 | self.set_cursor(new_cursor_x, new_cursor_y)?; 193 | 194 | Ok(()) 195 | } 196 | 197 | fn size(&self) -> io::Result { 198 | Ok(Rect::new(0, 0, self.width, self.height)) 199 | } 200 | 201 | fn window_size(&mut self) -> io::Result { 202 | static WINDOW_PIXEL_SIZE: Size = Size { width: 640, height: 480 }; 203 | Ok(WindowSize { 204 | columns_rows: (self.width, self.height).into(), 205 | pixels: WINDOW_PIXEL_SIZE, 206 | }) 207 | } 208 | 209 | fn flush(&mut self) -> io::Result<()> { 210 | Ok(()) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/integration/menus.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyModifiers}; 2 | use libgreetd_stub::SessionOptions; 3 | 4 | use crate::{ 5 | power::PowerOption, 6 | ui::{common::menu::Menu, power::Power, sessions::Session, users::User}, 7 | }; 8 | 9 | use super::common::IntegrationRunner; 10 | 11 | #[tokio::test] 12 | async fn menus_labels_default() { 13 | let opts = SessionOptions { 14 | username: "apognu".to_string(), 15 | password: "password".to_string(), 16 | mfa: false, 17 | }; 18 | 19 | let mut runner = IntegrationRunner::new(opts, None).await; 20 | 21 | let events = tokio::task::spawn({ 22 | let mut runner = runner.clone(); 23 | 24 | async move { 25 | runner.wait_until_buffer_contains("Username:").await; 26 | 27 | assert!(runner.output().await.contains("F2 Change command")); 28 | assert!(runner.output().await.contains("F3 Choose session")); 29 | assert!(runner.output().await.contains("F12 Power")); 30 | } 31 | }); 32 | 33 | runner.join_until_end(events).await; 34 | } 35 | 36 | #[tokio::test] 37 | async fn menus_labels_with_custom_bindings() { 38 | let opts = SessionOptions { 39 | username: "apognu".to_string(), 40 | password: "password".to_string(), 41 | mfa: false, 42 | }; 43 | 44 | let mut runner = IntegrationRunner::new( 45 | opts, 46 | Some(|greeter| { 47 | greeter.kb_command = 11; 48 | greeter.kb_sessions = 1; 49 | greeter.kb_power = 6; 50 | }), 51 | ) 52 | .await; 53 | 54 | let events = tokio::task::spawn({ 55 | let mut runner = runner.clone(); 56 | 57 | async move { 58 | runner.wait_until_buffer_contains("Username:").await; 59 | 60 | assert!(runner.output().await.contains("F11 Change command")); 61 | assert!(runner.output().await.contains("F1 Choose session")); 62 | assert!(runner.output().await.contains("F6 Power")); 63 | } 64 | }); 65 | 66 | runner.join_until_end(events).await; 67 | } 68 | 69 | #[tokio::test] 70 | async fn change_command() { 71 | let opts = SessionOptions { 72 | username: "apognu".to_string(), 73 | password: "password".to_string(), 74 | mfa: false, 75 | }; 76 | 77 | let mut runner = IntegrationRunner::new(opts, None).await; 78 | 79 | let events = tokio::task::spawn({ 80 | let mut runner = runner.clone(); 81 | 82 | async move { 83 | runner.wait_until_buffer_contains("Username:").await; 84 | runner.send_key(KeyCode::F(3)).await; 85 | runner.wait_for_render().await; 86 | 87 | assert!(runner.output().await.contains("CMD uname")); 88 | 89 | runner.send_key(KeyCode::F(2)).await; 90 | runner.wait_for_render().await; 91 | 92 | assert!(runner.output().await.contains("Change session command")); 93 | assert!(runner.output().await.contains("New command: uname")); 94 | 95 | runner.send_modified_key(KeyCode::Char('u'), KeyModifiers::CONTROL).await; 96 | runner.send_text("mynewcommand").await; 97 | runner.send_key(KeyCode::Enter).await; 98 | runner.wait_for_render().await; 99 | 100 | assert!(runner.output().await.contains("CMD mynewcommand")); 101 | } 102 | }); 103 | 104 | runner.join_until_end(events).await; 105 | } 106 | 107 | #[tokio::test] 108 | async fn session_menu() { 109 | let opts = SessionOptions { 110 | username: "apognu".to_string(), 111 | password: "password".to_string(), 112 | mfa: false, 113 | }; 114 | 115 | let mut runner = IntegrationRunner::new( 116 | opts, 117 | Some(|greeter| { 118 | greeter.sessions = Menu:: { 119 | title: "List of sessions".to_string(), 120 | options: vec![ 121 | Session { 122 | name: "My Session".to_string(), 123 | ..Default::default() 124 | }, 125 | Session { 126 | name: "Second Session".to_string(), 127 | ..Default::default() 128 | }, 129 | ], 130 | selected: 0, 131 | }; 132 | }), 133 | ) 134 | .await; 135 | 136 | let events = tokio::task::spawn({ 137 | let mut runner = runner.clone(); 138 | 139 | async move { 140 | runner.wait_until_buffer_contains("Username:").await; 141 | runner.send_key(KeyCode::F(3)).await; 142 | runner.wait_for_render().await; 143 | 144 | assert!(runner.output().await.contains("List of sessions")); 145 | assert!(runner.output().await.contains("My Session")); 146 | assert!(runner.output().await.contains("Second Session")); 147 | 148 | runner.send_key(KeyCode::Down).await; 149 | runner.send_key(KeyCode::Down).await; 150 | runner.send_key(KeyCode::Enter).await; 151 | runner.wait_for_render().await; 152 | 153 | assert!(runner.output().await.contains("SESS Second Session")); 154 | 155 | runner.send_key(KeyCode::F(3)).await; 156 | runner.wait_for_render().await; 157 | runner.send_key(KeyCode::Up).await; 158 | runner.send_key(KeyCode::Up).await; 159 | runner.send_key(KeyCode::Enter).await; 160 | runner.wait_for_render().await; 161 | 162 | assert!(runner.output().await.contains("SESS My Session")); 163 | } 164 | }); 165 | 166 | runner.join_until_end(events).await; 167 | } 168 | 169 | #[tokio::test] 170 | async fn power_menu() { 171 | let opts = SessionOptions { 172 | username: "apognu".to_string(), 173 | password: "password".to_string(), 174 | mfa: false, 175 | }; 176 | 177 | let mut runner = IntegrationRunner::new( 178 | opts, 179 | Some(|greeter| { 180 | greeter.powers = Menu:: { 181 | title: "What to do?".to_string(), 182 | options: vec![ 183 | Power { 184 | action: PowerOption::Shutdown, 185 | label: "Turn it off".to_string(), 186 | ..Default::default() 187 | }, 188 | Power { 189 | action: PowerOption::Reboot, 190 | label: "And back on again".to_string(), 191 | ..Default::default() 192 | }, 193 | ], 194 | selected: 0, 195 | }; 196 | }), 197 | ) 198 | .await; 199 | 200 | let events = tokio::task::spawn({ 201 | let mut runner = runner.clone(); 202 | 203 | async move { 204 | runner.wait_until_buffer_contains("Username:").await; 205 | runner.send_key(KeyCode::F(12)).await; 206 | runner.wait_for_render().await; 207 | 208 | assert!(runner.output().await.contains("What to do?")); 209 | assert!(runner.output().await.contains("Turn it off")); 210 | assert!(runner.output().await.contains("And back on again")); 211 | } 212 | }); 213 | 214 | runner.join_until_end(events).await; 215 | } 216 | 217 | #[tokio::test] 218 | async fn users_menu() { 219 | let opts = SessionOptions { 220 | username: "apognu".to_string(), 221 | password: "password".to_string(), 222 | mfa: false, 223 | }; 224 | 225 | let mut runner = IntegrationRunner::new( 226 | opts, 227 | Some(|greeter| { 228 | greeter.user_menu = true; 229 | greeter.users = Menu:: { 230 | title: "The users".to_string(), 231 | options: vec![ 232 | User { 233 | username: "apognu".to_string(), 234 | name: Some("Antoine POPINEAU".to_string()), 235 | }, 236 | User { 237 | username: "bob".to_string(), 238 | name: Some("Bob JOE".to_string()), 239 | }, 240 | ], 241 | selected: 0, 242 | } 243 | }), 244 | ) 245 | .await; 246 | 247 | let events = tokio::task::spawn({ 248 | let mut runner = runner.clone(); 249 | 250 | async move { 251 | runner.wait_until_buffer_contains("select a user").await; 252 | 253 | runner.send_key(KeyCode::Enter).await; 254 | runner.wait_for_render().await; 255 | 256 | assert!(runner.output().await.contains("Antoine POPINEAU")); 257 | assert!(runner.output().await.contains("Bob JOE")); 258 | 259 | runner.send_key(KeyCode::Down).await; 260 | runner.send_key(KeyCode::Enter).await; 261 | runner.wait_for_render().await; 262 | 263 | assert!(runner.output().await.contains("Username: Bob JOE")); 264 | assert!(runner.output().await.contains("Password:")); 265 | 266 | runner.send_key(KeyCode::Esc).await; 267 | runner.wait_for_render().await; 268 | 269 | runner.wait_until_buffer_contains("select a user").await; 270 | 271 | runner.send_text("otheruser").await; 272 | runner.wait_for_render().await; 273 | 274 | assert!(runner.output().await.contains("Username: otheruser")); 275 | assert!(runner.output().await.contains("Password:")); 276 | 277 | runner.send_key(KeyCode::Esc).await; 278 | runner.send_key(KeyCode::Enter).await; 279 | runner.send_key(KeyCode::Up).await; 280 | runner.send_key(KeyCode::Enter).await; 281 | runner.wait_for_render().await; 282 | 283 | assert!(runner.output().await.contains("Username: Antoine POPINEAU")); 284 | assert!(runner.output().await.contains("Password:")); 285 | 286 | runner.send_text("password").await; 287 | } 288 | }); 289 | 290 | runner.join_until_client_exit(events).await; 291 | } 292 | -------------------------------------------------------------------------------- /src/ui/sessions.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use crate::Greeter; 7 | 8 | use super::common::menu::MenuItem; 9 | 10 | // SessionSource models the selected session and where it comes from. 11 | // 12 | // A session can either come from a free-form command or an XDG-defined session 13 | // file. Each variant contains a reference to the data required to create a 14 | // session, either the String of the command or the index of the session in the 15 | // session list. 16 | #[derive(SmartDefault)] 17 | pub enum SessionSource { 18 | #[default] 19 | None, 20 | DefaultCommand(String, Option>), 21 | Command(String), 22 | Session(usize), 23 | } 24 | 25 | impl SessionSource { 26 | // Returns a human-readable label for the selected session. 27 | // 28 | // For free-form commands, this is the command itself. For session files, it 29 | // is the value of the `Name` attribute in that file. 30 | pub fn label<'g, 'ss: 'g>(&'ss self, greeter: &'g Greeter) -> Option<&'g str> { 31 | match self { 32 | SessionSource::None => None, 33 | SessionSource::DefaultCommand(command, _) => Some(command), 34 | SessionSource::Command(command) => Some(command), 35 | SessionSource::Session(index) => greeter.sessions.options.get(*index).map(|session| session.name.as_str()), 36 | } 37 | } 38 | 39 | // Returns the command that should be spawned when the selected session is 40 | // started. 41 | pub fn command<'g, 'ss: 'g>(&'ss self, greeter: &'g Greeter) -> Option<&'g str> { 42 | match self { 43 | SessionSource::None => None, 44 | SessionSource::DefaultCommand(command, _) => Some(command.as_str()), 45 | SessionSource::Command(command) => Some(command.as_str()), 46 | SessionSource::Session(index) => greeter.sessions.options.get(*index).map(|session| session.command.as_str()), 47 | } 48 | } 49 | 50 | pub fn env<'g, 'ss: 'g>(&'ss self) -> Option> { 51 | match self { 52 | SessionSource::None => None, 53 | SessionSource::DefaultCommand(_, env) => env.clone(), 54 | SessionSource::Command(_) => None, 55 | SessionSource::Session(_) => None, 56 | } 57 | } 58 | } 59 | 60 | // Represents the XDG type of the selected session. 61 | #[derive(SmartDefault, Debug, Copy, Clone, PartialEq)] 62 | pub enum SessionType { 63 | X11, 64 | Wayland, 65 | Tty, 66 | #[default] 67 | None, 68 | } 69 | 70 | impl SessionType { 71 | // Returns the value that should be set in `XDG_SESSION_TYPE` when the session 72 | // is started. 73 | pub fn as_xdg_session_type(&self) -> &'static str { 74 | match self { 75 | SessionType::X11 => "x11", 76 | SessionType::Wayland => "wayland", 77 | SessionType::Tty => "tty", 78 | SessionType::None => "unspecified", 79 | } 80 | } 81 | } 82 | 83 | // A session, as defined by an XDG session file. 84 | #[derive(SmartDefault, Clone)] 85 | pub struct Session { 86 | // Slug of the session, being the name of the desktop file without its 87 | // extension. 88 | pub slug: Option, 89 | // Human-friendly name for the session, maps to the `Name` attribute. 90 | pub name: String, 91 | // Command used to start the session, maps to the `Exec` attribute. 92 | pub command: String, 93 | // XDG session type for the session, detected from the location of the session 94 | // file. 95 | pub session_type: SessionType, 96 | // Path to the session file. Used to uniquely identify sessions, since names 97 | // and commands can be identital between two different sessions. 98 | pub path: Option, 99 | // Desktop names as defined with the `DesktopNames` desktop file property 100 | pub xdg_desktop_names: Option, 101 | } 102 | 103 | impl MenuItem for Session { 104 | fn format(&self) -> Cow<'_, str> { 105 | Cow::Borrowed(&self.name) 106 | } 107 | } 108 | 109 | impl Session { 110 | // Get a `Session` from the path of a session file. 111 | // 112 | // If the path maps to a valid session file, will return the associated 113 | // session. Otherwise, will return `None`. 114 | pub fn from_path

(greeter: &Greeter, path: P) -> Option<&Session> 115 | where 116 | P: AsRef, 117 | { 118 | greeter.sessions.options.iter().find(|session| session.path.as_deref() == Some(path.as_ref())) 119 | } 120 | 121 | // Retrieves the `Session` that is currently selected. 122 | // 123 | // Note that this does not indicate which menu item is "highlighted", but the 124 | // session that was selected. 125 | pub fn get_selected(greeter: &Greeter) -> Option<&Session> { 126 | match greeter.session_source { 127 | SessionSource::Session(index) => greeter.sessions.options.get(index), 128 | _ => None, 129 | } 130 | } 131 | } 132 | 133 | #[cfg(test)] 134 | mod test { 135 | use crate::{ 136 | ui::{ 137 | common::menu::Menu, 138 | sessions::{Session, SessionSource, SessionType}, 139 | }, 140 | Greeter, 141 | }; 142 | 143 | #[test] 144 | fn from_path_existing() { 145 | let mut greeter = Greeter::default(); 146 | greeter.session_source = SessionSource::Session(1); 147 | 148 | greeter.sessions = Menu:: { 149 | title: "Sessions".into(), 150 | selected: 1, 151 | options: vec![ 152 | Session { 153 | name: "Session1".into(), 154 | command: "Session1Cmd".into(), 155 | session_type: super::SessionType::Wayland, 156 | path: Some("/Session1Path".into()), 157 | ..Default::default() 158 | }, 159 | Session { 160 | name: "Session2".into(), 161 | command: "Session2Cmd".into(), 162 | session_type: super::SessionType::X11, 163 | path: Some("/Session2Path".into()), 164 | ..Default::default() 165 | }, 166 | ], 167 | }; 168 | 169 | let session = Session::from_path(&greeter, "/Session2Path"); 170 | 171 | assert!(matches!(session, Some(_))); 172 | assert_eq!(session.unwrap().name, "Session2"); 173 | assert_eq!(session.unwrap().session_type, SessionType::X11); 174 | } 175 | 176 | #[test] 177 | fn from_path_non_existing() { 178 | let mut greeter = Greeter::default(); 179 | greeter.session_source = SessionSource::Session(1); 180 | 181 | greeter.sessions = Menu:: { 182 | title: "Sessions".into(), 183 | selected: 1, 184 | options: vec![Session { 185 | name: "Session1".into(), 186 | command: "Session1Cmd".into(), 187 | session_type: super::SessionType::Wayland, 188 | path: Some("/Session1Path".into()), 189 | ..Default::default() 190 | }], 191 | }; 192 | 193 | let session = Session::from_path(&greeter, "/Session2Path"); 194 | 195 | assert!(matches!(session, None)); 196 | } 197 | 198 | #[test] 199 | fn no_session() { 200 | let greeter = Greeter::default(); 201 | 202 | assert!(matches!(Session::get_selected(&greeter), None)); 203 | } 204 | 205 | #[test] 206 | fn distinct_session() { 207 | let mut greeter = Greeter::default(); 208 | greeter.session_source = SessionSource::Session(1); 209 | 210 | greeter.sessions = Menu:: { 211 | title: "Sessions".into(), 212 | selected: 1, 213 | options: vec![ 214 | Session { 215 | name: "Session1".into(), 216 | command: "Session1Cmd".into(), 217 | session_type: super::SessionType::Wayland, 218 | path: Some("/Session1Path".into()), 219 | ..Default::default() 220 | }, 221 | Session { 222 | name: "Session2".into(), 223 | command: "Session2Cmd".into(), 224 | session_type: super::SessionType::X11, 225 | path: Some("/Session2Path".into()), 226 | ..Default::default() 227 | }, 228 | ], 229 | }; 230 | 231 | let session = Session::get_selected(&greeter); 232 | 233 | assert!(matches!(session, Some(_))); 234 | assert_eq!(session.unwrap().name, "Session2"); 235 | assert_eq!(session.unwrap().session_type, SessionType::X11); 236 | } 237 | 238 | #[test] 239 | fn same_name_session() { 240 | let mut greeter = Greeter::default(); 241 | greeter.session_source = SessionSource::Session(1); 242 | 243 | greeter.sessions = Menu:: { 244 | title: "Sessions".into(), 245 | selected: 1, 246 | options: vec![ 247 | Session { 248 | name: "Session".into(), 249 | command: "Session1Cmd".into(), 250 | session_type: super::SessionType::Wayland, 251 | path: Some("/Session1Path".into()), 252 | ..Default::default() 253 | }, 254 | Session { 255 | name: "Session".into(), 256 | command: "Session2Cmd".into(), 257 | session_type: super::SessionType::X11, 258 | path: Some("/Session2Path".into()), 259 | ..Default::default() 260 | }, 261 | ], 262 | }; 263 | 264 | let session = Session::get_selected(&greeter); 265 | 266 | assert!(matches!(session, Some(_))); 267 | assert_eq!(session.unwrap().name, "Session"); 268 | assert_eq!(session.unwrap().session_type, SessionType::X11); 269 | assert_eq!(session.unwrap().command, "Session2Cmd"); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/info.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | env, 3 | error::Error, 4 | fs::{self, File}, 5 | io::{self, BufRead, BufReader}, 6 | path::{Path, PathBuf}, 7 | process::Command, 8 | }; 9 | 10 | use chrono::Local; 11 | use ini::Ini; 12 | use lazy_static::lazy_static; 13 | use nix::sys::utsname; 14 | use utmp_rs::{UtmpEntry, UtmpParser}; 15 | use uzers::os::unix::UserExt; 16 | 17 | use crate::{ 18 | ui::{ 19 | common::masked::MaskedString, 20 | sessions::{Session, SessionType}, 21 | users::User, 22 | }, 23 | Greeter, 24 | }; 25 | 26 | const LAST_USER_USERNAME: &str = "/var/cache/tuigreet/lastuser"; 27 | const LAST_USER_NAME: &str = "/var/cache/tuigreet/lastuser-name"; 28 | const LAST_COMMAND: &str = "/var/cache/tuigreet/lastsession"; 29 | const LAST_SESSION: &str = "/var/cache/tuigreet/lastsession-path"; 30 | 31 | const DEFAULT_MIN_UID: u16 = 1000; 32 | const DEFAULT_MAX_UID: u16 = 60000; 33 | 34 | lazy_static! { 35 | static ref XDG_DATA_DIRS: Vec = { 36 | let value = env::var("XDG_DATA_DIRS").unwrap_or("/usr/local/share:/usr/share".to_string()); 37 | env::split_paths(&value).filter(|p| p.is_absolute()).collect() 38 | }; 39 | static ref DEFAULT_SESSION_PATHS: Vec<(PathBuf, SessionType)> = XDG_DATA_DIRS 40 | .iter() 41 | .map(|p| (p.join("wayland-sessions"), SessionType::Wayland)) 42 | .chain(XDG_DATA_DIRS.iter().map(|p| (p.join("xsessions"), SessionType::X11))) 43 | .collect(); 44 | } 45 | 46 | pub fn get_hostname() -> String { 47 | match utsname::uname() { 48 | Ok(uts) => uts.nodename().to_str().unwrap_or("").to_string(), 49 | _ => String::new(), 50 | } 51 | } 52 | 53 | pub fn get_issue() -> Option { 54 | let (date, time) = { 55 | let now = Local::now(); 56 | 57 | (now.format("%a %b %_d %Y").to_string(), now.format("%H:%M:%S").to_string()) 58 | }; 59 | 60 | let user_count = match UtmpParser::from_path("/var/run/utmp") 61 | .map(|utmp| { 62 | utmp.into_iter().fold(0, |acc, entry| match entry { 63 | Ok(UtmpEntry::UserProcess { .. }) => acc + 1, 64 | Ok(UtmpEntry::LoginProcess { .. }) => acc + 1, 65 | _ => acc, 66 | }) 67 | }) 68 | .unwrap_or(0) 69 | { 70 | n if n < 2 => format!("{n} user"), 71 | n => format!("{n} users"), 72 | }; 73 | 74 | let vtnr: usize = env::var("XDG_VTNR").unwrap_or_else(|_| "0".to_string()).parse().unwrap_or(0); 75 | let uts = utsname::uname(); 76 | 77 | if let Ok(issue) = fs::read_to_string("/etc/issue") { 78 | let issue = issue 79 | .replace("\\S", "Linux") 80 | .replace("\\l", &format!("tty{vtnr}")) 81 | .replace("\\d", &date) 82 | .replace("\\t", &time) 83 | .replace("\\U", &user_count); 84 | 85 | let issue = match uts { 86 | Ok(uts) => issue 87 | .replace("\\s", uts.sysname().to_str().unwrap_or("")) 88 | .replace("\\r", uts.release().to_str().unwrap_or("")) 89 | .replace("\\v", uts.version().to_str().unwrap_or("")) 90 | .replace("\\n", uts.nodename().to_str().unwrap_or("")) 91 | .replace("\\m", uts.machine().to_str().unwrap_or("")) 92 | .replace("\\o", uts.domainname().to_str().unwrap_or("")), 93 | 94 | _ => issue, 95 | }; 96 | 97 | return Some(issue.replace("\\x1b", "\x1b").replace("\\033", "\x1b").replace("\\e", "\x1b").replace(r"\\", r"\")); 98 | } 99 | 100 | None 101 | } 102 | 103 | pub fn get_last_user_username() -> Option { 104 | match fs::read_to_string(LAST_USER_USERNAME).ok() { 105 | None => None, 106 | Some(username) => { 107 | let username = username.trim(); 108 | 109 | if username.is_empty() { 110 | None 111 | } else { 112 | Some(username.to_string()) 113 | } 114 | } 115 | } 116 | } 117 | 118 | pub fn get_last_user_name() -> Option { 119 | match fs::read_to_string(LAST_USER_NAME).ok() { 120 | None => None, 121 | Some(name) => { 122 | let name = name.trim(); 123 | 124 | if name.is_empty() { 125 | None 126 | } else { 127 | Some(name.to_string()) 128 | } 129 | } 130 | } 131 | } 132 | 133 | pub fn write_last_username(username: &MaskedString) { 134 | let _ = fs::write(LAST_USER_USERNAME, &username.value); 135 | 136 | if let Some(ref name) = username.mask { 137 | let _ = fs::write(LAST_USER_NAME, name); 138 | } else { 139 | let _ = fs::remove_file(LAST_USER_NAME); 140 | } 141 | } 142 | 143 | pub fn get_last_session_path() -> Result { 144 | Ok(PathBuf::from(fs::read_to_string(LAST_SESSION)?.trim())) 145 | } 146 | 147 | pub fn get_last_command() -> Result { 148 | Ok(fs::read_to_string(LAST_COMMAND)?.trim().to_string()) 149 | } 150 | 151 | pub fn write_last_session_path

(session: &P) 152 | where 153 | P: AsRef, 154 | { 155 | let _ = fs::write(LAST_SESSION, session.as_ref().to_string_lossy().as_bytes()); 156 | } 157 | 158 | pub fn write_last_command(session: &str) { 159 | let _ = fs::write(LAST_COMMAND, session); 160 | } 161 | 162 | pub fn get_last_user_session(username: &str) -> Result { 163 | Ok(PathBuf::from(fs::read_to_string(format!("{LAST_SESSION}-{username}"))?.trim())) 164 | } 165 | 166 | pub fn get_last_user_command(username: &str) -> Result { 167 | Ok(fs::read_to_string(format!("{LAST_COMMAND}-{username}"))?.trim().to_string()) 168 | } 169 | 170 | pub fn write_last_user_session

(username: &str, session: P) 171 | where 172 | P: AsRef, 173 | { 174 | let _ = fs::write(format!("{LAST_SESSION}-{username}"), session.as_ref().to_string_lossy().as_bytes()); 175 | } 176 | 177 | pub fn delete_last_session() { 178 | let _ = fs::remove_file(LAST_SESSION); 179 | } 180 | 181 | pub fn write_last_user_command(username: &str, session: &str) { 182 | let _ = fs::write(format!("{LAST_COMMAND}-{username}"), session); 183 | } 184 | 185 | pub fn delete_last_user_session(username: &str) { 186 | let _ = fs::remove_file(format!("{LAST_SESSION}-{username}")); 187 | } 188 | 189 | pub fn delete_last_command() { 190 | let _ = fs::remove_file(LAST_COMMAND); 191 | } 192 | 193 | pub fn delete_last_user_command(username: &str) { 194 | let _ = fs::remove_file(format!("{LAST_COMMAND}-{username}")); 195 | } 196 | 197 | pub fn get_users(min_uid: u16, max_uid: u16) -> Vec { 198 | let users = unsafe { uzers::all_users() }; 199 | 200 | let users: Vec = users 201 | .filter(|user| user.uid() >= min_uid as u32 && user.uid() <= max_uid as u32) 202 | .map(|user| User { 203 | username: user.name().to_string_lossy().to_string(), 204 | name: match user.gecos() { 205 | name if name.is_empty() => None, 206 | name => { 207 | let name = name.to_string_lossy(); 208 | 209 | match name.split_once(',') { 210 | Some((name, _)) => Some(name.to_string()), 211 | None => Some(name.to_string()), 212 | } 213 | } 214 | }, 215 | }) 216 | .collect(); 217 | 218 | users 219 | } 220 | 221 | pub fn get_min_max_uids(min_uid: Option, max_uid: Option) -> (u16, u16) { 222 | if let (Some(min_uid), Some(max_uid)) = (min_uid, max_uid) { 223 | return (min_uid, max_uid); 224 | } 225 | 226 | let overrides = (min_uid, max_uid); 227 | let default = (min_uid.unwrap_or(DEFAULT_MIN_UID), max_uid.unwrap_or(DEFAULT_MAX_UID)); 228 | 229 | match File::open("/etc/login.defs") { 230 | Err(_) => default, 231 | Ok(file) => { 232 | let file = BufReader::new(file); 233 | 234 | let uids: (u16, u16) = file.lines().fold(default, |acc, line| { 235 | line 236 | .map(|line| { 237 | let mut tokens = line.split_whitespace(); 238 | 239 | match (overrides, tokens.next(), tokens.next()) { 240 | ((None, _), Some("UID_MIN"), Some(value)) => (value.parse::().unwrap_or(acc.0), acc.1), 241 | ((_, None), Some("UID_MAX"), Some(value)) => (acc.0, value.parse::().unwrap_or(acc.1)), 242 | _ => acc, 243 | } 244 | }) 245 | .unwrap_or(acc) 246 | }); 247 | 248 | uids 249 | } 250 | } 251 | } 252 | 253 | pub fn get_sessions(greeter: &Greeter) -> Result, Box> { 254 | let paths = if greeter.session_paths.is_empty() { 255 | DEFAULT_SESSION_PATHS.as_ref() 256 | } else { 257 | &greeter.session_paths 258 | }; 259 | 260 | let mut files = vec![]; 261 | 262 | for (path, session_type) in paths.iter() { 263 | tracing::info!("reading {:?} sessions from '{}'", session_type, path.display()); 264 | 265 | if let Ok(entries) = fs::read_dir(path) { 266 | files.extend(entries.flat_map(|entry| entry.map(|entry| load_desktop_file(entry.path(), *session_type))).flatten().flatten()); 267 | } 268 | } 269 | 270 | files.sort_by(|a, b| a.name.cmp(&b.name)); 271 | 272 | tracing::info!("found {} sessions", files.len()); 273 | 274 | Ok(files) 275 | } 276 | 277 | fn load_desktop_file

(path: P, session_type: SessionType) -> Result, Box> 278 | where 279 | P: AsRef, 280 | { 281 | let desktop = Ini::load_from_file(path.as_ref())?; 282 | let section = desktop.section(Some("Desktop Entry")).ok_or("no Desktop Entry section in desktop file")?; 283 | 284 | if let Some("true") = section.get("Hidden") { 285 | tracing::info!("ignoring session in '{}': Hidden=true", path.as_ref().display()); 286 | return Ok(None); 287 | } 288 | if let Some("true") = section.get("NoDisplay") { 289 | tracing::info!("ignoring session in '{}': NoDisplay=true", path.as_ref().display()); 290 | return Ok(None); 291 | } 292 | 293 | let slug = path.as_ref().file_stem().map(|slug| slug.to_string_lossy().to_string()); 294 | let name = section.get("Name").ok_or("no Name property in desktop file")?; 295 | let exec = section.get("Exec").ok_or("no Exec property in desktop file")?; 296 | let xdg_desktop_names = section.get("DesktopNames").map(str::to_string); 297 | 298 | tracing::info!("got session '{}' in '{}'", name, path.as_ref().display()); 299 | 300 | Ok(Some(Session { 301 | slug, 302 | name: name.to_string(), 303 | command: exec.to_string(), 304 | session_type, 305 | path: Some(path.as_ref().into()), 306 | xdg_desktop_names, 307 | })) 308 | } 309 | 310 | pub fn capslock_status() -> bool { 311 | let mut command = Command::new("kbdinfo"); 312 | command.args(["gkbled", "capslock"]); 313 | 314 | match command.output() { 315 | Ok(output) => output.status.code() == Some(0), 316 | Err(_) => false, 317 | } 318 | } 319 | 320 | #[cfg(feature = "nsswrapper")] 321 | #[cfg(test)] 322 | mod nsswrapper_tests { 323 | #[test] 324 | fn nsswrapper_get_users_from_nss() { 325 | use super::get_users; 326 | 327 | let users = get_users(1000, 2000); 328 | 329 | assert_eq!(users.len(), 2); 330 | assert_eq!(users[0].username, "joe"); 331 | assert_eq!(users[0].name, Some("Joe".to_string())); 332 | assert_eq!(users[1].username, "bob"); 333 | assert_eq!(users[1].name, None); 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/ui/util.rs: -------------------------------------------------------------------------------- 1 | use ansi_to_tui::IntoText; 2 | use tui::{ 3 | prelude::Rect, 4 | text::Text, 5 | widgets::{Paragraph, Wrap}, 6 | }; 7 | 8 | use crate::{Greeter, Mode}; 9 | 10 | pub fn titleize(message: &str) -> String { 11 | format!(" {message} ") 12 | } 13 | 14 | pub fn buttonize(message: &str) -> String { 15 | format!(" {message}") 16 | } 17 | 18 | // Determinew whether the cursor should be shown or hidden from the current 19 | // mode and configuration. Usually, we will show the cursor only when expecting 20 | // text entries from the user. 21 | pub fn should_hide_cursor(greeter: &Greeter) -> bool { 22 | greeter.working 23 | || greeter.done 24 | || (greeter.user_menu && greeter.mode == Mode::Username && greeter.username.value.is_empty()) 25 | || (greeter.mode == Mode::Password && greeter.prompt.is_none()) 26 | || greeter.mode == Mode::Users 27 | || greeter.mode == Mode::Sessions 28 | || greeter.mode == Mode::Power 29 | || greeter.mode == Mode::Processing 30 | || greeter.mode == Mode::Action 31 | } 32 | 33 | // Computes the height of the main window where we display content, depending on 34 | // the mode and spacing configuration. 35 | // 36 | // +------------------------+ 37 | // | | <- container padding 38 | // | Greeting | <- greeting height 39 | // | | <- auto-padding if greeting 40 | // | Username: | <- username 41 | // | Password: | <- password if prompt == Some(_) 42 | // | | <- container padding 43 | // +------------------------+ 44 | pub fn get_height(greeter: &Greeter) -> u16 { 45 | let (_, greeting_height) = get_greeting_height(greeter, 1, 0); 46 | let container_padding = greeter.container_padding(); 47 | let prompt_padding = greeter.prompt_padding(); 48 | 49 | let initial = match greeter.mode { 50 | Mode::Username | Mode::Action | Mode::Command => (2 * container_padding) + 1, 51 | Mode::Password => match greeter.prompt { 52 | Some(_) => (2 * container_padding) + prompt_padding + 2, 53 | None => (2 * container_padding) + 1, 54 | }, 55 | Mode::Users | Mode::Sessions | Mode::Power | Mode::Processing => 2 * container_padding, 56 | }; 57 | 58 | match greeter.mode { 59 | Mode::Command | Mode::Sessions | Mode::Power | Mode::Processing => initial, 60 | _ => initial + greeting_height, 61 | } 62 | } 63 | 64 | // Get the coordinates and size of the main window area, from the terminal size, 65 | // and the content we need to display. 66 | pub fn get_rect_bounds(greeter: &Greeter, area: Rect, items: usize) -> (u16, u16, u16, u16) { 67 | let width = greeter.width(); 68 | let height: u16 = get_height(greeter) + items as u16; 69 | 70 | let x = if width < area.width { (area.width - width) / 2 } else { 0 }; 71 | let y = if height < area.height { (area.height - height) / 2 } else { 0 }; 72 | 73 | let (x, width) = if (x + width) >= area.width { (0, area.width) } else { (x, width) }; 74 | let (y, height) = if (y + height) >= area.height { (0, area.height) } else { (y, height) }; 75 | 76 | (x, y, width, height) 77 | } 78 | 79 | // Computes the size of a text entry, from the container width and, if 80 | // applicable, the prompt length. 81 | pub fn get_input_width(greeter: &Greeter, width: u16, label: &Option) -> u16 { 82 | let width = std::cmp::min(greeter.width(), width); 83 | 84 | let label_width = match label { 85 | None => 0, 86 | Some(label) => label.chars().count(), 87 | }; 88 | 89 | width - label_width as u16 - 4 - 1 90 | } 91 | 92 | pub fn get_cursor_offset(greeter: &mut Greeter, length: usize) -> i16 { 93 | let mut offset = length as i16 + greeter.cursor_offset; 94 | 95 | if offset < 0 { 96 | offset = 0; 97 | greeter.cursor_offset = -(length as i16); 98 | } 99 | 100 | if offset > length as i16 { 101 | offset = length as i16; 102 | greeter.cursor_offset = 0; 103 | } 104 | 105 | offset 106 | } 107 | 108 | pub fn get_greeting_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Option, u16) { 109 | if let Some(greeting) = &greeter.greeting { 110 | let width = greeter.width(); 111 | 112 | let text = match greeting.clone().trim().into_text() { 113 | Ok(text) => text, 114 | Err(_) => Text::raw(greeting), 115 | }; 116 | 117 | let paragraph = Paragraph::new(text.clone()).wrap(Wrap { trim: false }); 118 | let height = paragraph.line_count(width - (2 * padding)) + 1; 119 | 120 | (Some(paragraph), height as u16) 121 | } else { 122 | (None, fallback) 123 | } 124 | } 125 | 126 | pub fn get_message_height(greeter: &Greeter, padding: u16, fallback: u16) -> (Option, u16) { 127 | if let Some(message) = &greeter.message { 128 | let width = greeter.width(); 129 | let paragraph = Paragraph::new(message.trim_end()).wrap(Wrap { trim: true }); 130 | let height = paragraph.line_count(width - 4); 131 | 132 | (Some(paragraph), height as u16 + padding) 133 | } else { 134 | (None, fallback) 135 | } 136 | } 137 | 138 | #[cfg(test)] 139 | mod test { 140 | use tui::{ 141 | prelude::Rect, 142 | style::{Color, Style}, 143 | text::{Line, Span, Text}, 144 | widgets::{Paragraph, Wrap}, 145 | }; 146 | 147 | use crate::{ 148 | ui::util::{get_greeting_height, get_height}, 149 | Greeter, Mode, 150 | }; 151 | 152 | use super::{get_input_width, get_rect_bounds}; 153 | 154 | // +-----------+ 155 | // | Username: | 156 | // +-----------+ 157 | #[test] 158 | fn test_container_height_username_padding_zero() { 159 | let mut greeter = Greeter::default(); 160 | greeter.config = Greeter::options().parse(&["--container-padding", "0"]).ok(); 161 | greeter.mode = Mode::Username; 162 | 163 | assert_eq!(get_height(&greeter), 3); 164 | } 165 | 166 | // +-----------+ 167 | // | | 168 | // | Username: | 169 | // | | 170 | // +-----------+ 171 | #[test] 172 | fn test_container_height_username_padding_one() { 173 | let mut greeter = Greeter::default(); 174 | greeter.config = Greeter::options().parse(&["--container-padding", "1"]).ok(); 175 | greeter.mode = Mode::Username; 176 | 177 | assert_eq!(get_height(&greeter), 5); 178 | } 179 | 180 | // +-----------+ 181 | // | | 182 | // | Greeting | 183 | // | | 184 | // | Username: | 185 | // | | 186 | // +-----------+ 187 | #[test] 188 | fn test_container_height_username_greeting_padding_one() { 189 | let mut greeter = Greeter::default(); 190 | greeter.config = Greeter::options().parse(&["--container-padding", "1"]).ok(); 191 | greeter.greeting = Some("Hello".into()); 192 | greeter.mode = Mode::Username; 193 | 194 | assert_eq!(get_height(&greeter), 7); 195 | } 196 | 197 | // +-----------+ 198 | // | | 199 | // | Greeting | 200 | // | | 201 | // | Username: | 202 | // | | 203 | // | Password: | 204 | // | | 205 | // +-----------+ 206 | #[test] 207 | fn test_container_height_password_greeting_padding_one_prompt_padding_1() { 208 | let mut greeter = Greeter::default(); 209 | greeter.config = Greeter::options().parse(&["--container-padding", "1"]).ok(); 210 | greeter.greeting = Some("Hello".into()); 211 | greeter.mode = Mode::Password; 212 | greeter.prompt = Some("Password:".into()); 213 | 214 | assert_eq!(get_height(&greeter), 9); 215 | } 216 | 217 | // +-----------+ 218 | // | | 219 | // | Greeting | 220 | // | | 221 | // | Username: | 222 | // | Password: | 223 | // | | 224 | // +-----------+ 225 | #[test] 226 | fn test_container_height_password_greeting_padding_one_prompt_padding_0() { 227 | let mut greeter = Greeter::default(); 228 | greeter.config = Greeter::options().parse(&["--container-padding", "1", "--prompt-padding", "0"]).ok(); 229 | greeter.greeting = Some("Hello".into()); 230 | greeter.mode = Mode::Password; 231 | greeter.prompt = Some("Password:".into()); 232 | 233 | assert_eq!(get_height(&greeter), 8); 234 | } 235 | 236 | #[test] 237 | fn test_rect_bounds() { 238 | let mut greeter = Greeter::default(); 239 | greeter.config = Greeter::options().parse(&["--width", "50"]).ok(); 240 | 241 | let (x, y, width, height) = get_rect_bounds(&greeter, Rect::new(0, 0, 100, 100), 1); 242 | 243 | assert_eq!(x, 25); 244 | assert_eq!(y, 47); 245 | assert_eq!(width, 50); 246 | assert_eq!(height, 6); 247 | } 248 | 249 | // | Username: __________________________ | 250 | // <--------------------------------------> width 40 (padding 1) 251 | // <-------> prompt width 9 252 | // <------------------------> input width 26 253 | #[test] 254 | fn input_width() { 255 | let mut greeter = Greeter::default(); 256 | greeter.config = Greeter::options().parse(&["--width", "40", "--container-padding", "1"]).ok(); 257 | 258 | let input_width = get_input_width(&greeter, 40, &Some("Username:".into())); 259 | 260 | assert_eq!(input_width, 26); 261 | } 262 | 263 | #[test] 264 | fn greeting_height_one_line() { 265 | let mut greeter = Greeter::default(); 266 | greeter.config = Greeter::options().parse(&["--width", "15", "--container-padding", "1"]).ok(); 267 | greeter.greeting = Some("Hello World".into()); 268 | 269 | let (_, height) = get_greeting_height(&greeter, 1, 0); 270 | 271 | assert_eq!(height, 2); 272 | } 273 | 274 | #[test] 275 | fn greeting_height_two_lines() { 276 | let mut greeter = Greeter::default(); 277 | greeter.config = Greeter::options().parse(&["--width", "8", "--container-padding", "1"]).ok(); 278 | greeter.greeting = Some("Hello World".into()); 279 | 280 | let (_, height) = get_greeting_height(&greeter, 1, 0); 281 | 282 | assert_eq!(height, 3); 283 | } 284 | 285 | #[test] 286 | fn ansi_greeting_height_one_line() { 287 | let mut greeter = Greeter::default(); 288 | greeter.config = Greeter::options().parse(&["--width", "15", "--container-padding", "1"]).ok(); 289 | greeter.greeting = Some("\x1b[31mHello\x1b[0m World".into()); 290 | 291 | let (text, height) = get_greeting_height(&greeter, 1, 0); 292 | 293 | let expected = Paragraph::new(Text::from(vec![Line::from(vec![ 294 | Span::styled("Hello", Style::default().fg(Color::Red)), 295 | Span::styled(" World", Style::reset()), 296 | ])])) 297 | .wrap(Wrap { trim: false }); 298 | 299 | assert_eq!(text, Some(expected)); 300 | assert_eq!(height, 2); 301 | } 302 | 303 | #[test] 304 | fn ansi_greeting_height_two_lines() { 305 | let mut greeter = Greeter::default(); 306 | greeter.config = Greeter::options().parse(&["--width", "8", "--container-padding", "1"]).ok(); 307 | greeter.greeting = Some("\x1b[31mHello\x1b[0m World".into()); 308 | 309 | let (text, height) = get_greeting_height(&greeter, 1, 0); 310 | 311 | let expected = Paragraph::new(Text::from(vec![Line::from(vec![ 312 | Span::styled("Hello", Style::default().fg(Color::Red)), 313 | Span::styled(" World", Style::reset()), 314 | ])])) 315 | .wrap(Wrap { trim: false }); 316 | 317 | assert_eq!(text, Some(expected)); 318 | assert_eq!(height, 3); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/ipc.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, error::Error, sync::Arc}; 2 | 3 | use greetd_ipc::{codec::TokioCodec, AuthMessageType, ErrorType, Request, Response}; 4 | use tokio::sync::{ 5 | mpsc::{Receiver, Sender}, 6 | Mutex, RwLock, 7 | }; 8 | 9 | use crate::{ 10 | event::Event, 11 | info::{delete_last_user_command, delete_last_user_session, write_last_user_command, write_last_user_session, write_last_username}, 12 | macros::SafeDebug, 13 | ui::sessions::{Session, SessionSource, SessionType}, 14 | AuthStatus, Greeter, Mode, 15 | }; 16 | 17 | #[derive(Clone)] 18 | pub struct Ipc(Arc); 19 | 20 | pub struct IpcHandle { 21 | tx: RwLock>, 22 | rx: Mutex>, 23 | } 24 | 25 | impl Ipc { 26 | pub fn new() -> Ipc { 27 | let (tx, rx) = tokio::sync::mpsc::channel::(10); 28 | 29 | Ipc(Arc::new(IpcHandle { 30 | tx: RwLock::new(tx), 31 | rx: Mutex::new(rx), 32 | })) 33 | } 34 | 35 | pub async fn send(&self, request: Request) { 36 | tracing::info!("sending request to greetd: {}", request.safe_repr()); 37 | 38 | let _ = self.0.tx.read().await.send(request).await; 39 | } 40 | 41 | pub async fn next(&mut self) -> Option { 42 | self.0.rx.lock().await.recv().await 43 | } 44 | 45 | pub async fn handle(&mut self, greeter: Arc>) -> Result<(), Box> { 46 | let request = self.next().await; 47 | 48 | if let Some(request) = request { 49 | let stream = { 50 | let greeter = greeter.read().await; 51 | 52 | greeter.stream.as_ref().unwrap().clone() 53 | }; 54 | 55 | let response = { 56 | request.write_to(&mut *stream.write().await).await?; 57 | 58 | let response = Response::read_from(&mut *stream.write().await).await?; 59 | 60 | greeter.write().await.working = false; 61 | 62 | response 63 | }; 64 | 65 | self.parse_response(&mut *greeter.write().await, response).await?; 66 | } 67 | 68 | Ok(()) 69 | } 70 | 71 | async fn parse_response(&mut self, greeter: &mut Greeter, response: Response) -> Result<(), Box> { 72 | // Do not display actual message from greetd, which may contain entered information, sometimes passwords. 73 | match response { 74 | Response::Error { ref error_type, .. } => tracing::info!("received greetd error message: {error_type:?}"), 75 | ref response => tracing::info!("received greetd message: {:?}", response), 76 | } 77 | 78 | match response { 79 | Response::AuthMessage { auth_message_type, auth_message } => match auth_message_type { 80 | AuthMessageType::Secret => { 81 | greeter.mode = Mode::Password; 82 | greeter.working = false; 83 | greeter.asking_for_secret = true; 84 | greeter.set_prompt(&auth_message); 85 | } 86 | 87 | AuthMessageType::Visible => { 88 | greeter.mode = Mode::Password; 89 | greeter.working = false; 90 | greeter.asking_for_secret = false; 91 | greeter.set_prompt(&auth_message); 92 | } 93 | 94 | AuthMessageType::Error => { 95 | greeter.message = Some(auth_message); 96 | 97 | self.send(Request::PostAuthMessageResponse { response: None }).await; 98 | } 99 | 100 | AuthMessageType::Info => { 101 | greeter.remove_prompt(); 102 | 103 | greeter.previous_mode = greeter.mode; 104 | greeter.mode = Mode::Action; 105 | 106 | if let Some(message) = &mut greeter.message { 107 | message.push('\n'); 108 | message.push_str(auth_message.trim_end()); 109 | } else { 110 | greeter.message = Some(auth_message.trim_end().to_string()); 111 | } 112 | 113 | self.send(Request::PostAuthMessageResponse { response: None }).await; 114 | } 115 | }, 116 | 117 | Response::Success => { 118 | if greeter.done { 119 | tracing::info!("greetd acknowledged session start, exiting"); 120 | 121 | if greeter.remember { 122 | tracing::info!("caching last successful username"); 123 | 124 | write_last_username(&greeter.username); 125 | 126 | if greeter.remember_user_session { 127 | match greeter.session_source { 128 | SessionSource::Command(ref command) => { 129 | tracing::info!("caching last user command: {command}"); 130 | 131 | write_last_user_command(&greeter.username.value, command); 132 | delete_last_user_session(&greeter.username.value); 133 | } 134 | 135 | SessionSource::Session(index) => { 136 | if let Some(Session { path: Some(session_path), .. }) = greeter.sessions.options.get(index) { 137 | tracing::info!("caching last user session: {session_path:?}"); 138 | 139 | write_last_user_session(&greeter.username.value, session_path); 140 | delete_last_user_command(&greeter.username.value); 141 | } 142 | } 143 | 144 | _ => {} 145 | } 146 | } 147 | } 148 | 149 | if let Some(ref sender) = greeter.events { 150 | let _ = sender.send(Event::Exit(AuthStatus::Success)).await; 151 | } 152 | } else { 153 | tracing::info!("authentication successful, starting session"); 154 | 155 | match greeter.session_source.command(greeter).map(str::to_string) { 156 | None => { 157 | Ipc::cancel(greeter).await; 158 | 159 | greeter.message = Some(fl!("command_missing")); 160 | greeter.reset(false).await; 161 | } 162 | 163 | Some(command) if command.is_empty() => { 164 | Ipc::cancel(greeter).await; 165 | 166 | greeter.message = Some(fl!("command_missing")); 167 | greeter.reset(false).await; 168 | } 169 | 170 | Some(command) => { 171 | greeter.done = true; 172 | greeter.mode = Mode::Processing; 173 | 174 | let session = Session::get_selected(greeter); 175 | let default = DefaultCommand(&command, greeter.session_source.env()); 176 | let (command, env) = wrap_session_command(greeter, session, &default); 177 | 178 | #[cfg(not(debug_assertions))] 179 | self.send(Request::StartSession { cmd: vec![command.to_string()], env }).await; 180 | 181 | #[cfg(debug_assertions)] 182 | { 183 | let _ = command; 184 | 185 | self.send(Request::StartSession { cmd: vec!["true".to_string()], env }).await; 186 | } 187 | } 188 | } 189 | } 190 | } 191 | 192 | Response::Error { error_type, .. } => { 193 | // Do not display actual message from greetd, which may contain entered information, sometimes passwords. 194 | tracing::info!("received an error from greetd: {error_type:?}"); 195 | 196 | Ipc::cancel(greeter).await; 197 | 198 | match error_type { 199 | ErrorType::AuthError => { 200 | greeter.message = Some(fl!("failed")); 201 | self 202 | .send(Request::CreateSession { 203 | username: greeter.username.value.clone(), 204 | }) 205 | .await; 206 | greeter.reset(true).await; 207 | } 208 | 209 | ErrorType::Error => { 210 | // Do not display actual message from greetd, which may contain entered information, sometimes passwords. 211 | greeter.message = Some("An error was received from greetd".to_string()); 212 | greeter.reset(false).await; 213 | } 214 | } 215 | } 216 | } 217 | 218 | Ok(()) 219 | } 220 | 221 | pub async fn cancel(greeter: &mut Greeter) { 222 | tracing::info!("cancelling session"); 223 | 224 | let _ = Request::CancelSession.write_to(&mut *greeter.stream().await).await; 225 | } 226 | } 227 | 228 | fn desktop_names_to_xdg(names: &str) -> String { 229 | names.replace(';', ":").trim_end_matches(':').to_string() 230 | } 231 | 232 | struct DefaultCommand<'a>(&'a str, Option>); 233 | 234 | impl<'a> DefaultCommand<'a> { 235 | fn command(&'a self) -> &'a str { 236 | self.0 237 | } 238 | 239 | fn env(&'a self) -> Option<&'a Vec> { 240 | self.1.as_ref() 241 | } 242 | } 243 | 244 | fn wrap_session_command<'a>(greeter: &Greeter, session: Option<&Session>, default: &'a DefaultCommand<'a>) -> (Cow<'a, str>, Vec) { 245 | let mut env: Vec = vec![]; 246 | 247 | match session { 248 | // If the target is a defined session, we should be able to deduce all the 249 | // environment we need from the desktop file. 250 | Some(Session { 251 | slug, 252 | session_type, 253 | xdg_desktop_names, 254 | .. 255 | }) => { 256 | if let Some(slug) = slug { 257 | env.push(format!("XDG_SESSION_DESKTOP={slug}")); 258 | env.push(format!("DESKTOP_SESSION={slug}")); 259 | } 260 | if *session_type != SessionType::None { 261 | env.push(format!("XDG_SESSION_TYPE={}", session_type.as_xdg_session_type())); 262 | } 263 | if let Some(xdg_desktop_names) = xdg_desktop_names { 264 | env.push(format!("XDG_CURRENT_DESKTOP={}", desktop_names_to_xdg(xdg_desktop_names))); 265 | } 266 | 267 | if *session_type == SessionType::X11 { 268 | if let Some(ref wrap) = greeter.xsession_wrapper { 269 | return (Cow::Owned(format!("{} {}", wrap, default.command())), env); 270 | } 271 | } else if let Some(ref wrap) = greeter.session_wrapper { 272 | return (Cow::Owned(format!("{} {}", wrap, default.command())), env); 273 | } 274 | } 275 | 276 | _ => { 277 | // If a wrapper script is used, assume that it is able to set up the 278 | // required environment. 279 | if let Some(ref wrap) = greeter.session_wrapper { 280 | return (Cow::Owned(format!("{} {}", wrap, default.command())), env); 281 | } 282 | // Otherwise, set up the environment from the provided argument. 283 | if let Some(base_env) = default.env() { 284 | env.append(&mut base_env.clone()); 285 | } 286 | } 287 | } 288 | 289 | (Cow::Borrowed(default.command()), env) 290 | } 291 | 292 | #[cfg(test)] 293 | mod test { 294 | use std::path::PathBuf; 295 | 296 | use crate::{ 297 | ipc::{desktop_names_to_xdg, DefaultCommand}, 298 | ui::sessions::{Session, SessionType}, 299 | Greeter, 300 | }; 301 | 302 | use super::wrap_session_command; 303 | 304 | #[test] 305 | fn wayland_no_wrapper() { 306 | let greeter = Greeter::default(); 307 | 308 | let session = Session { 309 | name: "Session1".into(), 310 | session_type: SessionType::Wayland, 311 | command: "Session1Cmd".into(), 312 | path: Some(PathBuf::from("/Session1Path")), 313 | ..Default::default() 314 | }; 315 | 316 | let default = DefaultCommand(&session.command, None); 317 | let (command, env) = wrap_session_command(&greeter, Some(&session), &default); 318 | 319 | assert_eq!(command.as_ref(), "Session1Cmd"); 320 | assert_eq!(env, vec!["XDG_SESSION_TYPE=wayland"]); 321 | } 322 | 323 | #[test] 324 | fn wayland_wrapper() { 325 | let mut greeter = Greeter::default(); 326 | greeter.session_wrapper = Some("/wrapper.sh".into()); 327 | 328 | let session = Session { 329 | name: "Session1".into(), 330 | session_type: SessionType::Wayland, 331 | command: "Session1Cmd".into(), 332 | path: Some(PathBuf::from("/Session1Path")), 333 | ..Default::default() 334 | }; 335 | 336 | let default = DefaultCommand(&session.command, None); 337 | let (command, env) = wrap_session_command(&greeter, Some(&session), &default); 338 | 339 | assert_eq!(command.as_ref(), "/wrapper.sh Session1Cmd"); 340 | assert_eq!(env, vec!["XDG_SESSION_TYPE=wayland"]); 341 | } 342 | 343 | #[test] 344 | fn x11_wrapper() { 345 | let mut greeter = Greeter::default(); 346 | greeter.xsession_wrapper = Some("startx /usr/bin/env".into()); 347 | 348 | let session = Session { 349 | slug: Some("thede".to_string()), 350 | name: "Session1".into(), 351 | session_type: SessionType::X11, 352 | command: "Session1Cmd".into(), 353 | path: Some(PathBuf::from("/Session1Path")), 354 | xdg_desktop_names: Some("one;two;three;".to_string()), 355 | ..Default::default() 356 | }; 357 | 358 | let default = DefaultCommand(&session.command, None); 359 | let (command, env) = wrap_session_command(&greeter, Some(&session), &default); 360 | 361 | assert_eq!(command.as_ref(), "startx /usr/bin/env Session1Cmd"); 362 | assert_eq!( 363 | env, 364 | vec!["XDG_SESSION_DESKTOP=thede", "DESKTOP_SESSION=thede", "XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=one:two:three"] 365 | ); 366 | } 367 | 368 | #[test] 369 | fn xdg_current_desktop() { 370 | assert_eq!(desktop_names_to_xdg("one;two;three four"), "one:two:three four"); 371 | assert_eq!(desktop_names_to_xdg("one;"), "one"); 372 | assert_eq!(desktop_names_to_xdg(""), ""); 373 | assert_eq!(desktop_names_to_xdg(";"), ""); 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tuigreet 2 | 3 | Graphical console greeter for [greetd](https://git.sr.ht/~kennylevinsen/greetd). 4 | 5 | ![Screenshot of tuigreet](https://github.com/apognu/tuigreet/blob/master/contrib/screenshot.png) 6 | 7 | ``` 8 | Usage: tuigreet [OPTIONS] 9 | 10 | Options: 11 | -h, --help show this usage information 12 | -v, --version print version information 13 | -d, --debug [FILE] enable debug logging to the provided file, or to 14 | /tmp/tuigreet.log 15 | -c, --cmd COMMAND command to run 16 | --env KEY=VALUE environment variables to run the default session with 17 | (can appear more than once) 18 | -s, --sessions DIRS colon-separated list of Wayland session paths 19 | --session-wrapper 'CMD [ARGS]...' 20 | wrapper command to initialize the non-X11 session 21 | -x, --xsessions DIRS 22 | colon-separated list of X11 session paths 23 | --xsession-wrapper 'CMD [ARGS]...' 24 | wrapper command to initialize X server and launch X11 25 | sessions (default: startx /usr/bin/env) 26 | --no-xsession-wrapper 27 | do not wrap commands for X11 sessions 28 | -w, --width WIDTH width of the main prompt (default: 80) 29 | -i, --issue show the host's issue file 30 | -g, --greeting GREETING 31 | show custom text above login prompt 32 | -t, --time display the current date and time 33 | --time-format FORMAT 34 | custom strftime format for displaying date and time 35 | -r, --remember remember last logged-in username 36 | --remember-session 37 | remember last selected session 38 | --remember-user-session 39 | remember last selected session for each user 40 | --user-menu allow graphical selection of users from a menu 41 | --user-menu-min-uid UID 42 | minimum UID to display in the user selection menu 43 | --user-menu-max-uid UID 44 | maximum UID to display in the user selection menu 45 | --theme THEME define the application theme colors 46 | --asterisks display asterisks when a secret is typed 47 | --asterisks-char CHARS 48 | characters to be used to redact secrets (default: *) 49 | --window-padding PADDING 50 | padding inside the terminal area (default: 0) 51 | --container-padding PADDING 52 | padding inside the main prompt container (default: 1) 53 | --prompt-padding PADDING 54 | padding between prompt rows (default: 1) 55 | --greet-align [left|center|right] 56 | alignment of the greeting text in the main prompt 57 | container (default: 'center') 58 | --power-shutdown 'CMD [ARGS]...' 59 | command to run to shut down the system 60 | --power-reboot 'CMD [ARGS]...' 61 | command to run to reboot the system 62 | --power-no-setsid 63 | do not prefix power commands with setsid 64 | --kb-command [1-12] 65 | F-key to use to open the command menu 66 | --kb-sessions [1-12] 67 | F-key to use to open the sessions menu 68 | --kb-power [1-12] 69 | F-key to use to open the power menu 70 | ``` 71 | 72 | ## Usage 73 | 74 | The default configuration tends to be as minimal as possible, visually speaking, only showing the authentication prompts and some minor information in the status bar. You may print your system's `/etc/issue` at the top of the prompt with `--issue` and the current date and time with `--time` (and possibly customize it with `--time-format`). You may include a custom one-line greeting message instead of `/etc/issue` with `--greeting`. 75 | 76 | The initial prompt container will be 80 column wide. You may change this with `--width` in case you need more space (for example, to account for large PAM challenge messages). Please refer to usage information (`--help`) for more customization options. Various padding settings are available through the `*-padding` options. 77 | 78 | You can instruct `tuigreet` to remember the last username that successfully opened a session with the `--remember` option (that way, the username field will be pre-filled). Similarly, the command and session configuration can be retained between runs with the `--remember-session` option (when using this, the `--cmd` value is overridden by manual selections). You can also remember the selected session per user with the `--remember-user-session` flag. In this case, the selected session will only be saved on successful authentication. Check the [cache instructions](#cache-instructions) if `/var/cache/tuigreet` doesn't exist after installing tuigreet. 79 | 80 | You may change the command that will be executed after opening a session by hitting `F2` and amending the command. Alternatively, you can list the system-declared sessions (or custom ones) by hitting `F3`. Power options are available through `F12`. 81 | 82 | ## Install 83 | 84 | ### From source 85 | 86 | Building from source requires an installation of Rust's `stable` toolchain, including `cargo`. 87 | 88 | ``` 89 | $ git clone https://github.com/apognu/tuigreet && cd tuigreet 90 | $ cargo build --release 91 | # mv target/release/tuigreet /usr/local/bin/tuigreet 92 | ``` 93 | 94 | 95 | Cache directory must be created for `--remember*` features to work. The directory must be owned by the user running the greeter. 96 | 97 | ``` 98 | # mkdir /var/cache/tuigreet 99 | # chown greeter:greeter /var/cache/tuigreet 100 | # chmod 0755 /var/cache/tuigreet 101 | ``` 102 | 103 | ### From Arch Linux 104 | 105 | On ArchLinux, `tuigreet` is available from the [extra](https://archlinux.org/packages/extra/x86_64/greetd-tuigreet/) repo and is installable through pacman: 106 | 107 | ``` 108 | $ pacman -S greetd-tuigreet 109 | ``` 110 | 111 | Two more distributions are available from the [AUR](https://aur.archlinux.org/packages?O=0&K=tuigreet): `greetd-tuigreet-bin` is the precompiled release for the latest tagged release of `tuigreet` and `greetd-tuigreet-git` is a rolling release always following the `master` branch of this repository. 112 | Those can be installed via your preferred AUR helper. 113 | 114 | ### From Gentoo 115 | 116 | On Gentoo, `tuigreet` is available as a package `gui-apps/tuigreet`: 117 | 118 | ``` 119 | $ emerge --ask --verbose gui-apps/tuigreet 120 | ``` 121 | 122 | ### From NixOS 123 | 124 | On NixOS `greetd` and `tuigreet` both available via `` main repository. 125 | Please refer to the snippet below for the minimal `tuigreet` configuration: 126 | 127 | ```nix 128 | { pkgs, ... }: 129 | { 130 | services.greetd = { 131 | enable = true; 132 | settings = { 133 | default_session = { 134 | command = "${pkgs.greetd.tuigreet}/bin/tuigreet --time --cmd sway"; 135 | user = "greeter"; 136 | }; 137 | }; 138 | }; 139 | } 140 | ``` 141 | 142 | [More details](https://search.nixos.org/options?channel=unstable&show=services.greetd.settings&from=0&size=50&sort=relevance&query=greetd) 143 | 144 | ### Pre-built binaries 145 | 146 | Pre-built binaries of `tuigreet` for several architectures can be found in the [releases](https://github.com/apognu/tuigreet/releases) section of this repository. The [tip prerelease](https://github.com/apognu/tuigreet/releases/tag/tip) is continuously built and kept in sync with the `master` branch. 147 | 148 | ## Running the tests 149 | 150 | Tests from the default features should run without any special consideration by running `cargo test`. 151 | 152 | If you intend to run the whole test suite, you will need to perform some setup. One of our features uses NSS to list and filter existing users on the system, and in order not to rely on actual users being created on the host, we use [libnss_wrapper](https://cwrap.org/nss_wrapper.html) to mock responses from NSS. Without this, the tests would use the real user list from your system and probably fail because it cannot find the one it looks for. 153 | 154 | After installing `libnss_wrapper` on your system (or compiling it to get the `.so`), you can run those specific tests as such: 155 | 156 | ``` 157 | $ export NSS_WRAPPER_PASSWD=contrib/fixtures/passwd 158 | $ export NSS_WRAPPER_GROUP=contrib/fixtures/group 159 | $ LD_PRELOAD=/path/to/libnss_wrapper.so cargo test --features nsswrapper nsswrapper_ # To run those tests specifically 160 | $ LD_PRELOAD=/path/to/libnss_wrapper.so cargo test --all-features # To run the whole test suite 161 | ``` 162 | 163 | ## Configuration 164 | 165 | Edit `/etc/greetd/config.toml` and set the `command` setting to use `tuigreet`: 166 | 167 | ``` 168 | [terminal] 169 | vt = 1 170 | 171 | [default_session] 172 | command = "tuigreet --cmd sway" 173 | user = "greeter" 174 | ``` 175 | 176 | Please refer to [greetd's wiki](https://man.sr.ht/~kennylevinsen/greetd/) for more information on setting up `greetd`. 177 | 178 | ### Sessions 179 | 180 | The available sessions are fetched from `desktop` files in `/usr/share/xsessions` and `/usr/share/wayland-sessions`. If you want to provide custom directories, you can set the `--sessions` arguments with a colon-separated list of directories for `tuigreet` to fetch session definitions some other place. 181 | 182 | #### Desktop environments 183 | 184 | `greetd` only accepts environment-less commands to be used to start a session. Therefore, if your desktop environment requires either arguments or environment variables, you will need to create a wrapper script and refer to it in an appropriate desktop file. 185 | 186 | For example, to run X11 Gnome, you may need to start it through `startx` and configure your `~/.xinitrc` (or an external `xinitrc` with a wrapper script): 187 | 188 | ``` 189 | exec gnome-session 190 | ``` 191 | 192 | To run Wayland Gnome, you would need to create a wrapper script akin to the following: 193 | 194 | ``` 195 | XDG_SESSION_TYPE=wayland dbus-run-session gnome-session 196 | ``` 197 | 198 | Then refer to your wrapper script in a custom desktop file (in a directory declared with the `-s/--sessions` option): 199 | 200 | ``` 201 | Name=Wayland Gnome 202 | Exec=/path/to/my/wrapper.sh 203 | ``` 204 | 205 | #### Common wrappers 206 | 207 | Two options allows you to automatically wrap run commands around sessions started from desktop files, depending on whether they come `/usr/share/wayland-sessions` or `/usr/share/xsessions`: `--sessions-wrapper` and `--xsessions-wrapper`. With this, you can prepend another command on front of the sessions you run to set up the required environment to run these kinds of sessions. 208 | 209 | By default, unless you change it, all X11 sessions (those picked up from `/usr/share/xsessions`) are prepended with `startx /usr/bin/env`, so the X11 server is started properly. 210 | 211 | ### Power management 212 | 213 | Two power actions are possible from `tuigreet`, shutting down (through `shutdown -h now`) and rebooting (with `shutdown -r now`) the machine. This requires that those commands be executable by regular users, which is not the case on some distros. 214 | 215 | To alleviate this, there are two options that can be used to customize the commands that are run: `--power-shutdown` and `--power-reboot`. The provided commands must be non-interactive, meaning they will not be able to print anything or prompt for anything. If you need to use `sudo` or `doas`, they will need to be configured to run passwordless for those specific commands. 216 | 217 | An example for `/etc/greetd/config.toml`: 218 | 219 | ``` 220 | [default_session] 221 | command = "tuigreet --power-shutdown 'sudo systemctl poweroff'" 222 | ``` 223 | 224 | Note that, by default, all commands are prefixed with `setsid` to completely detach the command from our TTY. If you would prefer to run the commands as is, or if `setsid` does not exist on your system, you can use `--power-no-setsid`. 225 | 226 | ### User menu 227 | 228 | Optionally, a user can be selected from a menu instead of typing out their name, with the `--user-menu` option, this will present all users returned by NSS at the time `tuigreet` was run, with a UID within the acceptable range. The values for the minimum and maximum UIDs are selected as follows, for each value: 229 | 230 | * A user-provided value, through `--user-menu-min-uid` or `--user-menu-max-uid`; 231 | * **Or**, the available values for `UID_MIN` or `UID_MAX` from `/etc/login.defs`; 232 | * **Or**, hardcoded `1000` for minimum UID and `60000` for maximum UID. 233 | 234 | ### Theming 235 | 236 | A theme specification can be given through the `--theme` argument to control some of the colors used to draw the UI. This specification string must have the following format: `component1=color;component2=color[;...]` where the component is one of the value listed in the table below, and the color is a valid ANSI color name as listed [here](https://github.com/ratatui-org/ratatui/blob/main/src/style/color.rs#L15). 237 | 238 | Mind that the specification string include semicolons, which are command delimiters in most shells, hence, you should enclose it in single-quotes so it is considered a single argument instead. 239 | 240 | Please note that we can only render colors as supported by the running terminal. In the case of the Linux virtual console, those colors might not look as good as one may think. Your mileage may vary. 241 | 242 | | Component name | Description | 243 | | -------------- | ---------------------------------------------------------------------------------- | 244 | | text | Base text color other than those specified below | 245 | | time | Color of the date and time. If unspecified, falls back to `text` | 246 | | container | Background color for the centered containers used throughout the app | 247 | | border | Color of the borders of those containers | 248 | | title | Color of the containers' titles. If unspecified, falls back to `border` | 249 | | greet | Color of the issue of greeting message. If unspecified, falls back to `text` | 250 | | prompt | Color of the prompt ("Username:", etc.) | 251 | | input | Color of user input feedback | 252 | | action | Color of the actions displayed at the bottom of the screen | 253 | | button | Color of the keybindings for those actions. If unspecified, falls back to `action` | 254 | 255 | Below is a screenshot of the greeter with the following theme applied: `border=magenta;text=cyan;prompt=green;time=red;action=blue;button=yellow;container=black;input=red`: 256 | 257 | ![Screenshot of tuigreet](https://github.com/apognu/tuigreet/blob/master/contrib/screenshot-themed.png) 258 | -------------------------------------------------------------------------------- /src/keyboard.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, sync::Arc}; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 4 | use greetd_ipc::Request; 5 | use tokio::sync::RwLock; 6 | 7 | use crate::{ 8 | info::{delete_last_command, delete_last_session, get_last_user_command, get_last_user_session, write_last_command, write_last_session_path}, 9 | ipc::Ipc, 10 | power::power, 11 | ui::{ 12 | common::masked::MaskedString, 13 | sessions::{Session, SessionSource}, 14 | users::User, 15 | }, 16 | Greeter, Mode, 17 | }; 18 | 19 | // Act on keyboard events. 20 | // 21 | // This function will be called whenever a keyboard event was captured by the 22 | // application. It takes a reference to the `Greeter` so it can be aware of the 23 | // current state of the application and act accordinly; It also receives the 24 | // `Ipc` interface so it is able to interact with `greetd` if necessary. 25 | pub async fn handle(greeter: Arc>, input: KeyEvent, ipc: Ipc) -> Result<(), Box> { 26 | let mut greeter = greeter.write().await; 27 | 28 | if greeter.working { 29 | return Ok(()); 30 | } 31 | 32 | match input { 33 | // ^U should erase the current buffer. 34 | KeyEvent { 35 | code: KeyCode::Char('u'), 36 | modifiers: KeyModifiers::CONTROL, 37 | .. 38 | } => match greeter.mode { 39 | Mode::Username => greeter.username = MaskedString::default(), 40 | Mode::Password => greeter.buffer = String::new(), 41 | Mode::Command => greeter.buffer = String::new(), 42 | _ => {} 43 | }, 44 | 45 | // In debug mode only, ^X will exit the application. 46 | #[cfg(debug_assertions)] 47 | KeyEvent { 48 | code: KeyCode::Char('x'), 49 | modifiers: KeyModifiers::CONTROL, 50 | .. 51 | } => { 52 | use crate::{AuthStatus, Event}; 53 | 54 | if let Some(ref sender) = greeter.events { 55 | let _ = sender.send(Event::Exit(AuthStatus::Cancel)).await; 56 | } 57 | } 58 | 59 | // Depending on the active screen, pressing Escape will either return to the 60 | // previous mode (close a popup, for example), or cancel the `greetd` 61 | // session. 62 | KeyEvent { code: KeyCode::Esc, .. } => match greeter.mode { 63 | Mode::Command => { 64 | greeter.mode = greeter.previous_mode; 65 | greeter.buffer = greeter.previous_buffer.take().unwrap_or_default(); 66 | greeter.cursor_offset = 0; 67 | } 68 | 69 | Mode::Users | Mode::Sessions | Mode::Power => { 70 | greeter.mode = greeter.previous_mode; 71 | } 72 | 73 | _ => { 74 | Ipc::cancel(&mut greeter).await; 75 | greeter.reset(false).await; 76 | } 77 | }, 78 | 79 | // Simple cursor directions in text fields. 80 | KeyEvent { code: KeyCode::Left, .. } => greeter.cursor_offset -= 1, 81 | KeyEvent { code: KeyCode::Right, .. } => greeter.cursor_offset += 1, 82 | 83 | // F2 will display the command entry prompt. If we are already in one of the 84 | // popup screens, we set the previous screen as being the current previous 85 | // screen. 86 | KeyEvent { code: KeyCode::F(i), .. } if i == greeter.kb_command => { 87 | greeter.previous_mode = match greeter.mode { 88 | Mode::Users | Mode::Command | Mode::Sessions | Mode::Power => greeter.previous_mode, 89 | _ => greeter.mode, 90 | }; 91 | 92 | // Set the edition buffer to the current command. 93 | greeter.previous_buffer = Some(greeter.buffer.clone()); 94 | greeter.buffer = greeter.session_source.command(&greeter).map(str::to_string).unwrap_or_default(); 95 | greeter.cursor_offset = 0; 96 | greeter.mode = Mode::Command; 97 | } 98 | 99 | // F3 will display the session selection menu. If we are already in one of 100 | // the popup screens, we set the previous screen as being the current 101 | // previous screen. 102 | KeyEvent { code: KeyCode::F(i), .. } if i == greeter.kb_sessions => { 103 | greeter.previous_mode = match greeter.mode { 104 | Mode::Users | Mode::Command | Mode::Sessions | Mode::Power => greeter.previous_mode, 105 | _ => greeter.mode, 106 | }; 107 | 108 | greeter.mode = Mode::Sessions; 109 | } 110 | 111 | // F12 will display the user selection menu. If we are already in one of the 112 | // popup screens, we set the previous screen as being the current previous 113 | // screen. 114 | KeyEvent { code: KeyCode::F(i), .. } if i == greeter.kb_power => { 115 | greeter.previous_mode = match greeter.mode { 116 | Mode::Users | Mode::Command | Mode::Sessions | Mode::Power => greeter.previous_mode, 117 | _ => greeter.mode, 118 | }; 119 | 120 | greeter.mode = Mode::Power; 121 | } 122 | 123 | // Handle moving up in menus. 124 | KeyEvent { code: KeyCode::Up, .. } => { 125 | if let Mode::Users = greeter.mode { 126 | if greeter.users.selected > 0 { 127 | greeter.users.selected -= 1; 128 | } 129 | } 130 | 131 | if let Mode::Sessions = greeter.mode { 132 | if greeter.sessions.selected > 0 { 133 | greeter.sessions.selected -= 1; 134 | } 135 | } 136 | 137 | if let Mode::Power = greeter.mode { 138 | if greeter.powers.selected > 0 { 139 | greeter.powers.selected -= 1; 140 | } 141 | } 142 | } 143 | 144 | // Handle moving down in menus. 145 | KeyEvent { code: KeyCode::Down, .. } => { 146 | if let Mode::Users = greeter.mode { 147 | if greeter.users.selected < greeter.users.options.len() - 1 { 148 | greeter.users.selected += 1; 149 | } 150 | } 151 | 152 | if let Mode::Sessions = greeter.mode { 153 | if greeter.sessions.selected < greeter.sessions.options.len() - 1 { 154 | greeter.sessions.selected += 1; 155 | } 156 | } 157 | 158 | if let Mode::Power = greeter.mode { 159 | if greeter.powers.selected < greeter.powers.options.len() - 1 { 160 | greeter.powers.selected += 1; 161 | } 162 | } 163 | } 164 | 165 | // ^A should go to the start of the current prompt 166 | KeyEvent { 167 | code: KeyCode::Char('a'), 168 | modifiers: KeyModifiers::CONTROL, 169 | .. 170 | } => { 171 | let value = { 172 | match greeter.mode { 173 | Mode::Username => &greeter.username.value, 174 | _ => &greeter.buffer, 175 | } 176 | }; 177 | 178 | greeter.cursor_offset = -(value.chars().count() as i16); 179 | } 180 | 181 | // ^A should go to the end of the current prompt 182 | KeyEvent { 183 | code: KeyCode::Char('e'), 184 | modifiers: KeyModifiers::CONTROL, 185 | .. 186 | } => greeter.cursor_offset = 0, 187 | 188 | // Tab should validate the username entry (same as Enter). 189 | KeyEvent { code: KeyCode::Tab, .. } => match greeter.mode { 190 | Mode::Username if !greeter.username.value.is_empty() => validate_username(&mut greeter, &ipc).await, 191 | _ => {} 192 | }, 193 | 194 | // Enter validates the current entry, depending on the active mode. 195 | KeyEvent { code: KeyCode::Enter, .. } => match greeter.mode { 196 | Mode::Username if !greeter.username.value.is_empty() => validate_username(&mut greeter, &ipc).await, 197 | 198 | Mode::Username if greeter.user_menu => { 199 | greeter.previous_mode = match greeter.mode { 200 | Mode::Users | Mode::Command | Mode::Sessions | Mode::Power => greeter.previous_mode, 201 | _ => greeter.mode, 202 | }; 203 | 204 | greeter.buffer = greeter.previous_buffer.take().unwrap_or_default(); 205 | greeter.mode = Mode::Users; 206 | } 207 | 208 | Mode::Username => {} 209 | 210 | Mode::Password => { 211 | greeter.working = true; 212 | greeter.message = None; 213 | 214 | ipc 215 | .send(Request::PostAuthMessageResponse { 216 | response: Some(greeter.buffer.clone()), 217 | }) 218 | .await; 219 | 220 | greeter.buffer = String::new(); 221 | } 222 | 223 | Mode::Command => { 224 | greeter.sessions.selected = 0; 225 | greeter.session_source = SessionSource::Command(greeter.buffer.clone()); 226 | 227 | if greeter.remember_session { 228 | write_last_command(&greeter.buffer); 229 | delete_last_session(); 230 | } 231 | 232 | greeter.buffer = greeter.previous_buffer.take().unwrap_or_default(); 233 | greeter.mode = greeter.previous_mode; 234 | } 235 | 236 | Mode::Users => { 237 | let username = greeter.users.options.get(greeter.users.selected).cloned(); 238 | 239 | if let Some(User { username, name }) = username { 240 | greeter.username = MaskedString::from(username, name); 241 | } 242 | 243 | greeter.mode = greeter.previous_mode; 244 | 245 | validate_username(&mut greeter, &ipc).await; 246 | } 247 | 248 | Mode::Sessions => { 249 | let session = greeter.sessions.options.get(greeter.sessions.selected).cloned(); 250 | 251 | if let Some(Session { path, .. }) = session { 252 | if greeter.remember_session { 253 | if let Some(ref path) = path { 254 | write_last_session_path(path); 255 | delete_last_command(); 256 | } 257 | } 258 | 259 | greeter.session_source = SessionSource::Session(greeter.sessions.selected); 260 | } 261 | 262 | greeter.mode = greeter.previous_mode; 263 | } 264 | 265 | Mode::Power => { 266 | let power_command = greeter.powers.options.get(greeter.powers.selected).cloned(); 267 | 268 | if let Some(command) = power_command { 269 | power(&mut greeter, command.action).await; 270 | } 271 | 272 | greeter.mode = greeter.previous_mode; 273 | } 274 | 275 | _ => {} 276 | }, 277 | 278 | // Do not handle any other controls keybindings 279 | KeyEvent { modifiers: KeyModifiers::CONTROL, .. } => {} 280 | 281 | // Handle free-form entry of characters. 282 | KeyEvent { code: KeyCode::Char(c), .. } => insert_key(&mut greeter, c).await, 283 | 284 | // Handle deletion of characters. 285 | KeyEvent { code: KeyCode::Backspace, .. } | KeyEvent { code: KeyCode::Delete, .. } => delete_key(&mut greeter, input.code).await, 286 | 287 | _ => {} 288 | } 289 | 290 | Ok(()) 291 | } 292 | 293 | // Handle insertion of characters into the proper buffer, depending on the 294 | // current mode and the position of the cursor. 295 | async fn insert_key(greeter: &mut Greeter, c: char) { 296 | let value = match greeter.mode { 297 | Mode::Username => &greeter.username.value, 298 | Mode::Password => &greeter.buffer, 299 | Mode::Command => &greeter.buffer, 300 | _ => return, 301 | }; 302 | 303 | let index = (value.chars().count() as i16 + greeter.cursor_offset) as usize; 304 | let left = value.chars().take(index); 305 | let right = value.chars().skip(index); 306 | 307 | let value = left.chain(vec![c].into_iter()).chain(right).collect(); 308 | let mode = greeter.mode; 309 | 310 | match mode { 311 | Mode::Username => greeter.username.value = value, 312 | Mode::Password => greeter.buffer = value, 313 | Mode::Command => greeter.buffer = value, 314 | _ => {} 315 | }; 316 | } 317 | 318 | // Handle deletion of characters from a prompt into the proper buffer, depending 319 | // on the current mode, whether Backspace or Delete was pressed and the position 320 | // of the cursor. 321 | async fn delete_key(greeter: &mut Greeter, key: KeyCode) { 322 | let value = match greeter.mode { 323 | Mode::Username => &greeter.username.value, 324 | Mode::Password => &greeter.buffer, 325 | Mode::Command => &greeter.buffer, 326 | _ => return, 327 | }; 328 | 329 | let index = match key { 330 | KeyCode::Backspace => (value.chars().count() as i16 + greeter.cursor_offset - 1) as usize, 331 | KeyCode::Delete => (value.chars().count() as i16 + greeter.cursor_offset) as usize, 332 | _ => 0, 333 | }; 334 | 335 | if value.chars().nth(index).is_some() { 336 | let left = value.chars().take(index); 337 | let right = value.chars().skip(index + 1); 338 | 339 | let value = left.chain(right).collect(); 340 | 341 | match greeter.mode { 342 | Mode::Username => greeter.username.value = value, 343 | Mode::Password => greeter.buffer = value, 344 | Mode::Command => greeter.buffer = value, 345 | _ => return, 346 | }; 347 | 348 | if let KeyCode::Delete = key { 349 | greeter.cursor_offset += 1; 350 | } 351 | } 352 | } 353 | 354 | // Creates a `greetd` session for the provided username. 355 | async fn validate_username(greeter: &mut Greeter, ipc: &Ipc) { 356 | greeter.working = true; 357 | greeter.message = None; 358 | 359 | ipc 360 | .send(Request::CreateSession { 361 | username: greeter.username.value.clone(), 362 | }) 363 | .await; 364 | greeter.buffer = String::new(); 365 | 366 | if greeter.remember_user_session { 367 | if let Ok(last_session) = get_last_user_session(&greeter.username.value) { 368 | if let Some(last_session) = Session::from_path(greeter, last_session).cloned() { 369 | tracing::info!("remembered user session is {}", last_session.name); 370 | 371 | greeter.sessions.selected = greeter.sessions.options.iter().position(|sess| sess.path == last_session.path).unwrap_or(0); 372 | greeter.session_source = SessionSource::Session(greeter.sessions.selected); 373 | } 374 | } 375 | 376 | if let Ok(command) = get_last_user_command(&greeter.username.value) { 377 | tracing::info!("remembered user command is {}", command); 378 | 379 | greeter.session_source = SessionSource::Command(command); 380 | } 381 | } 382 | } 383 | 384 | #[cfg(test)] 385 | mod test { 386 | use std::sync::Arc; 387 | 388 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 389 | use tokio::sync::RwLock; 390 | 391 | use super::handle; 392 | use crate::{ 393 | ipc::Ipc, 394 | ui::{common::masked::MaskedString, sessions::SessionSource}, 395 | Greeter, Mode, 396 | }; 397 | 398 | #[tokio::test] 399 | async fn ctrl_u() { 400 | let greeter = Arc::new(RwLock::new(Greeter::default())); 401 | 402 | { 403 | let mut greeter = greeter.write().await; 404 | greeter.mode = Mode::Username; 405 | greeter.username = MaskedString::from("apognu".to_string(), None); 406 | } 407 | 408 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), Ipc::new()).await; 409 | 410 | { 411 | let status = greeter.read().await; 412 | 413 | assert!(matches!(result, Ok(_))); 414 | assert_eq!(status.username.value, "".to_string()); 415 | } 416 | 417 | { 418 | let mut greeter = greeter.write().await; 419 | greeter.mode = Mode::Password; 420 | greeter.buffer = "password".to_string(); 421 | } 422 | 423 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), Ipc::new()).await; 424 | 425 | { 426 | let status = greeter.read().await; 427 | 428 | assert!(matches!(result, Ok(_))); 429 | assert_eq!(status.buffer, "".to_string()); 430 | } 431 | 432 | { 433 | let mut greeter = greeter.write().await; 434 | greeter.mode = Mode::Command; 435 | greeter.buffer = "newcommand".to_string(); 436 | } 437 | 438 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), Ipc::new()).await; 439 | 440 | { 441 | let status = greeter.read().await; 442 | 443 | assert!(matches!(result, Ok(_))); 444 | assert_eq!(status.buffer, "".to_string()); 445 | } 446 | } 447 | 448 | #[tokio::test] 449 | async fn escape() { 450 | let greeter = Arc::new(RwLock::new(Greeter::default())); 451 | 452 | { 453 | let mut greeter = greeter.write().await; 454 | greeter.previous_mode = Mode::Username; 455 | greeter.mode = Mode::Command; 456 | greeter.previous_buffer = Some("apognu".to_string()); 457 | greeter.buffer = "newcommand".to_string(); 458 | greeter.cursor_offset = 2; 459 | } 460 | 461 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), Ipc::new()).await; 462 | 463 | { 464 | let status = greeter.read().await; 465 | 466 | assert!(matches!(result, Ok(_))); 467 | assert_eq!(status.mode, Mode::Username); 468 | assert_eq!(status.buffer, "apognu".to_string()); 469 | assert!(matches!(status.previous_buffer, None)); 470 | assert_eq!(status.cursor_offset, 0); 471 | } 472 | 473 | for mode in [Mode::Users, Mode::Sessions, Mode::Power] { 474 | { 475 | let mut greeter = greeter.write().await; 476 | greeter.previous_mode = Mode::Username; 477 | greeter.mode = mode; 478 | } 479 | 480 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), Ipc::new()).await; 481 | 482 | { 483 | let status = greeter.read().await; 484 | 485 | assert!(matches!(result, Ok(_))); 486 | assert_eq!(status.mode, Mode::Username); 487 | } 488 | } 489 | } 490 | 491 | #[tokio::test] 492 | async fn left_right() { 493 | let greeter = Arc::new(RwLock::new(Greeter::default())); 494 | 495 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), Ipc::new()).await; 496 | 497 | { 498 | let status = greeter.read().await; 499 | 500 | assert!(matches!(result, Ok(_))); 501 | assert_eq!(status.cursor_offset, -1); 502 | } 503 | 504 | let _ = handle(greeter.clone(), KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), Ipc::new()).await; 505 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), Ipc::new()).await; 506 | 507 | { 508 | let status = greeter.read().await; 509 | 510 | assert!(matches!(result, Ok(_))); 511 | assert_eq!(status.cursor_offset, 1); 512 | } 513 | } 514 | 515 | #[tokio::test] 516 | async fn f2() { 517 | let greeter = Arc::new(RwLock::new(Greeter::default())); 518 | 519 | { 520 | let mut greeter = greeter.write().await; 521 | greeter.mode = Mode::Username; 522 | greeter.buffer = "apognu".to_string(); 523 | greeter.session_source = SessionSource::Command("thecommand".to_string()); 524 | } 525 | 526 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::F(2), KeyModifiers::empty()), Ipc::new()).await; 527 | 528 | { 529 | let status = greeter.read().await; 530 | 531 | assert!(matches!(result, Ok(_))); 532 | assert_eq!(status.mode, Mode::Command); 533 | assert_eq!(status.previous_buffer, Some("apognu".to_string())); 534 | assert_eq!(status.buffer, "thecommand".to_string()); 535 | } 536 | 537 | for mode in [Mode::Users, Mode::Sessions, Mode::Power] { 538 | { 539 | let mut greeter = greeter.write().await; 540 | greeter.previous_mode = Mode::Username; 541 | greeter.mode = mode; 542 | } 543 | 544 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::F(2), KeyModifiers::empty()), Ipc::new()).await; 545 | 546 | { 547 | let status = greeter.read().await; 548 | 549 | assert!(matches!(result, Ok(_))); 550 | assert_eq!(status.mode, Mode::Command); 551 | assert_eq!(status.previous_mode, Mode::Username); 552 | } 553 | } 554 | } 555 | 556 | #[tokio::test] 557 | async fn f_menu() { 558 | let greeter = Arc::new(RwLock::new(Greeter::default())); 559 | 560 | for (key, mode) in [(KeyCode::F(3), Mode::Sessions), (KeyCode::F(12), Mode::Power)] { 561 | { 562 | let mut greeter = greeter.write().await; 563 | greeter.mode = Mode::Username; 564 | greeter.buffer = "apognu".to_string(); 565 | } 566 | 567 | let result = handle(greeter.clone(), KeyEvent::new(key, KeyModifiers::empty()), Ipc::new()).await; 568 | 569 | { 570 | let status = greeter.read().await; 571 | 572 | assert!(matches!(result, Ok(_))); 573 | assert_eq!(status.mode, mode); 574 | assert_eq!(status.buffer, "apognu".to_string()); 575 | } 576 | 577 | for mode in [Mode::Users, Mode::Sessions, Mode::Power] { 578 | { 579 | let mut greeter = greeter.write().await; 580 | greeter.previous_mode = Mode::Username; 581 | greeter.mode = mode; 582 | } 583 | 584 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::F(2), KeyModifiers::empty()), Ipc::new()).await; 585 | 586 | { 587 | let status = greeter.read().await; 588 | 589 | assert!(matches!(result, Ok(_))); 590 | assert_eq!(status.mode, Mode::Command); 591 | assert_eq!(status.previous_mode, Mode::Username); 592 | } 593 | } 594 | } 595 | } 596 | 597 | #[tokio::test] 598 | async fn f_menu_rebinded() { 599 | let greeter = Arc::new(RwLock::new(Greeter::default())); 600 | 601 | for (key, mode) in [(KeyCode::F(1), Mode::Sessions), (KeyCode::F(11), Mode::Power)] { 602 | { 603 | let mut greeter = greeter.write().await; 604 | greeter.kb_command = 3; 605 | greeter.kb_sessions = 1; 606 | greeter.kb_power = 11; 607 | greeter.mode = Mode::Username; 608 | greeter.buffer = "apognu".to_string(); 609 | } 610 | 611 | let result = handle(greeter.clone(), KeyEvent::new(key, KeyModifiers::empty()), Ipc::new()).await; 612 | 613 | { 614 | let status = greeter.read().await; 615 | 616 | assert!(matches!(result, Ok(_))); 617 | assert_eq!(status.mode, mode); 618 | assert_eq!(status.buffer, "apognu".to_string()); 619 | } 620 | 621 | for mode in [Mode::Users, Mode::Sessions, Mode::Power] { 622 | { 623 | let mut greeter = greeter.write().await; 624 | greeter.previous_mode = Mode::Username; 625 | greeter.mode = mode; 626 | } 627 | 628 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::F(3), KeyModifiers::empty()), Ipc::new()).await; 629 | 630 | { 631 | let status = greeter.read().await; 632 | 633 | assert!(matches!(result, Ok(_))); 634 | assert_eq!(status.mode, Mode::Command); 635 | assert_eq!(status.previous_mode, Mode::Username); 636 | } 637 | } 638 | } 639 | } 640 | 641 | #[tokio::test] 642 | async fn ctrl_a_e() { 643 | let greeter = Arc::new(RwLock::new(Greeter::default())); 644 | 645 | { 646 | let mut greeter = greeter.write().await; 647 | greeter.mode = Mode::Command; 648 | greeter.buffer = "123456789".to_string(); 649 | } 650 | 651 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), Ipc::new()).await; 652 | 653 | { 654 | let status = greeter.read().await; 655 | 656 | assert!(matches!(result, Ok(_))); 657 | assert_eq!(status.cursor_offset, -9); 658 | } 659 | 660 | let result = handle(greeter.clone(), KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), Ipc::new()).await; 661 | 662 | { 663 | let status = greeter.read().await; 664 | 665 | assert!(matches!(result, Ok(_))); 666 | assert_eq!(status.cursor_offset, 0); 667 | } 668 | } 669 | } 670 | -------------------------------------------------------------------------------- /src/greeter.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | convert::TryInto, 3 | env, 4 | error::Error, 5 | ffi::OsStr, 6 | fmt::{self, Display}, 7 | path::PathBuf, 8 | process, 9 | sync::Arc, 10 | }; 11 | 12 | use chrono::{ 13 | format::{Item, StrftimeItems}, 14 | Locale, 15 | }; 16 | use getopts::{Matches, Options}; 17 | use i18n_embed::DesktopLanguageRequester; 18 | use tokio::{ 19 | net::UnixStream, 20 | sync::{mpsc::Sender, RwLock, RwLockWriteGuard}, 21 | }; 22 | use tracing_appender::non_blocking::WorkerGuard; 23 | use zeroize::Zeroize; 24 | 25 | use crate::{ 26 | event::Event, 27 | info::{get_issue, get_last_command, get_last_session_path, get_last_user_command, get_last_user_name, get_last_user_session, get_last_user_username, get_min_max_uids, get_sessions, get_users}, 28 | power::PowerOption, 29 | ui::{ 30 | common::{masked::MaskedString, menu::Menu, style::Theme}, 31 | power::Power, 32 | sessions::{Session, SessionSource, SessionType}, 33 | users::User, 34 | }, 35 | }; 36 | 37 | const DEFAULT_LOG_FILE: &str = "/tmp/tuigreet.log"; 38 | const DEFAULT_LOCALE: Locale = Locale::en_US; 39 | const DEFAULT_ASTERISKS_CHARS: &str = "*"; 40 | // `startx` wants an absolute path to the executable as a first argument. 41 | // We don't want to resolve the session command in the greeter though, so it should be additionally wrapped with a known noop command (like `/usr/bin/env`). 42 | const DEFAULT_XSESSION_WRAPPER: &str = "startx /usr/bin/env"; 43 | 44 | #[derive(Debug, Copy, Clone)] 45 | pub enum AuthStatus { 46 | Success, 47 | Failure, 48 | Cancel, 49 | } 50 | 51 | impl Display for AuthStatus { 52 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 53 | write!(f, "{:?}", self) 54 | } 55 | } 56 | 57 | impl Error for AuthStatus {} 58 | 59 | // A mode represents the large section of the software, usually screens to be 60 | // displayed, or the state of the application. 61 | #[derive(SmartDefault, Debug, Copy, Clone, PartialEq)] 62 | pub enum Mode { 63 | #[default] 64 | Username, 65 | Password, 66 | Action, 67 | Users, 68 | Command, 69 | Sessions, 70 | Power, 71 | Processing, 72 | } 73 | 74 | // This enum models how secret values should be displayed on terminal. 75 | #[derive(SmartDefault, Debug, Clone)] 76 | pub enum SecretDisplay { 77 | #[default] 78 | // All characters hidden. 79 | Hidden, 80 | // All characters are replaced by a placeholder character. 81 | Character(String), 82 | } 83 | 84 | impl SecretDisplay { 85 | pub fn show(&self) -> bool { 86 | match self { 87 | SecretDisplay::Hidden => false, 88 | SecretDisplay::Character(_) => true, 89 | } 90 | } 91 | } 92 | 93 | // This enum models text alignment options 94 | #[derive(SmartDefault, Debug, Clone)] 95 | pub enum GreetAlign { 96 | #[default] 97 | Center, 98 | Left, 99 | Right, 100 | } 101 | 102 | #[derive(SmartDefault)] 103 | pub struct Greeter { 104 | pub debug: bool, 105 | pub logfile: String, 106 | pub logger: Option, 107 | 108 | #[default(DEFAULT_LOCALE)] 109 | pub locale: Locale, 110 | pub config: Option, 111 | pub socket: String, 112 | pub stream: Option>>, 113 | pub events: Option>, 114 | 115 | // Current mode of the application, will define what actions are permitted. 116 | pub mode: Mode, 117 | // Mode the application will return to when exiting the current mode. 118 | pub previous_mode: Mode, 119 | // Offset the cursor should be at from its base position for the current mode. 120 | pub cursor_offset: i16, 121 | 122 | // Buffer to be used as a temporary editing zone for the various modes. 123 | // Previous buffer is saved when a transient screen has to use the buffer, to 124 | // be able to restore it when leaving the transient screen. 125 | pub previous_buffer: Option, 126 | pub buffer: String, 127 | 128 | // Define the selected session and how to resolve it. 129 | pub session_source: SessionSource, 130 | // List of session files found on disk. 131 | pub session_paths: Vec<(PathBuf, SessionType)>, 132 | // Menu for session selection. 133 | pub sessions: Menu, 134 | // Wrapper command to prepend to non-X11 sessions. 135 | pub session_wrapper: Option, 136 | // Wrapper command to prepend to X11 sessions. 137 | pub xsession_wrapper: Option, 138 | 139 | // Whether user menu is enabled. 140 | pub user_menu: bool, 141 | // Menu for user selection. 142 | pub users: Menu, 143 | // Current username. Masked to display the full name if available. 144 | pub username: MaskedString, 145 | // Prompt that should be displayed to ask for entry. 146 | pub prompt: Option, 147 | 148 | // Whether the current edition prompt should be hidden. 149 | pub asking_for_secret: bool, 150 | // How should secrets be displayed? 151 | pub secret_display: SecretDisplay, 152 | 153 | // Whether last logged-in user should be remembered. 154 | pub remember: bool, 155 | // Whether last launched session (regardless of user) should be remembered. 156 | pub remember_session: bool, 157 | // Whether last launched session for the current user should be remembered. 158 | pub remember_user_session: bool, 159 | 160 | // Style object for the terminal UI 161 | pub theme: Theme, 162 | // Display the current time 163 | pub time: bool, 164 | // Time format 165 | pub time_format: Option, 166 | // Greeting message (MOTD) to use to welcome the user. 167 | pub greeting: Option, 168 | // Transaction message to show to the user. 169 | pub message: Option, 170 | 171 | // Menu for power options. 172 | pub powers: Menu, 173 | // Whether to prefix the power commands with `setsid`. 174 | pub power_setsid: bool, 175 | 176 | #[default(2)] 177 | pub kb_command: u8, 178 | #[default(3)] 179 | pub kb_sessions: u8, 180 | #[default(12)] 181 | pub kb_power: u8, 182 | 183 | // The software is waiting for a response from `greetd`. 184 | pub working: bool, 185 | // We are done working. 186 | pub done: bool, 187 | // Should we exit? 188 | pub exit: Option, 189 | } 190 | 191 | impl Drop for Greeter { 192 | fn drop(&mut self) { 193 | self.scrub(true, false); 194 | } 195 | } 196 | 197 | impl Greeter { 198 | pub async fn new(events: Sender) -> Self { 199 | let mut greeter = Self::default(); 200 | 201 | greeter.events = Some(events); 202 | greeter.set_locale(); 203 | 204 | greeter.powers = Menu { 205 | title: fl!("title_power"), 206 | options: Default::default(), 207 | selected: 0, 208 | }; 209 | 210 | #[cfg(not(test))] 211 | { 212 | match env::var("GREETD_SOCK") { 213 | Ok(socket) => greeter.socket = socket, 214 | Err(_) => { 215 | eprintln!("GREETD_SOCK must be defined"); 216 | process::exit(1); 217 | } 218 | } 219 | 220 | let args = env::args().collect::>(); 221 | 222 | if let Err(err) = greeter.parse_options(&args).await { 223 | eprintln!("{err}"); 224 | print_usage(Greeter::options()); 225 | 226 | process::exit(1); 227 | } 228 | 229 | greeter.connect().await; 230 | } 231 | 232 | greeter.logger = crate::init_logger(&greeter); 233 | 234 | let sessions = get_sessions(&greeter).unwrap_or_default(); 235 | 236 | if let SessionSource::None = greeter.session_source { 237 | if !sessions.is_empty() { 238 | greeter.session_source = SessionSource::Session(0); 239 | } 240 | } 241 | 242 | greeter.sessions = Menu { 243 | title: fl!("title_session"), 244 | options: sessions, 245 | selected: 0, 246 | }; 247 | 248 | // If we should remember the last logged-in user. 249 | if greeter.remember { 250 | if let Some(username) = get_last_user_username() { 251 | greeter.username = MaskedString::from(username, get_last_user_name()); 252 | 253 | // If, on top of that, we should remember their last session. 254 | if greeter.remember_user_session { 255 | // See if we have the last free-form command from the user. 256 | if let Ok(command) = get_last_user_command(greeter.username.get()) { 257 | greeter.session_source = SessionSource::Command(command); 258 | } 259 | 260 | // If a session was saved, use it and its name. 261 | if let Ok(ref session_path) = get_last_user_session(greeter.username.get()) { 262 | // Set the selected menu option and the session source. 263 | if let Some(index) = greeter.sessions.options.iter().position(|Session { path, .. }| path.as_deref() == Some(session_path)) { 264 | greeter.sessions.selected = index; 265 | greeter.session_source = SessionSource::Session(greeter.sessions.selected); 266 | } 267 | } 268 | } 269 | } 270 | } 271 | 272 | // Same thing, but not user specific. 273 | if greeter.remember_session { 274 | if let Ok(command) = get_last_command() { 275 | greeter.session_source = SessionSource::Command(command.trim().to_string()); 276 | } 277 | 278 | if let Ok(ref session_path) = get_last_session_path() { 279 | if let Some(index) = greeter.sessions.options.iter().position(|Session { path, .. }| path.as_deref() == Some(session_path)) { 280 | greeter.sessions.selected = index; 281 | greeter.session_source = SessionSource::Session(greeter.sessions.selected); 282 | } 283 | } 284 | } 285 | 286 | greeter 287 | } 288 | 289 | // Scrub memory of all data, unless `soft` is true, in which case, we will 290 | // keep the username (can happen if a wrong password was entered, we want to 291 | // give the user another chance, as PAM would). 292 | fn scrub(&mut self, scrub_message: bool, soft: bool) { 293 | self.buffer.zeroize(); 294 | self.prompt.zeroize(); 295 | 296 | if !soft { 297 | self.username.zeroize(); 298 | } 299 | 300 | if scrub_message { 301 | self.message.zeroize(); 302 | } 303 | } 304 | 305 | // Reset the software to its initial state. 306 | pub async fn reset(&mut self, soft: bool) { 307 | if soft { 308 | self.mode = Mode::Password; 309 | self.previous_mode = Mode::Password; 310 | } else { 311 | self.mode = Mode::Username; 312 | self.previous_mode = Mode::Username; 313 | } 314 | 315 | self.working = false; 316 | self.done = false; 317 | 318 | self.scrub(false, soft); 319 | self.connect().await; 320 | } 321 | 322 | // Connect to `greetd` and return a stream we can safely write to. 323 | pub async fn connect(&mut self) { 324 | match UnixStream::connect(&self.socket).await { 325 | Ok(stream) => self.stream = Some(Arc::new(RwLock::new(stream))), 326 | 327 | Err(err) => { 328 | eprintln!("{err}"); 329 | process::exit(1); 330 | } 331 | } 332 | } 333 | 334 | pub fn config(&self) -> &Matches { 335 | self.config.as_ref().unwrap() 336 | } 337 | 338 | pub async fn stream(&self) -> RwLockWriteGuard<'_, UnixStream> { 339 | self.stream.as_ref().unwrap().write().await 340 | } 341 | 342 | pub fn option(&self, name: &str) -> Option { 343 | self.config().opt_str(name) 344 | } 345 | 346 | pub fn options_multi(&self, name: &str) -> Option> { 347 | match self.config().opt_present(name) { 348 | true => Some(self.config().opt_strs(name)), 349 | false => None, 350 | } 351 | } 352 | 353 | // Returns the width of the main window where content is displayed from the 354 | // provided arguments. 355 | pub fn width(&self) -> u16 { 356 | if let Some(value) = self.option("width") { 357 | if let Ok(width) = value.parse::() { 358 | return width; 359 | } 360 | } 361 | 362 | 80 363 | } 364 | 365 | // Returns the padding of the screen from the provided arguments. 366 | pub fn window_padding(&self) -> u16 { 367 | if let Some(value) = self.option("window-padding") { 368 | if let Ok(padding) = value.parse::() { 369 | return padding; 370 | } 371 | } 372 | 373 | 0 374 | } 375 | 376 | // Returns the padding of the main window where content is displayed from the 377 | // provided arguments. 378 | pub fn container_padding(&self) -> u16 { 379 | if let Some(value) = self.option("container-padding") { 380 | if let Ok(padding) = value.parse::() { 381 | return padding + 1; 382 | } 383 | } 384 | 385 | 2 386 | } 387 | 388 | // Returns the spacing between each prompt from the provided arguments. 389 | pub fn prompt_padding(&self) -> u16 { 390 | if let Some(value) = self.option("prompt-padding") { 391 | if let Ok(padding) = value.parse::() { 392 | return padding; 393 | } 394 | } 395 | 396 | 1 397 | } 398 | 399 | pub fn greet_align(&self) -> GreetAlign { 400 | if let Some(value) = self.option("greet-align") { 401 | match value.as_str() { 402 | "left" => GreetAlign::Left, 403 | "right" => GreetAlign::Right, 404 | _ => GreetAlign::Center, 405 | } 406 | } else { 407 | GreetAlign::default() 408 | } 409 | } 410 | 411 | // Sets the locale that will be used for this invocation from environment. 412 | fn set_locale(&mut self) { 413 | let locale = DesktopLanguageRequester::requested_languages() 414 | .into_iter() 415 | .next() 416 | .and_then(|locale| locale.region.map(|region| format!("{}_{region}", locale.language))) 417 | .and_then(|id| id.as_str().try_into().ok()); 418 | 419 | if let Some(locale) = locale { 420 | self.locale = locale; 421 | } 422 | } 423 | 424 | pub fn options() -> Options { 425 | let mut opts = Options::new(); 426 | 427 | let xsession_wrapper_desc = format!("wrapper command to initialize X server and launch X11 sessions (default: {DEFAULT_XSESSION_WRAPPER})"); 428 | 429 | opts.optflag("h", "help", "show this usage information"); 430 | opts.optflag("v", "version", "print version information"); 431 | opts.optflagopt("d", "debug", "enable debug logging to the provided file, or to /tmp/tuigreet.log", "FILE"); 432 | opts.optopt("c", "cmd", "command to run", "COMMAND"); 433 | opts.optmulti("", "env", "environment variables to run the default session with (can appear more than once)", "KEY=VALUE"); 434 | opts.optopt("s", "sessions", "colon-separated list of Wayland session paths", "DIRS"); 435 | opts.optopt("", "session-wrapper", "wrapper command to initialize the non-X11 session", "'CMD [ARGS]...'"); 436 | opts.optopt("x", "xsessions", "colon-separated list of X11 session paths", "DIRS"); 437 | opts.optopt("", "xsession-wrapper", xsession_wrapper_desc.as_str(), "'CMD [ARGS]...'"); 438 | opts.optflag("", "no-xsession-wrapper", "do not wrap commands for X11 sessions"); 439 | opts.optopt("w", "width", "width of the main prompt (default: 80)", "WIDTH"); 440 | opts.optflag("i", "issue", "show the host's issue file"); 441 | opts.optopt("g", "greeting", "show custom text above login prompt", "GREETING"); 442 | opts.optflag("t", "time", "display the current date and time"); 443 | opts.optopt("", "time-format", "custom strftime format for displaying date and time", "FORMAT"); 444 | opts.optflag("r", "remember", "remember last logged-in username"); 445 | opts.optflag("", "remember-session", "remember last selected session"); 446 | opts.optflag("", "remember-user-session", "remember last selected session for each user"); 447 | opts.optflag("", "user-menu", "allow graphical selection of users from a menu"); 448 | opts.optopt("", "user-menu-min-uid", "minimum UID to display in the user selection menu", "UID"); 449 | opts.optopt("", "user-menu-max-uid", "maximum UID to display in the user selection menu", "UID"); 450 | opts.optopt("", "theme", "define the application theme colors", "THEME"); 451 | opts.optflag("", "asterisks", "display asterisks when a secret is typed"); 452 | opts.optopt("", "asterisks-char", "characters to be used to redact secrets (default: *)", "CHARS"); 453 | opts.optopt("", "window-padding", "padding inside the terminal area (default: 0)", "PADDING"); 454 | opts.optopt("", "container-padding", "padding inside the main prompt container (default: 1)", "PADDING"); 455 | opts.optopt("", "prompt-padding", "padding between prompt rows (default: 1)", "PADDING"); 456 | opts.optopt( 457 | "", 458 | "greet-align", 459 | "alignment of the greeting text in the main prompt container (default: 'center')", 460 | "[left|center|right]", 461 | ); 462 | 463 | opts.optopt("", "power-shutdown", "command to run to shut down the system", "'CMD [ARGS]...'"); 464 | opts.optopt("", "power-reboot", "command to run to reboot the system", "'CMD [ARGS]...'"); 465 | opts.optflag("", "power-no-setsid", "do not prefix power commands with setsid"); 466 | 467 | opts.optopt("", "kb-command", "F-key to use to open the command menu", "[1-12]"); 468 | opts.optopt("", "kb-sessions", "F-key to use to open the sessions menu", "[1-12]"); 469 | opts.optopt("", "kb-power", "F-key to use to open the power menu", "[1-12]"); 470 | 471 | opts 472 | } 473 | 474 | // Parses command line arguments to configured the software accordingly. 475 | pub async fn parse_options(&mut self, args: &[S]) -> Result<(), Box> 476 | where 477 | S: AsRef, 478 | { 479 | let opts = Greeter::options(); 480 | 481 | self.config = match opts.parse(args) { 482 | Ok(matches) => Some(matches), 483 | Err(err) => return Err(err.into()), 484 | }; 485 | 486 | if self.config().opt_present("help") { 487 | print_usage(opts); 488 | process::exit(0); 489 | } 490 | if self.config().opt_present("version") { 491 | print_version(); 492 | process::exit(0); 493 | } 494 | 495 | if self.config().opt_present("debug") { 496 | self.debug = true; 497 | 498 | self.logfile = match self.config().opt_str("debug") { 499 | Some(file) => file.to_string(), 500 | None => DEFAULT_LOG_FILE.to_string(), 501 | } 502 | } 503 | 504 | if self.config().opt_present("issue") && self.config().opt_present("greeting") { 505 | return Err("Only one of --issue and --greeting may be used at the same time".into()); 506 | } 507 | 508 | if self.config().opt_present("theme") { 509 | if let Some(spec) = self.config().opt_str("theme") { 510 | self.theme = Theme::parse(spec.as_str()); 511 | } 512 | } 513 | 514 | if self.config().opt_present("asterisks") { 515 | let asterisk = if let Some(value) = self.config().opt_str("asterisks-char") { 516 | if value.chars().count() < 1 { 517 | return Err("--asterisks-char must have at least one character as its value".into()); 518 | } 519 | 520 | value 521 | } else { 522 | DEFAULT_ASTERISKS_CHARS.to_string() 523 | }; 524 | 525 | self.secret_display = SecretDisplay::Character(asterisk); 526 | } 527 | 528 | self.time = self.config().opt_present("time"); 529 | 530 | if let Some(format) = self.config().opt_str("time-format") { 531 | if StrftimeItems::new(&format).any(|item| item == Item::Error) { 532 | return Err("Invalid strftime format provided in --time-format".into()); 533 | } 534 | 535 | self.time_format = Some(format); 536 | } 537 | 538 | if self.config().opt_present("user-menu") { 539 | self.user_menu = true; 540 | 541 | let min_uid = self.config().opt_str("user-menu-min-uid").and_then(|uid| uid.parse::().ok()); 542 | let max_uid = self.config().opt_str("user-menu-max-uid").and_then(|uid| uid.parse::().ok()); 543 | let (min_uid, max_uid) = get_min_max_uids(min_uid, max_uid); 544 | 545 | tracing::info!("min/max UIDs are {}/{}", min_uid, max_uid); 546 | 547 | if min_uid >= max_uid { 548 | return Err("Minimum UID ({min_uid}) must be less than maximum UID ({max_uid})".into()); 549 | } 550 | 551 | self.users = Menu { 552 | title: fl!("title_users"), 553 | options: get_users(min_uid, max_uid), 554 | selected: 0, 555 | }; 556 | 557 | tracing::info!("found {} users", self.users.options.len()); 558 | } 559 | 560 | if self.config().opt_present("remember-session") && self.config().opt_present("remember-user-session") { 561 | return Err("Only one of --remember-session and --remember-user-session may be used at the same time".into()); 562 | } 563 | if self.config().opt_present("remember-user-session") && !self.config().opt_present("remember") { 564 | return Err("--remember-session must be used with --remember".into()); 565 | } 566 | 567 | self.remember = self.config().opt_present("remember"); 568 | self.remember_session = self.config().opt_present("remember-session"); 569 | self.remember_user_session = self.config().opt_present("remember-user-session"); 570 | self.greeting = self.option("greeting"); 571 | 572 | // If the `--cmd` argument is provided, it will override the selected session. 573 | if let Some(command) = self.option("cmd") { 574 | let envs = self.options_multi("env"); 575 | 576 | if let Some(envs) = envs { 577 | for env in envs { 578 | if !env.contains('=') { 579 | return Err(format!("malformed environment variable definition for '{env}'").into()); 580 | } 581 | } 582 | } 583 | 584 | self.session_source = SessionSource::DefaultCommand(command, self.options_multi("env")); 585 | } 586 | 587 | if let Some(dirs) = self.option("sessions") { 588 | self.session_paths.extend(env::split_paths(&dirs).map(|dir| (dir, SessionType::Wayland))); 589 | } 590 | 591 | if let Some(dirs) = self.option("xsessions") { 592 | self.session_paths.extend(env::split_paths(&dirs).map(|dir| (dir, SessionType::X11))); 593 | } 594 | 595 | if self.option("session-wrapper").is_some() { 596 | self.session_wrapper = self.option("session-wrapper"); 597 | } 598 | 599 | if !self.config().opt_present("no-xsession-wrapper") { 600 | self.xsession_wrapper = self.option("xsession-wrapper").or_else(|| Some(DEFAULT_XSESSION_WRAPPER.to_string())); 601 | } 602 | 603 | if self.config().opt_present("issue") { 604 | self.greeting = get_issue(); 605 | } 606 | 607 | self.powers.options.push(Power { 608 | action: PowerOption::Shutdown, 609 | label: fl!("shutdown"), 610 | command: self.config().opt_str("power-shutdown"), 611 | }); 612 | 613 | self.powers.options.push(Power { 614 | action: PowerOption::Reboot, 615 | label: fl!("reboot"), 616 | command: self.config().opt_str("power-reboot"), 617 | }); 618 | 619 | self.power_setsid = !self.config().opt_present("power-no-setsid"); 620 | 621 | self.kb_command = self.config().opt_str("kb-command").map(|i| i.parse::().unwrap_or_default()).unwrap_or(2); 622 | self.kb_sessions = self.config().opt_str("kb-sessions").map(|i| i.parse::().unwrap_or_default()).unwrap_or(3); 623 | self.kb_power = self.config().opt_str("kb-power").map(|i| i.parse::().unwrap_or_default()).unwrap_or(12); 624 | 625 | if self.kb_command == self.kb_sessions || self.kb_sessions == self.kb_power || self.kb_power == self.kb_command { 626 | return Err("keybindings must all be distinct".into()); 627 | } 628 | 629 | Ok(()) 630 | } 631 | 632 | pub fn set_prompt(&mut self, prompt: &str) { 633 | self.prompt = if prompt.ends_with(' ') { Some(prompt.into()) } else { Some(format!("{prompt} ")) }; 634 | } 635 | 636 | pub fn remove_prompt(&mut self) { 637 | self.prompt = None; 638 | } 639 | 640 | // Computes the size of the prompt to help determine where input should start. 641 | pub fn prompt_width(&self) -> usize { 642 | match &self.prompt { 643 | None => 0, 644 | Some(prompt) => prompt.chars().count(), 645 | } 646 | } 647 | } 648 | 649 | fn print_usage(opts: Options) { 650 | eprint!("{}", opts.usage("Usage: tuigreet [OPTIONS]")); 651 | } 652 | 653 | fn print_version() { 654 | println!("tuigreet {} ({})", env!("VERSION"), env!("TARGET")); 655 | println!("Copyright (C) 2020 Antoine POPINEAU ."); 656 | println!("Licensed under GPLv3+ (GNU GPL version 3 or later)."); 657 | println!(); 658 | println!("This is free software, you are welcome to redistribute it under some conditions."); 659 | println!("There is NO WARRANTY, to the extent provided by law."); 660 | } 661 | 662 | #[cfg(test)] 663 | mod test { 664 | use crate::{ui::sessions::SessionSource, Greeter, SecretDisplay}; 665 | 666 | #[test] 667 | fn test_prompt_width() { 668 | let mut greeter = Greeter::default(); 669 | greeter.prompt = None; 670 | 671 | assert_eq!(greeter.prompt_width(), 0); 672 | 673 | greeter.prompt = Some("Hello:".into()); 674 | 675 | assert_eq!(greeter.prompt_width(), 6); 676 | } 677 | 678 | #[test] 679 | fn test_set_prompt() { 680 | let mut greeter = Greeter::default(); 681 | 682 | greeter.set_prompt("Hello:"); 683 | 684 | assert_eq!(greeter.prompt, Some("Hello: ".into())); 685 | 686 | greeter.set_prompt("Hello World: "); 687 | 688 | assert_eq!(greeter.prompt, Some("Hello World: ".into())); 689 | 690 | greeter.remove_prompt(); 691 | 692 | assert_eq!(greeter.prompt, None); 693 | } 694 | 695 | #[tokio::test] 696 | async fn test_command_line_arguments() { 697 | let table: &[(&[&str], _, Option)] = &[ 698 | // No arguments 699 | (&[], true, None), 700 | // Valid combinations 701 | (&["--cmd", "hello"], true, None), 702 | ( 703 | &[ 704 | "--cmd", 705 | "uname", 706 | "--env", 707 | "A=B", 708 | "--env", 709 | "C=D=E", 710 | "--asterisks", 711 | "--asterisks-char", 712 | ".", 713 | "--issue", 714 | "--time", 715 | "--prompt-padding", 716 | "0", 717 | "--window-padding", 718 | "1", 719 | "--container-padding", 720 | "12", 721 | "--user-menu", 722 | ], 723 | true, 724 | Some(|greeter| { 725 | assert!(matches!(&greeter.session_source, SessionSource::DefaultCommand(cmd, Some(env)) if cmd == "uname" && env.len() == 2)); 726 | 727 | if let SessionSource::DefaultCommand(_, Some(env)) = &greeter.session_source { 728 | assert_eq!(env[0], "A=B"); 729 | assert_eq!(env[1], "C=D=E"); 730 | } 731 | 732 | assert!(matches!(&greeter.secret_display, SecretDisplay::Character(c) if c == ".")); 733 | assert_eq!(greeter.prompt_padding(), 0); 734 | assert_eq!(greeter.window_padding(), 1); 735 | assert_eq!(greeter.container_padding(), 13); 736 | assert_eq!(greeter.user_menu, true); 737 | assert!(matches!(greeter.xsession_wrapper.as_deref(), Some("startx /usr/bin/env"))); 738 | }), 739 | ), 740 | ( 741 | &["--xsession-wrapper", "mywrapper.sh"], 742 | true, 743 | Some(|greeter| { 744 | assert!(matches!(greeter.xsession_wrapper.as_deref(), Some("mywrapper.sh"))); 745 | }), 746 | ), 747 | ( 748 | &["--no-xsession-wrapper"], 749 | true, 750 | Some(|greeter| { 751 | assert!(matches!(greeter.xsession_wrapper, None)); 752 | }), 753 | ), 754 | // Invalid combinations 755 | (&["--remember-session", "--remember-user-session"], false, None), 756 | (&["--asterisk-char", ""], false, None), 757 | (&["--remember-user-session"], false, None), 758 | (&["--min-uid", "10000", "--max-uid", "5000"], false, None), 759 | (&["--issue", "--greeting", "Hello, world!"], false, None), 760 | (&["--kb-command", "F2", "--kb-sessions", "F2"], false, None), 761 | (&["--time-format", "%i %"], false, None), 762 | (&["--cmd", "cmd", "--env"], false, None), 763 | (&["--cmd", "cmd", "--env", "A"], false, None), 764 | ]; 765 | 766 | for (opts, valid, check) in table { 767 | let mut greeter = Greeter::default(); 768 | 769 | match valid { 770 | true => { 771 | assert!(matches!(greeter.parse_options(*opts).await, Ok(())), "{:?} cannot be parsed", opts); 772 | 773 | if let Some(check) = check { 774 | check(&greeter); 775 | } 776 | } 777 | false => assert!(matches!(greeter.parse_options(*opts).await, Err(_))), 778 | } 779 | } 780 | } 781 | } 782 | --------------------------------------------------------------------------------