Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
7 changes: 6 additions & 1 deletion crates/voisu-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"],
Expand Down
6 changes: 5 additions & 1 deletion crates/voisu-app/src/bin/voisu-daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
133 changes: 123 additions & 10 deletions crates/voisu-app/src/bin/voisu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<DoctorRow> = inspector
.inspect()
.inspect_with_global_shortcuts(!ubuntu_gnome)
.into_iter()
.map(|finding| DoctorRow {
label: finding.capability.cli_label().to_owned(),
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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(()) => {
Expand Down
1 change: 1 addition & 0 deletions crates/voisu-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ pub mod setup;
pub mod setup_profile;
pub mod smart_writing;
pub mod system;
pub mod ubuntu_gnome;
70 changes: 70 additions & 0 deletions crates/voisu-app/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,45 @@ pub fn hyprland_overlay_readiness() -> Result<OverlayReadiness, String> {
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())
}
Expand Down Expand Up @@ -273,6 +312,12 @@ fn overlay_state(operation: &str) -> Result<String, String> {
}

fn manage_optional_overlay(action: OptionalOverlayAction) -> Option<OptionalOverlayReport> {
// 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;
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading