diff --git a/CONTEXT.md b/CONTEXT.md index afb836ec..dfa07595 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -58,7 +58,9 @@ preservation as the fallback. _Avoid_: Paste, output **Overlay**: -The optional, separate on-screen status surface that reflects daemon state. +The separate on-screen status surface that reflects daemon state. A Setup +Profile may require an Overlay when visible Recording feedback is part of that +desktop's supported experience. _Avoid_: Popup, HUD **Recording Deadline**: diff --git a/crates/voisu-app/Cargo.toml b/crates/voisu-app/Cargo.toml index ad60586f..efd819da 100644 --- a/crates/voisu-app/Cargo.toml +++ b/crates/voisu-app/Cargo.toml @@ -41,7 +41,7 @@ Trigger Key and Delivery use desktop portals.""" # wl-clipboard only.) # /usr/bin/secret-tool -> libsecret-tools (secret-tool binary) # curl -> curl (provider HTTPS/WSS) -depends = "$auto, xdg-desktop-portal, pipewire-bin, wireplumber, wl-clipboard | xclip, libsecret-tools, curl" +depends = "$auto, xdg-desktop-portal, pipewire-bin, wireplumber, wl-clipboard | xclip, libsecret-tools, curl, gnome-shell (>= 50), gnome-shell (<< 51)" # libei is dlopen()'d by SONAME at runtime (an optional auto-type capability), # never linked -- so it is Recommends, mirroring the RPM `Recommends: libei`. recommends = "libei1" @@ -67,6 +67,11 @@ assets = [ ["../../packaging/voisu.service", "usr/lib/systemd/user/", "644"], ["../../packaging/voisu-overlay.service", "usr/lib/systemd/user/", "644"], ["../../packaging/voisu.desktop", "usr/share/applications/", "644"], + ["../../packaging/gnome-shell/overlay@voisu.app/metadata.json", "usr/share/gnome-shell/extensions/overlay@voisu.app/metadata.json", "644"], + ["../../packaging/gnome-shell/overlay@voisu.app/extension.js", "usr/share/gnome-shell/extensions/overlay@voisu.app/extension.js", "644"], + ["../../packaging/gnome-shell/overlay@voisu.app/state.mjs", "usr/share/gnome-shell/extensions/overlay@voisu.app/state.mjs", "644"], + ["../../packaging/gnome-shell/overlay@voisu.app/stylesheet.css", "usr/share/gnome-shell/extensions/overlay@voisu.app/stylesheet.css", "644"], + ["../../packaging/gnome-shell/overlay@voisu.app/schemas/app.voisu.shell-overlay.gschema.xml", "usr/share/glib-2.0/schemas/app.voisu.shell-overlay.gschema.xml", "644"], ["../../packaging/deb/lintian-overrides", "usr/share/lintian/overrides/voisu", "644"], ["../../packaging/deb/copyright", "usr/share/doc/voisu/copyright", "644"], ["../../packaging/deb/ring/LICENSE", "usr/share/doc/voisu/ring/LICENSE", "644"], diff --git a/crates/voisu-app/src/bin/voisu-daemon.rs b/crates/voisu-app/src/bin/voisu-daemon.rs index eed80a15..856b4e40 100644 --- a/crates/voisu-app/src/bin/voisu-daemon.rs +++ b/crates/voisu-app/src/bin/voisu-daemon.rs @@ -239,7 +239,11 @@ async fn run() -> Result<(), String> { // serialization so repeated or concurrent activations cannot overlap. // Disabling it (VOISU_DISABLE_SHORTCUTS) keeps the daemon usable in // sessions or tests that have no desktop portal. - if std::env::var_os("VOISU_DISABLE_SHORTCUTS").is_none() { + let portal_owns_trigger = voisu_app::setup_profile::trigger_integration( + &voisu_app::setup_profile::live_setup_facts(), + ) + == voisu_app::setup_profile::TriggerIntegration::GlobalShortcutsPortal; + if portal_owns_trigger && std::env::var_os("VOISU_DISABLE_SHORTCUTS").is_none() { tokio::spawn(shortcut_listener(actor_tx.clone())); } let connections = std::sync::Arc::new(Semaphore::new(MAX_CONNECTIONS)); diff --git a/crates/voisu-app/src/bin/voisu.rs b/crates/voisu-app/src/bin/voisu.rs index 8f01800d..ab33363f 100644 --- a/crates/voisu-app/src/bin/voisu.rs +++ b/crates/voisu-app/src/bin/voisu.rs @@ -11,9 +11,9 @@ use voisu_app::system::{ use voisu_core::{ AsrMode, BoundaryError, BoundaryFuture, BoundaryKind, Command, Credential, DaemonReadiness, ExportCorrelationId, KeyDiagnosis, KeyLocation, PROTOCOL_VERSION, PasteActionState, - PasteBackend, Provider, ProviderAuthenticator, ProviderKeyStatus, ReadinessInspector, - ReadinessStatus, ReplayFixturePath, Request, Response, SecretStore, SessionKind, - VersionEnvelope, provider_free_tier_hint, resolve_session, socket_path, + PasteBackend, Provider, ProviderAuthenticator, ProviderKeyStatus, ReadinessStatus, + ReplayFixturePath, Request, Response, SecretStore, SessionKind, VersionEnvelope, + provider_free_tier_hint, resolve_session, socket_path, }; /// The most response the CLI will buffer — per transport frame, and in total @@ -329,9 +329,13 @@ impl DoctorRow { } fn doctor(verbose: bool) -> ExitCode { + use voisu_app::setup_profile::{TriggerIntegration, live_setup_facts, trigger_integration}; + + let trigger_integration = trigger_integration(&live_setup_facts()); + let ubuntu_gnome = trigger_integration == TriggerIntegration::GnomeShellExtension; let mut inspector = FedoraReadiness::default(); let mut rows: Vec = inspector - .inspect() + .inspect_with_global_shortcuts(!ubuntu_gnome) .into_iter() .map(|finding| DoctorRow { label: finding.capability.cli_label().to_owned(), @@ -354,6 +358,62 @@ fn doctor(verbose: bool) -> ExitCode { .unwrap_or(voisu_app::focus::FocusBackendKind::None); rows.push(focus_guard_row(focus_backend)); + if ubuntu_gnome { + use voisu_app::ubuntu_gnome::{LiveState, TriggerState}; + let state = voisu_app::ubuntu_gnome::doctor_state(); + rows.push(match state.overlay { + LiveState::Active => DoctorRow::new( + "Overlay", + ReadinessStatus::Pass, + "Ubuntu native GNOME Shell Overlay is active", + ) + .value("active"), + LiveState::RestartRequired => DoctorRow::new( + "Overlay", + ReadinessStatus::Warn, + "GNOME persisted the native Overlay but needs a logout/login or reboot to load it", + ) + .value("restart required"), + LiveState::Unavailable => DoctorRow::new( + "Overlay", + ReadinessStatus::Fail, + state + .detail + .as_deref() + .unwrap_or("Ubuntu native GNOME Shell Overlay is missing, disabled, or broken"), + ) + .value("unavailable") + .action("run `voisu setup`"), + }); + rows.push(match state.trigger { + TriggerState::Active => DoctorRow::new( + "Trigger Key", + ReadinessStatus::Pass, + "GNOME accepted the extension-owned Trigger Key", + ) + .value(state.trigger_key.as_deref().unwrap_or("configured")), + TriggerState::RestartRequired => DoctorRow::new( + "Trigger Key", + ReadinessStatus::Warn, + "Trigger Key will be attempted when GNOME loads the extension", + ) + .value(format!( + "{} / restart required", + state.trigger_key.as_deref().unwrap_or("configured") + )), + TriggerState::NotInstalled => DoctorRow::new( + "Trigger Key", + ReadinessStatus::Warn, + "GNOME could not install the configured Trigger Key", + ) + .value(format!( + "{} / not installed", + state.trigger_key.as_deref().unwrap_or("configured") + )) + .action("use `voisu toggle`"), + }); + } + // The CLI's own probes remain useful, but they cannot prove that the // already-running service inherited the same display endpoint. Newer // daemons add an additive readiness payload on the handshake already used @@ -837,12 +897,19 @@ fn setup() -> ExitCode { LiveHyprlandSetupActions, LiveKeyValidator, ProviderOutcome, SETUP_COMPLETE_MESSAGE, StdioWizard, run_consented_local_setup, run_hyprland_setup, run_setup, }; - use voisu_app::setup_profile::{HyprlandConfig, discover_setup_profile, live_setup_facts}; + use voisu_app::setup_profile::{ + HyprlandConfig, SetupProfile, discover_setup_profile, live_setup_facts, + }; // Integration tests can isolate the wizard while exercising the real CLI; // production never sets this seam. let wizard_only = std::env::var_os("VOISU_TEST_SETUP_WIZARD_ONLY").is_some(); let mut wizard = StdioWizard; + let discovery = match discover_setup_profile(&live_setup_facts()) { + Ok(discovery) => discovery, + Err(error) => return fail(4, &error.message()), + }; + println!("Detected {} Setup Profile.", discovery.profile.as_str()); if voisu_app::local_setup::is_local_mode() { if let Err(error) = run_consented_local_setup(&mut wizard) { return fail(4, &error); @@ -852,11 +919,6 @@ fn setup() -> ExitCode { return ExitCode::SUCCESS; } } - let discovery = match discover_setup_profile(&live_setup_facts()) { - Ok(discovery) => discovery, - Err(error) => return fail(4, &error.message()), - }; - println!("Detected {} Setup Profile.", discovery.profile.as_str()); let hyprland_config = discovery.hyprland_config; if hyprland_config.is_some() && !wizard_only @@ -876,6 +938,9 @@ fn setup() -> ExitCode { ); } } + if discovery.profile == SetupProfile::UbuntuWayland { + return finish_ubuntu_setup(SETUP_COMPLETE_MESSAGE); + } println!("{SETUP_COMPLETE_MESSAGE}"); return ExitCode::SUCCESS; } @@ -913,6 +978,8 @@ fn setup() -> ExitCode { ), ), } + } else if discovery.profile == SetupProfile::UbuntuWayland { + finish_ubuntu_setup(SETUP_COMPLETE_MESSAGE) } else { println!("{SETUP_COMPLETE_MESSAGE}"); ExitCode::SUCCESS @@ -926,6 +993,52 @@ fn setup() -> ExitCode { } } +fn finish_ubuntu_setup(complete_message: &str) -> ExitCode { + use voisu_app::ubuntu_gnome::{LiveState, TriggerState}; + match voisu_app::ubuntu_gnome::setup() { + Ok(result) => { + println!("{complete_message}"); + match result.overlay { + LiveState::Active => println!("Overlay: active (native GNOME capsule)."), + LiveState::RestartRequired => { + println!("Overlay: enabled; restart required (log out and back in, or reboot).") + } + LiveState::Unavailable => println!("Overlay: unavailable."), + } + match result.trigger { + TriggerState::Active => println!( + "Trigger Key: {}.", + result.trigger_key.as_deref().unwrap_or("configured") + ), + TriggerState::RestartRequired => { + println!( + "Trigger Key: {}; restart required before GNOME can install it.", + result.trigger_key.as_deref().unwrap_or("configured") + ) + } + TriggerState::NotInstalled => { + println!( + "Trigger Key: not installed; configured binding {}; use `voisu toggle`.", + result.trigger_key.as_deref().unwrap_or("configured") + ) + } + } + if result.daemon_restart_required { + println!("Daemon: enabled; restart required before it can start."); + } else { + println!("Daemon: enabled and active."); + } + ExitCode::SUCCESS + } + Err(error) => fail( + 4, + &format!( + "Ubuntu setup incomplete: {error}; recovery: run `voisu setup` after fixing this step" + ), + ), + } +} + fn auth_set(provider: Provider, credential: Credential) -> ExitCode { match SecretToolStore.replace(provider, credential) { Ok(()) => { diff --git a/crates/voisu-app/src/lib.rs b/crates/voisu-app/src/lib.rs index fb269d94..f682dc29 100644 --- a/crates/voisu-app/src/lib.rs +++ b/crates/voisu-app/src/lib.rs @@ -33,3 +33,4 @@ pub mod setup; pub mod setup_profile; pub mod smart_writing; pub mod system; +pub mod ubuntu_gnome; diff --git a/crates/voisu-app/src/service.rs b/crates/voisu-app/src/service.rs index cd23a507..36bd971d 100644 --- a/crates/voisu-app/src/service.rs +++ b/crates/voisu-app/src/service.rs @@ -191,6 +191,45 @@ pub fn hyprland_overlay_readiness() -> Result { Ok(OverlayReadiness { enabled, active }) } +/// Stops the notification-only GTK observer before Ubuntu enables its native +/// GNOME Shell Overlay. Success means systemd confirmed both lifecycle states. +pub fn disable_ubuntu_gtk_overlay() -> Result<(), String> { + let disable = systemctl(&["disable", "--now", OVERLAY_UNIT_NAME]); + let load_state = systemctl(&["show", OVERLAY_UNIT_NAME, "--property=LoadState", "--value"])? + .stdout + .trim() + .to_owned(); + let active_state = systemctl(&["is-active", OVERLAY_UNIT_NAME])? + .stdout + .trim() + .to_owned(); + let enabled_state = systemctl(&["is-enabled", OVERLAY_UNIT_NAME])? + .stdout + .trim() + .to_owned(); + + if ubuntu_overlay_is_stopped(&load_state, &active_state, &enabled_state) { + return Ok(()); + } + + let disable_detail = match disable { + Ok(output) if output.success => "disable command succeeded".to_owned(), + Ok(_) => "disable command failed".to_owned(), + Err(error) => format!("disable command could not run: {error}"), + }; + Err(format!( + "could not stop the GTK notification observer: {disable_detail}; systemd reports load={load_state}, active={active_state}, enabled={enabled_state}; run `systemctl --user disable --now {OVERLAY_UNIT_NAME}` and retry" + )) +} + +fn ubuntu_overlay_is_stopped(load: &str, active: &str, enabled: &str) -> bool { + match load { + "loaded" => active == "inactive" && enabled == "disabled", + "not-found" => active == "inactive" && matches!(enabled, "disabled" | "not-found"), + _ => false, + } +} + pub fn hyprland_overlay_install_command() -> String { hyprland_overlay_install_command_on_path(std::env::var_os("PATH").as_deref()) } @@ -273,6 +312,12 @@ fn overlay_state(operation: &str) -> Result { } fn manage_optional_overlay(action: OptionalOverlayAction) -> Option { + // GNOME Shell owns Ubuntu's native capsule. Starting the GTK observer here + // would degrade to duplicate desktop notifications because GNOME has no + // layer-shell protocol. + if crate::setup_profile::is_live_ubuntu_wayland() { + return None; + } if !packaged_overlay_unit_exists() { return None; } @@ -504,6 +549,9 @@ fn import_session_environment() -> Result<(), String> { "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", ]; + if std::env::var_os("XDG_SESSION_DESKTOP").is_some_and(|value| !value.is_empty()) { + arguments.push("XDG_SESSION_DESKTOP"); + } if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some_and(|value| !value.is_empty()) { arguments.push("HYPRLAND_INSTANCE_SIGNATURE"); } @@ -1330,6 +1378,28 @@ mod tests { assert!(error.contains("optional Overlay service was not enabled")); } + #[test] + fn ubuntu_gtk_overlay_requires_inactive_and_disabled_or_confirmed_absent() { + assert!(ubuntu_overlay_is_stopped("loaded", "inactive", "disabled")); + assert!(ubuntu_overlay_is_stopped( + "not-found", + "inactive", + "not-found" + )); + assert!(!ubuntu_overlay_is_stopped("loaded", "active", "disabled")); + assert!(!ubuntu_overlay_is_stopped("loaded", "inactive", "enabled")); + assert!(!ubuntu_overlay_is_stopped( + "not-found", + "active", + "not-found" + )); + assert!(!ubuntu_overlay_is_stopped( + "not-found", + "unknown", + "not-found" + )); + } + #[test] fn overlay_install_command_on_arch_with_yay_prints_yay() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/voisu-app/src/setup_profile.rs b/crates/voisu-app/src/setup_profile.rs index 9c849603..e9a7021d 100644 --- a/crates/voisu-app/src/setup_profile.rs +++ b/crates/voisu-app/src/setup_profile.rs @@ -14,14 +14,24 @@ use voisu_core::{SessionKind, resolve_session}; pub enum SetupProfile { /// Fedora KDE or GNOME running on Wayland. FedoraWayland, + /// Ubuntu GNOME running on Wayland. + UbuntuWayland, /// Hyprland, including an Omarchy session. Hyprland, } +/// The desktop component that owns Voisu's Trigger Key in this login. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TriggerIntegration { + GlobalShortcutsPortal, + GnomeShellExtension, +} + impl SetupProfile { pub const fn as_str(self) -> &'static str { match self { Self::FedoraWayland => "fedora-wayland", + Self::UbuntuWayland => "ubuntu-wayland", Self::Hyprland => "hyprland", } } @@ -53,6 +63,7 @@ pub struct SetupDiscoveryFacts { pub current_desktop: Option, pub session_desktop: Option, pub distro_id: Option, + pub distro_version_id: Option, /// Presence of this compositor-owned session variable is the live /// Hyprland evidence. A desktop label alone is never sufficient. pub hyprland_instance_signature: Option, @@ -81,7 +92,7 @@ impl SetupDiscoveryError { pub fn message(&self) -> String { match self { Self::UnknownSession => { - "cannot determine the desktop session; run `voisu setup` from Fedora KDE/GNOME Wayland or Hyprland (including Omarchy)".to_owned() + "cannot determine the desktop session; run `voisu setup` from Fedora KDE/GNOME Wayland, Ubuntu GNOME Wayland, or Hyprland (including Omarchy)".to_owned() } Self::UnsupportedSession { session, desktop } => { let session = match session { @@ -161,6 +172,17 @@ pub fn discover_setup_profile( }); } + if session.session == SessionKind::Wayland + && is_ubuntu(facts.distro_id.as_deref()) + && is_ubuntu_26_04(facts.distro_version_id.as_deref()) + && has_gnome_desktop(desktop) + { + return Ok(SetupProfileDiscovery { + profile: SetupProfile::UbuntuWayland, + hyprland_config: None, + }); + } + Err(SetupDiscoveryError::UnsupportedSession { session: session.session, desktop: desktop.map(str::to_owned), @@ -183,22 +205,41 @@ pub fn live_setup_facts() -> SetupDiscoveryFacts { session_type: std::env::var("XDG_SESSION_TYPE").ok(), current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(), session_desktop: std::env::var("XDG_SESSION_DESKTOP").ok(), - distro_id: read_os_release_id(), + distro_id: read_os_release_value("ID"), + distro_version_id: read_os_release_value("VERSION_ID"), hyprland_instance_signature: std::env::var("HYPRLAND_INSTANCE_SIGNATURE").ok(), current_lua_config: existing_file(current_lua), legacy_config: existing_file(legacy_config), } } +/// True only for the exact Ubuntu GNOME Wayland Setup Profile. Service +/// lifecycle code uses the same resolver as setup so desktop-label fallbacks +/// and supported-release boundaries cannot drift. +pub fn is_live_ubuntu_wayland() -> bool { + trigger_integration(&live_setup_facts()) == TriggerIntegration::GnomeShellExtension +} + +/// Resolve Trigger Key ownership from the same injected facts as setup. +pub fn trigger_integration(facts: &SetupDiscoveryFacts) -> TriggerIntegration { + if discover_setup_profile(facts) + .is_ok_and(|discovery| discovery.profile == SetupProfile::UbuntuWayland) + { + TriggerIntegration::GnomeShellExtension + } else { + TriggerIntegration::GlobalShortcutsPortal + } +} + fn existing_file(path: PathBuf) -> Option { path.is_file().then_some(path) } -fn read_os_release_id() -> Option { +fn read_os_release_value(name: &str) -> Option { let contents = std::fs::read_to_string("/etc/os-release").ok()?; contents.lines().find_map(|line| { let value = line - .strip_prefix("ID=")? + .strip_prefix(&format!("{name}="))? .trim_matches('"') .trim_matches('\''); (!value.is_empty()).then(|| value.to_owned()) @@ -217,16 +258,30 @@ fn is_fedora(value: Option<&str>) -> bool { value.is_some_and(|value| value.eq_ignore_ascii_case("fedora")) } +fn is_ubuntu(value: Option<&str>) -> bool { + value.is_some_and(|value| value.eq_ignore_ascii_case("ubuntu")) +} + +fn is_ubuntu_26_04(value: Option<&str>) -> bool { + value.is_some_and(|value| value == "26.04") +} + +fn has_gnome_desktop(value: Option<&str>) -> bool { + has_desktop_label(value, |label| label.eq_ignore_ascii_case("gnome")) +} + fn has_supported_desktop(value: Option<&str>) -> bool { - value.is_some_and(|value| { - value.split([':', ';', ',']).map(str::trim).any(|label| { - label.eq_ignore_ascii_case("kde") - || label.eq_ignore_ascii_case("kde plasma") - || label.eq_ignore_ascii_case("gnome") - }) + has_desktop_label(value, |label| { + label.eq_ignore_ascii_case("kde") + || label.eq_ignore_ascii_case("kde plasma") + || label.eq_ignore_ascii_case("gnome") }) } +fn has_desktop_label(value: Option<&str>, matches: impl Fn(&str) -> bool) -> bool { + value.is_some_and(|value| value.split([':', ';', ',']).map(str::trim).any(matches)) +} + #[cfg(test)] mod tests { use super::*; @@ -331,9 +386,81 @@ mod tests { } } + #[test] + fn ubuntu_gnome_wayland_resolves_to_the_ubuntu_profile() { + for desktop in ["ubuntu:GNOME", "GNOME"] { + let facts = SetupDiscoveryFacts { + wayland_display: Some("wayland-0".to_owned()), + session_type: Some("wayland".to_owned()), + current_desktop: Some(desktop.to_owned()), + distro_id: Some("Ubuntu".to_owned()), + distro_version_id: Some("26.04".to_owned()), + ..SetupDiscoveryFacts::default() + }; + + assert_eq!( + discover_setup_profile(&facts).unwrap().profile, + SetupProfile::UbuntuWayland + ); + } + } + + #[test] + fn ubuntu_x11_and_other_ubuntu_or_gnome_combinations_remain_unsupported() { + for facts in [ + SetupDiscoveryFacts { + x11_display: Some(":0".to_owned()), + session_type: Some("x11".to_owned()), + current_desktop: Some("ubuntu:GNOME".to_owned()), + distro_id: Some("ubuntu".to_owned()), + ..SetupDiscoveryFacts::default() + }, + SetupDiscoveryFacts { + wayland_display: Some("wayland-0".to_owned()), + session_type: Some("wayland".to_owned()), + current_desktop: Some("GNOME".to_owned()), + distro_id: Some("debian".to_owned()), + ..SetupDiscoveryFacts::default() + }, + SetupDiscoveryFacts { + wayland_display: Some("wayland-0".to_owned()), + session_type: Some("wayland".to_owned()), + current_desktop: Some("KDE".to_owned()), + distro_id: Some("ubuntu".to_owned()), + distro_version_id: Some("26.04".to_owned()), + ..SetupDiscoveryFacts::default() + }, + SetupDiscoveryFacts { + wayland_display: Some("wayland-0".to_owned()), + session_type: Some("wayland".to_owned()), + current_desktop: Some("GNOME".to_owned()), + distro_id: Some("ubuntu".to_owned()), + distro_version_id: Some("24.04".to_owned()), + ..SetupDiscoveryFacts::default() + }, + ] { + assert!(matches!( + discover_setup_profile(&facts), + Err(SetupDiscoveryError::UnsupportedSession { .. }) + )); + } + } + + #[test] + fn live_hyprland_evidence_takes_priority_over_ubuntu_labels() { + let mut facts = hyprland_facts(); + facts.current_desktop = Some("ubuntu:GNOME".to_owned()); + facts.distro_id = Some("ubuntu".to_owned()); + + assert_eq!( + discover_setup_profile(&facts).unwrap().profile, + SetupProfile::Hyprland + ); + } + #[test] fn empty_current_desktop_uses_the_session_desktop_fallback() { - let facts = SetupDiscoveryFacts { + let fedora = SetupDiscoveryFacts { wayland_display: Some("wayland-0".to_owned()), session_type: Some("wayland".to_owned()), current_desktop: Some(" ".to_owned()), @@ -343,9 +470,54 @@ mod tests { }; assert_eq!( - discover_setup_profile(&facts).unwrap().profile, + discover_setup_profile(&fedora).unwrap().profile, SetupProfile::FedoraWayland ); + + let ubuntu = SetupDiscoveryFacts { + wayland_display: Some("wayland-0".to_owned()), + session_type: Some("wayland".to_owned()), + current_desktop: Some(" ".to_owned()), + session_desktop: Some("ubuntu:GNOME".to_owned()), + distro_id: Some("ubuntu".to_owned()), + distro_version_id: Some("26.04".to_owned()), + ..SetupDiscoveryFacts::default() + }; + assert_eq!( + discover_setup_profile(&ubuntu).unwrap().profile, + SetupProfile::UbuntuWayland + ); + } + + #[test] + fn only_exact_ubuntu_profile_gives_trigger_ownership_to_gnome_shell() { + let ubuntu_fallback = SetupDiscoveryFacts { + wayland_display: Some("wayland-0".to_owned()), + session_type: Some("wayland".to_owned()), + current_desktop: Some(" ".to_owned()), + session_desktop: Some("ubuntu:GNOME".to_owned()), + distro_id: Some("ubuntu".to_owned()), + distro_version_id: Some("26.04".to_owned()), + ..SetupDiscoveryFacts::default() + }; + assert_eq!( + trigger_integration(&ubuntu_fallback), + TriggerIntegration::GnomeShellExtension + ); + + let mut fedora = ubuntu_fallback.clone(); + fedora.distro_id = Some("fedora".to_owned()); + assert_eq!( + trigger_integration(&fedora), + TriggerIntegration::GlobalShortcutsPortal + ); + + let mut hyprland = ubuntu_fallback; + hyprland.hyprland_instance_signature = Some("instance".to_owned()); + assert_eq!( + trigger_integration(&hyprland), + TriggerIntegration::GlobalShortcutsPortal + ); } #[test] diff --git a/crates/voisu-app/src/system/readiness.rs b/crates/voisu-app/src/system/readiness.rs index 0fee9ef7..1c39c9ce 100644 --- a/crates/voisu-app/src/system/readiness.rs +++ b/crates/voisu-app/src/system/readiness.rs @@ -15,25 +15,28 @@ impl FedoraReadiness { pub fn daemon_status(&self) -> Option<&Response> { self.daemon_status.as_ref() } -} -impl ReadinessInspector for FedoraReadiness { - fn inspect(&mut self) -> Vec { + /// Inspect shared desktop capabilities while omitting the GlobalShortcuts + /// probe when another desktop component owns the Trigger Key. + pub fn inspect_with_global_shortcuts(&mut self, use_portal: bool) -> Vec { self.daemon_status = daemon_status_response(); if let Some(value) = std::env::var_os("VOISU_TEST_READINESS") { - return controlled_readiness(&value.to_string_lossy(), self.daemon_status.as_ref()); + let mut findings = + controlled_readiness(&value.to_string_lossy(), self.daemon_status.as_ref()); + if !use_portal { + findings.retain(|finding| finding.capability != ReadinessCapability::Portals); + } + return findings; + } + let mut findings = vec![session_finding(), pipewire_finding(), microphone_finding()]; + if use_portal { + findings.push(portals_finding()); } - let mut findings = vec![ - session_finding(), - pipewire_finding(), - microphone_finding(), - portals_finding(), + findings.extend([ clipboard_finding(), secret_service_finding(), daemon_finding(self.daemon_status.as_ref()), - ]; - // Appended only when it can demonstrate a problem, so the common case - // stays quiet and the golden table is unaffected. + ]); if let Some(finding) = service_display_env_finding() { findings.push(finding); } @@ -41,6 +44,12 @@ impl ReadinessInspector for FedoraReadiness { } } +impl ReadinessInspector for FedoraReadiness { + fn inspect(&mut self) -> Vec { + self.inspect_with_global_shortcuts(true) + } +} + /// The first executable of `program` found on `PATH`, honoring `PATH` order so /// the resolved path is the one a spawned helper would actually run. fn first_on_path(program: &str) -> Option { diff --git a/crates/voisu-app/src/ubuntu_gnome.rs b/crates/voisu-app/src/ubuntu_gnome.rs new file mode 100644 index 00000000..3a50355f --- /dev/null +++ b/crates/voisu-app/src/ubuntu_gnome.rs @@ -0,0 +1,476 @@ +//! Ubuntu GNOME's native Overlay adapter lifecycle. +//! +//! The Shell extension is package-owned. This module only persists per-user +//! enablement and reports whether GNOME could activate it in the current login. + +use std::path::Path; +use std::process::{Command, Output}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::service::{UserServiceAction, manage_user_service}; + +pub const EXTENSION_UUID: &str = "overlay@voisu.app"; +pub const EXTENSION_DIR: &str = "/usr/share/gnome-shell/extensions/overlay@voisu.app"; +const SCHEMA: &str = "app.voisu.shell-overlay"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LiveState { + Active, + RestartRequired, + Unavailable, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TriggerState { + Active, + NotInstalled, + RestartRequired, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SetupResult { + pub overlay: LiveState, + pub trigger: TriggerState, + pub trigger_key: Option, + pub daemon_restart_required: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DoctorState { + pub overlay: LiveState, + pub trigger: TriggerState, + pub trigger_key: Option, + pub detail: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ExtensionInfoState { + Active, + Disabled, + Error, + OutOfDate, + Pending(String), + Other(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum ExtensionRuntimeState { + NotLoaded, + Known(ExtensionInfoState), +} + +pub fn setup() -> Result { + if !Path::new(EXTENSION_DIR).join("extension.js").is_file() { + return Err( + "Ubuntu native Overlay extension is not installed; reinstall the Voisu package" + .to_owned(), + ); + } + + // Ubuntu must never start the notification-only GTK observer alongside the + // Shell capsule. Missing units and already-stopped units are harmless. + crate::service::disable_ubuntu_gtk_overlay()?; + + let install = manage_user_service(UserServiceAction::Install)?; + if install.exit_code != 0 { + return Err(install.message); + } + let daemon_restart_required = match manage_user_service(UserServiceAction::Start) { + Ok(report) if report.exit_code == 0 => false, + _ if systemctl_enabled("voisu.service") => true, + Err(error) => { + return Err(format!( + "daemon could not start and persistent enablement was not confirmed: {error}" + )); + } + Ok(report) => { + return Err(format!( + "daemon could not start and persistent enablement was not confirmed: {}", + report.message + )); + } + }; + + persist_extension_enablement()?; + // This can activate an extension GNOME already discovered. A fresh + // machine-wide install commonly needs the next login; persistence above is + // the completion contract, not this best-effort live attempt. + let _ = run("gnome-extensions", &["enable", EXTENSION_UUID]); + + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match query_extension_state()? { + ExtensionRuntimeState::Known(ExtensionInfoState::Active) => { + let trigger = if gsettings_bool("trigger-active") == Some(true) { + TriggerState::Active + } else { + TriggerState::NotInstalled + }; + return Ok(SetupResult { + overlay: LiveState::Active, + trigger, + trigger_key: configured_trigger_key(), + daemon_restart_required, + }); + } + ExtensionRuntimeState::NotLoaded if Instant::now() >= deadline => { + return Ok(SetupResult { + overlay: LiveState::RestartRequired, + trigger: TriggerState::RestartRequired, + trigger_key: configured_trigger_key(), + daemon_restart_required, + }); + } + ExtensionRuntimeState::Known(ExtensionInfoState::Pending(state)) + if Instant::now() < deadline => + { + let _ = state; + } + ExtensionRuntimeState::Known(state) => { + return Err(extension_failure_detail(&state)); + } + ExtensionRuntimeState::NotLoaded => {} + } + thread::sleep(Duration::from_millis(50)); + } +} + +pub fn doctor_state() -> DoctorState { + let binding = configured_trigger_key(); + if !Path::new(EXTENSION_DIR).join("extension.js").is_file() { + return DoctorState { + overlay: LiveState::Unavailable, + trigger: TriggerState::NotInstalled, + trigger_key: binding, + detail: Some( + "Ubuntu native Overlay extension is missing; reinstall the Voisu package" + .to_owned(), + ), + }; + } + if !extension_is_persisted() { + return DoctorState { + overlay: LiveState::Unavailable, + trigger: TriggerState::NotInstalled, + trigger_key: binding, + detail: Some( + "GNOME did not confirm persistent Voisu Overlay enablement; run `voisu setup`" + .to_owned(), + ), + }; + } + match query_extension_state() { + Ok(runtime) => { + let (overlay, trigger, detail) = + classify_runtime_state(&runtime, gsettings_bool("trigger-active") == Some(true)); + DoctorState { + overlay, + trigger, + trigger_key: binding, + detail, + } + } + Err(error) => DoctorState { + overlay: LiveState::Unavailable, + trigger: TriggerState::NotInstalled, + trigger_key: binding, + detail: Some(error), + }, + } +} + +fn classify_runtime_state( + runtime: &ExtensionRuntimeState, + trigger_active: bool, +) -> (LiveState, TriggerState, Option) { + match runtime { + ExtensionRuntimeState::NotLoaded => ( + LiveState::RestartRequired, + TriggerState::RestartRequired, + None, + ), + ExtensionRuntimeState::Known(ExtensionInfoState::Active) => ( + LiveState::Active, + if trigger_active { + TriggerState::Active + } else { + TriggerState::NotInstalled + }, + None, + ), + ExtensionRuntimeState::Known(state) => ( + LiveState::Unavailable, + TriggerState::NotInstalled, + Some(extension_failure_detail(state)), + ), + } +} + +fn systemctl_enabled(unit: &str) -> bool { + run("systemctl", &["--user", "is-enabled", unit]).is_ok_and(|output| { + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "enabled" + }) +} + +fn persist_extension_enablement() -> Result<(), String> { + let output = run( + "gsettings", + &["get", "org.gnome.shell", "enabled-extensions"], + ) + .map_err(|error| format!("could not read GNOME extension enablement: {error}"))?; + if !output.status.success() { + return Err("could not read GNOME extension enablement".to_owned()); + } + let current = parse_string_array(&String::from_utf8_lossy(&output.stdout)) + .ok_or_else(|| "GNOME returned malformed extension enablement".to_owned())?; + let merged = merge_extension_uuid(¤t); + let encoded = encode_string_array(&merged); + let set = run( + "gsettings", + &["set", "org.gnome.shell", "enabled-extensions", &encoded], + ) + .map_err(|error| format!("could not persist GNOME extension enablement: {error}"))?; + if !set.status.success() || !extension_is_persisted() { + return Err("GNOME did not confirm persistent Voisu Overlay enablement".to_owned()); + } + Ok(()) +} + +fn extension_is_persisted() -> bool { + let Ok(output) = run( + "gsettings", + &["get", "org.gnome.shell", "enabled-extensions"], + ) else { + return false; + }; + output.status.success() + && parse_string_array(&String::from_utf8_lossy(&output.stdout)) + .is_some_and(|items| items.iter().any(|item| item == EXTENSION_UUID)) +} + +fn query_extension_state() -> Result { + let list = run_c_locale("gnome-extensions", &["list"]) + .map_err(|error| format!("could not query GNOME extensions: {error}"))?; + if !list.status.success() { + return Err("could not query GNOME extensions; run `gnome-extensions list`".to_owned()); + } + if !extension_list_contains(&String::from_utf8_lossy(&list.stdout), EXTENSION_UUID) { + return Ok(ExtensionRuntimeState::NotLoaded); + } + + let info = run_c_locale("gnome-extensions", &["info", EXTENSION_UUID]) + .map_err(|error| format!("could not query the Voisu Overlay state: {error}"))?; + if !info.status.success() { + return Err(format!( + "GNOME knows {EXTENSION_UUID} but its state query failed; run `gnome-extensions info {EXTENSION_UUID}`" + )); + } + let state = parse_extension_state(&String::from_utf8_lossy(&info.stdout)).ok_or_else(|| { + format!( + "GNOME returned no state for {EXTENSION_UUID}; run `gnome-extensions info {EXTENSION_UUID}`" + ) + })?; + Ok(ExtensionRuntimeState::Known(state)) +} + +fn extension_list_contains(output: &str, uuid: &str) -> bool { + output.lines().any(|line| line.trim() == uuid) +} + +fn parse_extension_state(output: &str) -> Option { + let value = output + .lines() + .find_map(|line| line.trim().strip_prefix("State:").map(str::trim))?; + let normalized = value.replace([' ', '-'], "_").to_ascii_uppercase(); + Some(match normalized.as_str() { + "ACTIVE" | "ENABLED" => ExtensionInfoState::Active, + "DISABLED" | "INACTIVE" => ExtensionInfoState::Disabled, + "ERROR" => ExtensionInfoState::Error, + "OUT_OF_DATE" => ExtensionInfoState::OutOfDate, + "ACTIVATING" | "DEACTIVATING" | "INITIALIZED" | "DOWNLOADING" => { + ExtensionInfoState::Pending(normalized) + } + _ => ExtensionInfoState::Other(value.to_owned()), + }) +} + +fn extension_failure_detail(state: &ExtensionInfoState) -> String { + let state = match state { + ExtensionInfoState::Active => "ACTIVE", + ExtensionInfoState::Disabled => "DISABLED", + ExtensionInfoState::Error => "ERROR", + ExtensionInfoState::OutOfDate => "OUT_OF_DATE", + ExtensionInfoState::Pending(state) | ExtensionInfoState::Other(state) => state, + }; + format!( + "GNOME reports the Voisu Overlay as {state}; inspect `gnome-extensions info {EXTENSION_UUID}` and the GNOME Shell journal, then run `voisu setup`" + ) +} + +fn configured_trigger_key() -> Option { + let output = run("gsettings", &["get", SCHEMA, "voisu-trigger-key"]).ok()?; + let values = parse_string_array(&String::from_utf8_lossy(&output.stdout))?; + values.first().map(|value| format_binding(value)) +} + +fn format_binding(value: &str) -> String { + value + .replace("", "Ctrl+") + .replace("", "Shift+") + .replace("", "Alt+") + .replace("", "Super+") +} + +fn merge_extension_uuid(current: &[String]) -> Vec { + let mut merged = current.to_vec(); + if !merged.iter().any(|item| item == EXTENSION_UUID) { + merged.push(EXTENSION_UUID.to_owned()); + } + merged +} + +fn parse_string_array(value: &str) -> Option> { + let value = value.trim().strip_prefix("@as ").unwrap_or(value.trim()); + let inner = value.strip_prefix('[')?.strip_suffix(']')?.trim(); + if inner.is_empty() { + return Some(Vec::new()); + } + let mut items = Vec::new(); + let mut chars = inner.chars().peekable(); + loop { + while chars.peek().is_some_and(|c| c.is_whitespace() || *c == ',') { + chars.next(); + } + let quote = chars.next()?; + if quote != '\'' && quote != '"' { + return None; + } + let mut item = String::new(); + loop { + match chars.next()? { + '\\' => item.push(chars.next()?), + c if c == quote => break, + c => item.push(c), + } + } + items.push(item); + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + match chars.peek() { + None => break, + Some(',') => { + chars.next(); + } + _ => return None, + } + } + Some(items) +} + +fn encode_string_array(items: &[String]) -> String { + format!( + "[{}]", + items + .iter() + .map(|item| format!("'{}'", item.replace('\\', "\\\\").replace('\'', "\\'"))) + .collect::>() + .join(", ") + ) +} + +fn gsettings_bool(key: &str) -> Option { + let output = run("gsettings", &["get", SCHEMA, key]).ok()?; + if !output.status.success() { + return None; + } + match String::from_utf8_lossy(&output.stdout).trim() { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +fn run(program: &str, args: &[&str]) -> std::io::Result { + Command::new(program).args(args).output() +} + +fn run_c_locale(program: &str, args: &[&str]) -> std::io::Result { + Command::new(program) + .args(args) + .env("LC_ALL", "C") + .env("LANG", "C") + .output() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persistent_enablement_merge_preserves_existing_extensions_and_is_idempotent() { + assert_eq!( + merge_extension_uuid(&["other@example.org".to_owned()]), + ["other@example.org", EXTENSION_UUID] + ); + assert_eq!( + merge_extension_uuid(&[EXTENSION_UUID.to_owned()]), + [EXTENSION_UUID] + ); + } + + #[test] + fn gsettings_array_round_trips_preserved_values() { + let original = vec!["one@example.org".to_owned(), "quote'and\\slash".to_owned()]; + assert_eq!( + parse_string_array(&encode_string_array(&original)), + Some(original) + ); + assert_eq!(parse_string_array("@as []"), Some(Vec::new())); + } + + #[test] + fn c_locale_extension_info_parser_reads_only_the_state_field() { + assert_eq!( + parse_extension_state("Name: Voisu Overlay\nState: ACTIVE\n"), + Some(ExtensionInfoState::Active) + ); + assert_eq!(parse_extension_state("Estado: ACTIVE\n"), None); + assert_eq!( + parse_extension_state("State: OUT_OF_DATE\n"), + Some(ExtensionInfoState::OutOfDate) + ); + assert_eq!( + parse_extension_state("State: DISABLED\n"), + Some(ExtensionInfoState::Disabled) + ); + assert_eq!( + parse_extension_state("State: ERROR\n"), + Some(ExtensionInfoState::Error) + ); + } + + #[test] + fn restart_is_reserved_for_a_persisted_extension_not_loaded_by_this_shell() { + assert_eq!( + classify_runtime_state(&ExtensionRuntimeState::NotLoaded, false), + ( + LiveState::RestartRequired, + TriggerState::RestartRequired, + None + ) + ); + for state in [ + ExtensionInfoState::Disabled, + ExtensionInfoState::Error, + ExtensionInfoState::OutOfDate, + ] { + let (overlay, trigger, detail) = + classify_runtime_state(&ExtensionRuntimeState::Known(state), false); + assert_eq!(overlay, LiveState::Unavailable); + assert_eq!(trigger, TriggerState::NotInstalled); + assert!(detail.is_some()); + } + } +} diff --git a/crates/voisu-app/tests/local_l5_status.rs b/crates/voisu-app/tests/local_l5_status.rs index 696bdf44..e86b4313 100644 --- a/crates/voisu-app/tests/local_l5_status.rs +++ b/crates/voisu-app/tests/local_l5_status.rs @@ -175,9 +175,19 @@ fn local_doctor_skips_cloud_probes_with_explicit_skip_rows() { fn local_setup_does_not_prompt_for_cloud_keys() { let harness = Harness::new(); assert!(harness.voisu(&["mode", "local"]).status.success()); + fs::create_dir_all(harness.config.path().join("hypr")).unwrap(); + fs::write( + harness.config.path().join("hypr/hyprland.lua"), + "-- current Lua configuration for setup profile discovery\n", + ) + .unwrap(); let setup = harness .command(env!("CARGO_BIN_EXE_voisu")) .args(["setup"]) + .env("XDG_SESSION_TYPE", "wayland") + .env("WAYLAND_DISPLAY", "wayland-test") + .env("XDG_CURRENT_DESKTOP", "Hyprland") + .env("HYPRLAND_INSTANCE_SIGNATURE", "setup-test-instance") .env("VOISU_TEST_SETUP_WIZARD_ONLY", "1") .stdin(Stdio::null()) .output() @@ -202,3 +212,60 @@ fn local_setup_does_not_prompt_for_cloud_keys() { ); assert!(!combined.contains("Download and install"), "{combined}"); } + +#[test] +fn setup_discovers_the_profile_before_local_prompts() { + let harness = Harness::new(); + assert!(harness.voisu(&["mode", "local"]).status.success()); + + let setup = harness + .command(env!("CARGO_BIN_EXE_voisu")) + .args(["setup"]) + .env("XDG_SESSION_TYPE", "x11") + .env("DISPLAY", ":0") + .env_remove("WAYLAND_DISPLAY") + .env_remove("HYPRLAND_INSTANCE_SIGNATURE") + .env_remove("XDG_CURRENT_DESKTOP") + .env_remove("XDG_SESSION_DESKTOP") + .stdin(Stdio::null()) + .output() + .expect("setup"); + + let combined = format!("{}{}", stdout(&setup), stderr(&setup)); + assert_eq!(setup.status.code(), Some(4), "{combined}"); + assert!( + combined.contains("unsupported setup session: X11"), + "{combined}" + ); + assert!(!combined.contains("Local Setup"), "{combined}"); +} + +#[test] +fn setup_discovers_the_profile_before_cloud_prompts() { + let harness = Harness::new(); + + let setup = harness + .command(env!("CARGO_BIN_EXE_voisu")) + .args(["setup"]) + .env("XDG_SESSION_TYPE", "x11") + .env("DISPLAY", ":0") + .env_remove("WAYLAND_DISPLAY") + .env_remove("HYPRLAND_INSTANCE_SIGNATURE") + .env_remove("XDG_CURRENT_DESKTOP") + .env_remove("XDG_SESSION_DESKTOP") + .stdin(Stdio::null()) + .output() + .expect("setup"); + + let combined = format!("{}{}", stdout(&setup), stderr(&setup)); + assert_eq!(setup.status.code(), Some(4), "{combined}"); + assert!( + combined.contains("unsupported setup session: X11"), + "{combined}" + ); + assert!( + !combined.contains("Enter your Deepgram API key"), + "{combined}" + ); + assert!(!combined.contains("Enter your Groq API key"), "{combined}"); +} diff --git a/crates/voisu-app/tests/service_cli.rs b/crates/voisu-app/tests/service_cli.rs index e01aa03a..c792f0b9 100644 --- a/crates/voisu-app/tests/service_cli.rs +++ b/crates/voisu-app/tests/service_cli.rs @@ -84,6 +84,7 @@ impl ServiceFixture { .env("VOISU_DISABLE_DIRECT_DELIVERY", "1") .env("VOISU_TEST_MODE", "controlled") .env_remove("HYPRLAND_INSTANCE_SIGNATURE"); + command.env_remove("XDG_SESSION_DESKTOP"); command } @@ -383,12 +384,31 @@ fn assert_unit_assignment(unit_name: &str, unit: &str, assignment: &str, expecte } fn assert_unit_assignment_absent(unit_name: &str, unit: &str, assignment: &str) { + let key = assignment + .strip_suffix('=') + .expect("assignment contract must end with ="); assert!( - !unit.lines().any(|line| line.starts_with(assignment)), + !unit.lines().any(|line| { + line.split_once('=') + .is_some_and(|(candidate, _)| candidate.trim() == key) + }), "{unit_name} must not contain a {assignment} assignment" ); } +#[test] +fn unit_assignment_absence_rejects_whitespace_valid_assignment() { + let result = std::panic::catch_unwind(|| { + assert_unit_assignment_absent( + "fixture.service", + " ProtectHostname = yes\n", + "ProtectHostname=", + ); + }); + + assert!(result.is_err(), "whitespace cannot hide a valid assignment"); +} + fn assert_graphical_session_unit_shape( unit_name: &str, unit: &str, @@ -440,11 +460,7 @@ fn assert_packaged_daemon_runtime_contract(unit: &str) { "AF_UNIX AF_INET AF_INET6 AF_NETLINK", ), ("ProtectKernelTunables=", "yes"), - ("ProtectKernelModules=", "yes"), - ("ProtectKernelLogs=", "yes"), ("ProtectControlGroups=", "yes"), - ("ProtectClock=", "yes"), - ("ProtectHostname=", "yes"), ("RestrictRealtime=", "yes"), ("RestrictSUIDSGID=", "yes"), ("LockPersonality=", "yes"), @@ -454,6 +470,14 @@ fn assert_packaged_daemon_runtime_contract(unit: &str) { ] { assert_unit_assignment("voisu.service", unit, assignment, expected); } + for assignment in [ + "ProtectKernelModules=", + "ProtectKernelLogs=", + "ProtectClock=", + "ProtectHostname=", + ] { + assert_unit_assignment_absent("voisu.service", unit, assignment); + } } fn assert_packaged_overlay_runtime_contract(unit: &str) { @@ -465,11 +489,7 @@ fn assert_packaged_overlay_runtime_contract(unit: &str) { ("PrivateTmp=", "yes"), ("RestrictAddressFamilies=", "AF_UNIX"), ("ProtectKernelTunables=", "yes"), - ("ProtectKernelModules=", "yes"), - ("ProtectKernelLogs=", "yes"), ("ProtectControlGroups=", "yes"), - ("ProtectClock=", "yes"), - ("ProtectHostname=", "yes"), ("RestrictRealtime=", "yes"), ("RestrictSUIDSGID=", "yes"), ("LockPersonality=", "yes"), @@ -478,6 +498,14 @@ fn assert_packaged_overlay_runtime_contract(unit: &str) { ] { assert_unit_assignment("voisu-overlay.service", unit, assignment, expected); } + for assignment in [ + "ProtectKernelModules=", + "ProtectKernelLogs=", + "ProtectClock=", + "ProtectHostname=", + ] { + assert_unit_assignment_absent("voisu-overlay.service", unit, assignment); + } } #[test] diff --git a/docs/adr/0011-require-a-native-ubuntu-overlay.md b/docs/adr/0011-require-a-native-ubuntu-overlay.md new file mode 100644 index 00000000..28e932de --- /dev/null +++ b/docs/adr/0011-require-a-native-ubuntu-overlay.md @@ -0,0 +1,12 @@ +# Require a native Overlay for Ubuntu GNOME + +The Ubuntu GNOME Setup Profile is complete only when Voisu can show its capsule +as an Overlay. Desktop notifications do not satisfy this contract. Because +GNOME does not support the layer-shell protocol used by the GTK Overlay, Voisu +will use a GNOME Shell extension for the Ubuntu capsule rather than pretending +the notification fallback provides the same experience. The extension owns an +explicit Voisu Trigger Key setting whose default is `Ctrl+Space`; users may +change that setting without modifying GNOME's general custom-shortcut list. +Ubuntu setup deliberately gives Voisu ownership of `Ctrl+Space`, replacing or +shadowing an existing binding for that accelerator. A user who wants the prior +binding must change Voisu's extension-owned Trigger Key afterward. diff --git a/docs/packaging-fedora.md b/docs/packaging-fedora.md index f76e6639..a989b105 100644 --- a/docs/packaging-fedora.md +++ b/docs/packaging-fedora.md @@ -152,6 +152,11 @@ Both user units carry systemd hardening directives (`NoNewPrivileges`, protections, `SystemCallArchitectures=native`) so a compromised dependency gets a confined process. The deliberate exceptions: +- `ProtectKernelModules`, `ProtectKernelLogs`, `ProtectClock`, and + `ProtectHostname` are omitted. On systems that restrict unprivileged user + namespaces, these can make a per-user service fail before `ExecStart` with + `status=218/CAPABILITIES`. + - Daemon `ReadWritePaths=%t %h/.config/voisu %h/.local/state/voisu` — control socket and capture scratch, config/dictionary, history/diagnostics. - Daemon keeps `AF_INET`/`AF_INET6` (provider HTTPS/WSS via curl) and diff --git a/packaging/RELEASING.md b/packaging/RELEASING.md index 8e3f65aa..7fa3370c 100644 --- a/packaging/RELEASING.md +++ b/packaging/RELEASING.md @@ -84,7 +84,7 @@ not-yet-published channel. | Leg | Script | Path exercised | Asserts | |-----|--------|----------------|---------| -| ubuntu:26.04 | `packaging/ci/smoke-ubuntu.sh` | publishes the built `.deb` into a local apt repo signed with an **ephemeral** key, serves it over local HTTP, adds the repo the documented friend way (fingerprint-pin → `signed-by`), `apt install voisu` with signatures enforced | `voisu --version`, `voisu-daemon --help`, `systemd-analyze verify` on both user units, `lintian` clean-enough | +| ubuntu:26.04 | `packaging/ci/smoke-ubuntu.sh` | publishes the built `.deb` into a local apt repo signed with an **ephemeral** key, serves it over local HTTP, adds the repo the documented friend way (fingerprint-pin → `signed-by`), `apt install voisu` with signatures enforced | `voisu --version`, `voisu-daemon --help`, `systemd-analyze verify` on both user units, rejects user-service-incompatible capability protections, `lintian` clean-enough | | fedora:latest | `packaging/ci/smoke-fedora.sh` | `build-rpm.sh` (non-root) builds the RPM, `dnf install` the main + Overlay subpackage | binaries run, `systemd-analyze verify` both units, `rpm --requires`/file-list checks | | archlinux (arch) | `packaging/ci/smoke-arch.sh` | `makepkg -si` the **source** PKGBUILD pointed at the tag | `namcap` clean (errors only), binaries run, `systemd-analyze verify` both units | | archlinux (arch-bin) | `packaging/ci/smoke-arch-bin.sh` | stages voisu-bin exactly as `aur-publish.sh` will (pkgver/sha256/.SRCINFO) but sources the **locally built release tarball**, then `makepkg -si` | proves the tarball layout matches the PKGBUILD's install steps; `namcap` clean, binaries run, `systemd-analyze verify` both units | diff --git a/packaging/ci/smoke-ubuntu.sh b/packaging/ci/smoke-ubuntu.sh index ff2e916d..ad6720ac 100755 --- a/packaging/ci/smoke-ubuntu.sh +++ b/packaging/ci/smoke-ubuntu.sh @@ -87,6 +87,11 @@ echo "== binaries ==" test -x /usr/bin/voisu test -x /usr/bin/voisu-daemon test -x /usr/bin/voisu-overlay +test -f /usr/share/gnome-shell/extensions/overlay@voisu.app/extension.js +test -f /usr/share/gnome-shell/extensions/overlay@voisu.app/metadata.json +test -f /usr/share/glib-2.0/schemas/app.voisu.shell-overlay.gschema.xml +grep -Fq '"shell-version": ["50"]' /usr/share/gnome-shell/extensions/overlay@voisu.app/metadata.json +gsettings list-keys app.voisu.shell-overlay | grep -qx 'voisu-trigger-key' voisu --version voisu-daemon --help >/dev/null @@ -95,6 +100,17 @@ echo "== systemd-analyze verify (both user units) ==" systemd-analyze verify /usr/lib/systemd/user/voisu.service systemd-analyze verify /usr/lib/systemd/user/voisu-overlay.service +# These directives can require capability or namespace setup unavailable to a +# per-user manager when Ubuntu restricts unprivileged user namespaces. +for unit in voisu.service voisu-overlay.service; do + unit_path="/usr/lib/systemd/user/$unit" + if grep -Eq '^[[:space:]]*(ProtectKernelModules|ProtectKernelLogs|ProtectClock|ProtectHostname)[[:space:]]*=' "$unit_path"; then + echo "FAIL: $unit contains a user-service-incompatible protection directive" + exit 1 + fi +done +echo "[evidence] both user units omit capability-dropping protection directives" + # --- assertion: a fresh home cannot break namespace setup --- echo "== fresh-home safety: the unit provisions its own config/state dirs ==" # A fresh home that never created ~/.config/voisu or ~/.local/state/voisu must diff --git a/packaging/deb/postinst b/packaging/deb/postinst index 4bc1ae8a..a590233c 100755 --- a/packaging/deb/postinst +++ b/packaging/deb/postinst @@ -9,6 +9,9 @@ set -e case "$1" in configure) + if command -v glib-compile-schemas >/dev/null 2>&1; then + glib-compile-schemas /usr/share/glib-2.0/schemas + fi cat <<'EOF' Voisu is installed. Its dictation daemon and Overlay run as systemd *user* @@ -17,11 +20,13 @@ services and are NOT enabled automatically. To start Voisu for your user, run: systemctl --user daemon-reload systemctl --user enable --now voisu.service -To also run the optional Overlay -- the on-screen status surface that reflects -daemon state -- run: +On Fedora/Hyprland, to also run the optional GTK Overlay, run: systemctl --user enable --now voisu-overlay.service +On Ubuntu 26.04 GNOME Wayland, run `voisu setup`; it enables the packaged +native GNOME Shell Overlay and keeps the GTK notification observer disabled. + EOF ;; abort-upgrade|abort-remove|abort-deconfigure) diff --git a/packaging/deb/postrm b/packaging/deb/postrm index 42e58c60..bfdf8dbe 100755 --- a/packaging/deb/postrm +++ b/packaging/deb/postrm @@ -7,6 +7,9 @@ set -e case "$1" in remove|purge) + if command -v glib-compile-schemas >/dev/null 2>&1; then + glib-compile-schemas /usr/share/glib-2.0/schemas + fi cat <<'EOF' Voisu has been removed. If you enabled its user services, disable them for your diff --git a/packaging/gnome-shell/overlay@voisu.app/extension.js b/packaging/gnome-shell/overlay@voisu.app/extension.js new file mode 100644 index 00000000..88dae305 --- /dev/null +++ b/packaging/gnome-shell/overlay@voisu.app/extension.js @@ -0,0 +1,206 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import {MAX_RESPONSE_BYTES, RequestLifecycle, capsuleForResponse, parseBoundedResponse} from './state.mjs'; + +const PROTOCOL_VERSION = 1; +const VISIBLE_MS = 1400; +const ROUND_TRIP_MS = 750; + +export default class VoisuOverlay extends Extension { + enable() { + this._requestLifecycle ??= new RequestLifecycle(); + this._requestGeneration = this._requestLifecycle.enable(); + this._settings = this.getSettings(); + this._capsule = new St.Label({style_class: 'voisu-capsule', visible: false}); + Main.layoutManager.addTopChrome(this._capsule); + this._position(); + this._lastEvent = null; + this._pollInFlight = false; + this._settingsChanged = this._settings.connect('changed::voisu-trigger-key', () => this._installBinding()); + this._installBinding(); + this._pollId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, () => { + this._poll(); + return GLib.SOURCE_CONTINUE; + }); + } + + disable() { + this._requestLifecycle?.disable(); + if (this._pollId) GLib.source_remove(this._pollId); + this._pollId = 0; + if (this._hideId) GLib.source_remove(this._hideId); + this._hideId = 0; + if (this._settingsChanged) this._settings.disconnect(this._settingsChanged); + this._settingsChanged = 0; + this._pollInFlight = false; + this._removeBinding(); + this._settings?.set_boolean('trigger-active', false); + this._capsule?.destroy(); + this._capsule = null; + } + + _position() { + const monitor = Main.layoutManager.primaryMonitor; + const [, width] = this._capsule.get_preferred_width(-1); + this._capsule.set_position( + Math.floor(monitor.x + (monitor.width - width) / 2), + monitor.y + monitor.height - 110); + } + + _installBinding() { + this._removeBinding(); + this._settings.set_boolean('trigger-active', false); + try { + this._bindingAttempted = true; + const action = Main.wm.addKeybinding('voisu-trigger-key', this._settings, Meta.KeyBindingFlags.NONE, + Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, + () => this._sendCommand('toggle')); + if (action === Meta.KeyBindingAction.NONE) + throw new Error('GNOME could not register the Voisu Trigger Key'); + this._settings.set_boolean('trigger-active', true); + } catch (error) { + console.warn(`Voisu Trigger Key not installed: ${error.message}`); + } + } + + _removeBinding() { + if (!this._bindingAttempted) return; + Main.wm.removeKeybinding('voisu-trigger-key'); + this._bindingAttempted = false; + } + + _socketPath() { + return `${GLib.get_user_runtime_dir()}/voisu/v${PROTOCOL_VERSION}/daemon.sock`; + } + + _sendCommand(command) { + this._request(command, response => this._render(response), + () => this._show('Voisu unavailable', 'voisu-failure', true)); + } + + _poll() { + if (this._pollInFlight) return; + this._pollInFlight = true; + this._request('overlaystatus', response => { + this._pollInFlight = false; + this._render(response); + }, () => { + // Daemon availability is diagnosed by `voisu doctor`; idle polling stays quiet. + this._pollInFlight = false; + }); + } + + _request(command, onReply, onError) { + const client = new Gio.SocketClient(); + const address = new Gio.UnixSocketAddress({path: this._socketPath()}); + const cancellable = new Gio.Cancellable(); + let settled = false; + let connection = null; + let timeoutId = 0; + let request = null; + const cleanup = () => { + if (timeoutId) { + GLib.source_remove(timeoutId); + timeoutId = 0; + } + connection?.close_async(GLib.PRIORITY_DEFAULT, null, null); + connection = null; + }; + const cancel = () => { + if (settled) return; + settled = true; + cancellable.cancel(); + cleanup(); + }; + const finish = (reply, failed = false) => { + if (settled) return; + settled = true; + cleanup(); + if (!this._requestLifecycle.finish(request)) return; + if (failed) onError?.(); else onReply?.(reply); + }; + request = this._requestLifecycle.register(this._requestGeneration, cancel); + if (settled) return; + timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ROUND_TRIP_MS, () => { + cancellable.cancel(); + finish(null, true); + return GLib.SOURCE_REMOVE; + }); + client.connect_async(address, cancellable, (_client, result) => { + try { + const connected = client.connect_finish(result); + if (settled) { + connected.close_async(GLib.PRIORITY_DEFAULT, null, null); + return; + } + connection = connected; + } catch (_) { + finish(null, true); + return; + } + const request = new TextEncoder().encode(JSON.stringify({version: PROTOCOL_VERSION, command}) + '\n'); + connection.output_stream.write_all_async(request, GLib.PRIORITY_DEFAULT, cancellable, (output, writeResult) => { + try { + output.write_all_finish(writeResult); + if (settled) return; + const chunks = []; + let size = 0; + const readNext = () => connection.input_stream.read_bytes_async( + 4096, GLib.PRIORITY_DEFAULT, cancellable, (stream, readResult) => { + try { + const bytes = stream.read_bytes_finish(readResult); + if (settled) return; + const data = bytes.get_data(); + if (!data.length) throw new Error('daemon closed without a response'); + size += data.length; + if (size > MAX_RESPONSE_BYTES) throw new Error('daemon response exceeds bound'); + chunks.push(...data); + const newline = chunks.indexOf(10); + if (newline < 0) return readNext(); + const line = new TextDecoder().decode(new Uint8Array(chunks.slice(0, newline))); + finish(parseBoundedResponse(line)); + } catch (_) { + finish(null, true); + } + }); + readNext(); + } catch (_) { + finish(null, true); + } + }); + }); + } + + _render(response) { + const capsule = capsuleForResponse(response, this._lastEvent); + if (capsule.eventIdentity) this._lastEvent = capsule.eventIdentity; + if (capsule.visible) + return this._show(capsule.text, capsule.styleClass, capsule.terminal); + if (response.overlay_event) { + // A retained terminal event already shown stays hidden instead of + // resurfacing on every observer poll. + return; + } + this._capsule.hide(); + } + + _show(text, phaseClass, terminal) { + for (const name of ['voisu-recording', 'voisu-processing', 'voisu-success', 'voisu-failure', 'voisu-nospeech']) + this._capsule.remove_style_class_name(name); + this._capsule.add_style_class_name(phaseClass); + this._capsule.text = text; + this._capsule.show(); + this._position(); + if (this._hideId) GLib.source_remove(this._hideId); + if (terminal) this._hideId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, VISIBLE_MS, () => { + this._capsule.hide(); + this._hideId = 0; + return GLib.SOURCE_REMOVE; + }); + } +} diff --git a/packaging/gnome-shell/overlay@voisu.app/metadata.json b/packaging/gnome-shell/overlay@voisu.app/metadata.json new file mode 100644 index 00000000..a6dc1910 --- /dev/null +++ b/packaging/gnome-shell/overlay@voisu.app/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "overlay@voisu.app", + "name": "Voisu Overlay", + "description": "Native Recording status capsule and Trigger Key for Voisu", + "shell-version": ["50"], + "settings-schema": "app.voisu.shell-overlay", + "version": 1 +} diff --git a/packaging/gnome-shell/overlay@voisu.app/schemas/app.voisu.shell-overlay.gschema.xml b/packaging/gnome-shell/overlay@voisu.app/schemas/app.voisu.shell-overlay.gschema.xml new file mode 100644 index 00000000..65079a02 --- /dev/null +++ b/packaging/gnome-shell/overlay@voisu.app/schemas/app.voisu.shell-overlay.gschema.xml @@ -0,0 +1,13 @@ + + + + + ['<Control>space'] + Voisu Trigger Key + + + false + Whether GNOME accepted the Trigger Key + + + diff --git a/packaging/gnome-shell/overlay@voisu.app/state.mjs b/packaging/gnome-shell/overlay@voisu.app/state.mjs new file mode 100644 index 00000000..71447e61 --- /dev/null +++ b/packaging/gnome-shell/overlay@voisu.app/state.mjs @@ -0,0 +1,67 @@ +export const MAX_RESPONSE_BYTES = 64 * 1024; + +export class RequestLifecycle { + constructor() { + this._generation = 0; + this._requests = new Set(); + } + + enable() { + return ++this._generation; + } + + register(generation, cancel) { + const request = {generation, cancel}; + if (generation !== this._generation) { + cancel(); + return request; + } + this._requests.add(request); + return request; + } + + finish(request) { + this._requests.delete(request); + return request.generation === this._generation; + } + + disable() { + this._generation++; + const requests = [...this._requests]; + this._requests.clear(); + for (const request of requests) + request.cancel(); + } +} + +export function parseBoundedResponse(line) { + if (typeof line !== 'string' || new TextEncoder().encode(line).length > MAX_RESPONSE_BYTES) + throw new Error('invalid daemon response size'); + const response = JSON.parse(line); + if (!response || typeof response !== 'object' || response.version !== 1 || typeof response.ok !== 'boolean') + throw new Error('invalid daemon response'); + return response; +} + +export function capsuleForResponse(response, lastEvent = null) { + if (!response.ok) + return {visible: true, text: response.message || 'Voisu rejected request', styleClass: 'voisu-failure', terminal: true}; + if (response.state === 'recording') + return {visible: true, text: 'Recording', styleClass: 'voisu-recording', terminal: false}; + if (response.state === 'processing') + return {visible: true, text: 'Processing', styleClass: 'voisu-processing', terminal: false}; + const event = response.overlay_event; + if (!event) return {visible: false}; + const eventIdentity = `${event.instance}:${event.id}`; + if (eventIdentity === lastEvent) return {visible: false}; + if (['empty_recording', 'too_short_recording', 'silent_recording'].includes(event.outcome)) + return {visible: true, text: 'No speech', styleClass: 'voisu-nospeech', terminal: true, eventIdentity}; + const success = event.outcome === 'delivered'; + return { + visible: true, + text: success ? '✓' : 'Recording failed', + styleClass: success ? 'voisu-success' : 'voisu-failure', + terminal: true, + eventIdentity, + }; +} diff --git a/packaging/gnome-shell/overlay@voisu.app/stylesheet.css b/packaging/gnome-shell/overlay@voisu.app/stylesheet.css new file mode 100644 index 00000000..b10bd415 --- /dev/null +++ b/packaging/gnome-shell/overlay@voisu.app/stylesheet.css @@ -0,0 +1,14 @@ +.voisu-capsule { + background-color: rgba(20, 20, 24, 0.96); + border-radius: 24px; + border: 2px solid rgba(255, 255, 255, 0.22); + color: white; + padding: 10px 22px; + min-width: 128px; + font-weight: 600; +} +.voisu-recording { border-color: #ff4b55; } +.voisu-processing { border-color: #8ba7ff; } +.voisu-success { border-color: #55d58a; } +.voisu-failure { border-color: #ffb454; } +.voisu-nospeech { border-color: #ffcf66; } diff --git a/packaging/gnome-shell/tests/state.test.mjs b/packaging/gnome-shell/tests/state.test.mjs new file mode 100644 index 00000000..6cdf1fa3 --- /dev/null +++ b/packaging/gnome-shell/tests/state.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {MAX_RESPONSE_BYTES, RequestLifecycle, capsuleForResponse, parseBoundedResponse} from '../overlay@voisu.app/state.mjs'; + +test('daemon state maps to the native capsule phases', () => { + assert.deepEqual(capsuleForResponse({ok: true, state: 'recording'}), { + visible: true, text: 'Recording', styleClass: 'voisu-recording', terminal: false, + }); + assert.deepEqual(capsuleForResponse({ok: true, state: 'processing'}), { + visible: true, text: 'Processing', styleClass: 'voisu-processing', terminal: false, + }); + assert.equal(capsuleForResponse({ok: true, state: 'idle'}).visible, false); +}); + +test('terminal events map to success, no-speech, and failure once', () => { + const success = capsuleForResponse({ok: true, overlay_event: {instance: 4, id: 2, outcome: 'delivered'}}); + assert.equal(success.styleClass, 'voisu-success'); + assert.equal(success.eventIdentity, '4:2'); + assert.equal(capsuleForResponse({ok: true, overlay_event: {instance: 4, id: 2, outcome: 'delivered'}}, '4:2').visible, false); + assert.equal(capsuleForResponse({ok: true, overlay_event: {instance: 4, id: 3, outcome: 'silent_recording'}}).styleClass, 'voisu-nospeech'); + assert.equal(capsuleForResponse({ok: true, overlay_event: {instance: 4, id: 4, outcome: 'provider_failure'}}).styleClass, 'voisu-failure'); +}); + +test('bounded parser rejects oversized and malformed daemon replies', () => { + assert.deepEqual(parseBoundedResponse('{"version":1,"ok":true,"state":"idle"}').state, 'idle'); + assert.throws(() => parseBoundedResponse('x'.repeat(MAX_RESPONSE_BYTES + 1)), /size/); + assert.throws(() => parseBoundedResponse('{"version":2,"ok":true}'), /invalid/); +}); + +test('rejected Toggle response renders the daemon reason', () => { + const capsule = capsuleForResponse({ok: false, message: 'Recording is already processing'}); + assert.equal(capsule.styleClass, 'voisu-failure'); + assert.equal(capsule.text, 'Recording is already processing'); +}); + +test('disable cancels old requests and isolates a re-enabled generation', () => { + const lifecycle = new RequestLifecycle(); + const firstGeneration = lifecycle.enable(); + let cancelled = 0; + const stale = lifecycle.register(firstGeneration, () => cancelled++); + + lifecycle.disable(); + const secondGeneration = lifecycle.enable(); + const current = lifecycle.register(secondGeneration, () => cancelled++); + + assert.equal(cancelled, 1); + assert.equal(lifecycle.finish(stale), false); + assert.equal(lifecycle.finish(current), true); +}); diff --git a/packaging/voisu-overlay.service b/packaging/voisu-overlay.service index df7291fa..b417fcbf 100644 --- a/packaging/voisu-overlay.service +++ b/packaging/voisu-overlay.service @@ -18,11 +18,9 @@ PrivateTmp=yes # AF_UNIX: daemon socket, Wayland, D-Bus. No network. RestrictAddressFamilies=AF_UNIX ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectKernelLogs=yes ProtectControlGroups=yes -ProtectClock=yes -ProtectHostname=yes +# ProtectKernelModules, ProtectKernelLogs, ProtectClock, and ProtectHostname are +# omitted: restricted user namespaces can make user units fail at CAPABILITIES. RestrictRealtime=yes RestrictSUIDSGID=yes LockPersonality=yes diff --git a/packaging/voisu.service b/packaging/voisu.service index 20a47f26..b1de0641 100644 --- a/packaging/voisu.service +++ b/packaging/voisu.service @@ -43,11 +43,9 @@ PrivateTmp=yes # AF_NETLINK: glibc getaddrinfo enumerates interfaces over netlink. RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectKernelLogs=yes ProtectControlGroups=yes -ProtectClock=yes -ProtectHostname=yes +# ProtectKernelModules, ProtectKernelLogs, ProtectClock, and ProtectHostname are +# omitted: restricted user namespaces can make user units fail at CAPABILITIES. RestrictRealtime=yes RestrictSUIDSGID=yes LockPersonality=yes