diff --git a/.gitignore b/.gitignore index 40c608c..170b3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ debian/* !debian/links !debian/rules !debian/source + +# HyprCosmic: OMC operational artifacts +.omc/ diff --git a/data/hyprcosmic.desktop b/data/hyprcosmic.desktop new file mode 100644 index 0000000..08577a1 --- /dev/null +++ b/data/hyprcosmic.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=HyprCosmic +Comment=COSMIC's compositor with a HyDE-style shell +Exec=/usr/bin/start-hyprcosmic +Type=Application +DesktopNames=COSMIC +Keywords=hyprland;hyde;waybar;cosmic; diff --git a/data/start-hyprcosmic b/data/start-hyprcosmic new file mode 100755 index 0000000..573ee0a --- /dev/null +++ b/data/start-hyprcosmic @@ -0,0 +1,50 @@ +#!/usr/bin/bash +# +# Launch a HyprCosmic session: cosmic-comp for window management, HyDE's shell +# on top. +# +# Deliberately separate from /usr/bin/start-cosmic rather than patching it. +# The stock COSMIC session entry keeps working untouched, so a broken +# HyprCosmic config is always one logout away from being escaped — which +# matters when the thing you are replacing is your desktop. + +set -e + +# Default to the installed binaries. Override either one to point at an +# in-tree debug build when testing the fork without installing it. +HYPRCOSMIC_SESSION_BIN="${HYPRCOSMIC_SESSION_BIN:-/usr/bin/cosmic-session}" +HYPRCOSMIC_COMP_BIN="${HYPRCOSMIC_COMP_BIN:-cosmic-comp}" + +# Selects the component set in cosmic-session's profile module: cosmic-panel, +# cosmic-launcher, cosmic-app-library, cosmic-workspaces, cosmic-bg and +# cosmic-files-applet are skipped; waybar and friends come from +# ~/.config/hyprcosmic/autostart. +export HYPRCOSMIC_PROFILE=hyprcosmic + +# Keep COSMIC's identity: portals, cosmic-settings and any COSMIC app still +# key off this, and the compositor underneath really is COSMIC. +export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-COSMIC}" +export XDG_SESSION_DESKTOP="${XDG_SESSION_DESKTOP:-hyprcosmic}" + +# Same hygiene as start-cosmic: a failed unit left by a previous graphical +# session will otherwise block this one from starting. +if command -v systemctl >/dev/null; then + for unit in $(systemctl --user --no-legend --state=failed --plain list-units | cut -f1 -d' '); do + partof="$(systemctl --user show -p PartOf --value "$unit")" + for target in cosmic-session.target graphical-session.target; do + if [ "$partof" = "$target" ]; then + systemctl --user reset-failed "$unit" + break + fi + done + done +fi + +# cosmic-session takes the compositor to launch as its first argument +# (main.rs: `args.next().unwrap_or_else(|| String::from("cosmic-comp"))`), +# which is how a forked cosmic-comp gets used without replacing the system one. +if [[ -z "${DBUS_SESSION_BUS_ADDRESS}" ]]; then + exec /usr/bin/dbus-run-session -- "${HYPRCOSMIC_SESSION_BIN}" "${HYPRCOSMIC_COMP_BIN}" +else + exec "${HYPRCOSMIC_SESSION_BIN}" "${HYPRCOSMIC_COMP_BIN}" +fi diff --git a/src/main.rs b/src/main.rs index 0f2c00f..8681e78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod a11y; mod comp; mod notifications; mod process; +mod profile; mod service; mod systemd; @@ -282,49 +283,54 @@ async fn start( panel_notifications_fd.as_raw_fd().to_string(), )); - let panel_key = Arc::new(Mutex::new(None)); - let notif_key = Arc::new(Mutex::new(None)); + // cosmic-panel and cosmic-notifications are started as a coupled pair + // (they share notification fds), so they cannot go through the gate in + // `start_component` and are checked here instead. + if profile::Profile::cached().is_enabled("cosmic-panel") { + let panel_key = Arc::new(Mutex::new(None)); + let notif_key = Arc::new(Mutex::new(None)); - let notifications_span = info_span!(parent: None, "cosmic-notifications"); - let panel_span = info_span!(parent: None, "cosmic-panel"); + let notifications_span = info_span!(parent: None, "cosmic-notifications"); + let panel_span = info_span!(parent: None, "cosmic-panel"); - let mut guard = notif_key.lock().await; - *guard = Some( - process_manager - .start(notifications_process( - notifications_span.clone(), - "cosmic-notifications", - notif_key.clone(), - daemon_env_vars.clone(), - daemon_notifications_fd, - panel_span.clone(), - "cosmic-panel", - panel_key.clone(), - panel_env_vars.clone(), - )) - .await - .expect("failed to start notifications daemon"), - ); - drop(guard); + let mut guard = notif_key.lock().await; + *guard = Some( + process_manager + .start(notifications_process( + notifications_span.clone(), + "cosmic-notifications", + notif_key.clone(), + daemon_env_vars.clone(), + daemon_notifications_fd, + panel_span.clone(), + "cosmic-panel", + panel_key.clone(), + panel_env_vars.clone(), + )) + .await + .expect("failed to start notifications daemon"), + ); + drop(guard); - let mut guard = panel_key.lock().await; - *guard = Some( - process_manager - .start(notifications_process( - panel_span, - "cosmic-panel", - panel_key.clone(), - panel_env_vars, - panel_notifications_fd, - notifications_span, - "cosmic-notifications", - notif_key, - daemon_env_vars, - )) - .await - .expect("failed to start panel"), - ); - drop(guard); + let mut guard = panel_key.lock().await; + *guard = Some( + process_manager + .start(notifications_process( + panel_span, + "cosmic-panel", + panel_key.clone(), + panel_env_vars, + panel_notifications_fd, + notifications_span, + "cosmic-notifications", + notif_key, + daemon_env_vars, + )) + .await + .expect("failed to start panel"), + ); + drop(guard); + } let span = info_span!(parent: None, "cosmic-app-library"); start_component("cosmic-app-library", span, &process_manager, &env_vars).await; @@ -350,6 +356,13 @@ async fn start( let span = info_span!(parent: None, "cosmic-idle"); start_component("cosmic-idle", span, &process_manager, &env_vars).await; + // Profile extras (waybar, swww, ...) come last, so they start against a + // session that already has its compositor-side services up. + for command in profile::Profile::cached().extra() { + let span = info_span!(parent: None, "hyprcosmic-extra"); + start_component(command.clone(), span, &process_manager, &env_vars).await; + } + #[cfg(feature = "autostart")] if !*is_systemd_used() { info!("looking for autostart folders"); @@ -508,10 +521,17 @@ async fn start_component( process_manager: &ProcessManager, env_vars: &[(String, String)], ) { + let cmd = cmd.into(); + // Gating here rather than at each call site keeps the diff against + // upstream to this one block, which matters for rebasing. + if !profile::Profile::cached().is_enabled(&cmd) { + info!("{cmd} disabled by the {} profile", profile::Profile::cached().name); + return; + } + let stdout_span = span.clone(); let stderr_span = span.clone(); let stderr_span_clone = stderr_span.clone(); - let cmd = cmd.into(); let cmd_clone = cmd.clone(); if let Err(err) = process_manager diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..1f3e3c0 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Which shell components this session should launch. +//! +//! Upstream hardcodes a list of `cosmic-*` components. HyprCosmic needs a +//! different set — cosmic-comp for window management, but waybar and rofi in +//! place of cosmic-panel and cosmic-launcher. +//! +//! Rather than delete the upstream calls, this gates them. The default profile +//! is byte-for-byte upstream behaviour, so an unconfigured build of this fork +//! still produces an ordinary COSMIC session; the alternate profile is opt-in +//! through the session's `.desktop` entry. That keeps the diff against upstream +//! to a handful of lines and means a broken HyprCosmic config can never leave +//! you unable to log in — the stock session entry is still there. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +/// Selects the profile. Set by `hyprcosmic.desktop`; absent for the stock +/// COSMIC session entry. +pub const PROFILE_ENV: &str = "HYPRCOSMIC_PROFILE"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub name: String, + /// Upstream components to skip. + disabled: BTreeSet, + /// Additional commands to launch after the built-in set. + extra: Vec, +} + +impl Default for Profile { + /// Stock COSMIC: launch everything upstream launches, add nothing. + fn default() -> Self { + Self { + name: "cosmic".into(), + disabled: BTreeSet::new(), + extra: Vec::new(), + } + } +} + +impl Profile { + /// The HyprCosmic set: COSMIC's compositor, HyDE's shell. + /// + /// cosmic-greeter is deliberately NOT disabled — login stays on a known + /// working greeter, because a display manager is the easiest thing to lock + /// yourself out of. + /// + /// cosmic-osd and cosmic-idle are also kept: HyDE's equivalents are + /// separate programs a user may not have, and neither conflicts with + /// waybar for layer-shell space. + pub fn hyprcosmic() -> Self { + Self { + name: "hyprcosmic".into(), + disabled: [ + // Replaced by waybar. + "cosmic-panel", + // Replaced by rofi, launched on demand via a keybind rather + // than run as a daemon. + "cosmic-launcher", + "cosmic-app-library", + // COSMIC's overview has no HyDE equivalent and would render + // over waybar. + "cosmic-workspaces", + // HyDE themes ship wallpapers driven by swww. + "cosmic-bg", + // cosmic-panel hosts this applet; without the panel it has + // nowhere to appear. + "cosmic-files-applet", + ] + .into_iter() + .map(String::from) + .collect(), + extra: Vec::new(), + } + } + + /// Read the active profile from the environment, then layer any + /// user-specified extra commands on top. + pub fn active() -> Self { + let mut profile = match std::env::var(PROFILE_ENV).as_deref() { + Ok("hyprcosmic") => Self::hyprcosmic(), + _ => Self::default(), + }; + if let Some(path) = extras_path() { + profile.extra = read_extras(&path); + } + profile + } + + /// The active profile, resolved once. `start_component` consults this on + /// every call, and re-reading the extras file each time would be silly. + pub fn cached() -> &'static Profile { + static PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); + PROFILE.get_or_init(Profile::active) + } + + pub fn is_enabled(&self, component: &str) -> bool { + !self.disabled.contains(component) + } + + pub fn extra(&self) -> &[String] { + &self.extra + } +} + +fn extras_path() -> Option { + let base = match std::env::var_os("XDG_CONFIG_HOME") { + Some(x) if !x.is_empty() => PathBuf::from(x), + _ => PathBuf::from(std::env::var_os("HOME")?).join(".config"), + }; + Some(base.join("hyprcosmic").join("autostart")) +} + +/// One command per line; `#` comments and blank lines ignored. +/// +/// A missing file is normal, not an error — most sessions will not have one. +/// Deliberately not a shell: each line is exec'd directly, so a stray +/// backtick in a config cannot run something unexpected. +fn read_extras(path: &std::path::Path) -> Vec { + let Ok(text) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + parse_extras(&text) +} + +fn parse_extras(text: &str) -> Vec { + text.lines() + .map(|l| match l.find('#') { + Some(i) => &l[..i], + None => l, + }) + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_profile_launches_everything_upstream_does() { + // The stock session must be unaffected by this fork existing. + let p = Profile::default(); + for c in [ + "cosmic-panel", + "cosmic-launcher", + "cosmic-app-library", + "cosmic-workspaces", + "cosmic-osd", + "cosmic-bg", + "cosmic-greeter", + "cosmic-files-applet", + "cosmic-idle", + ] { + assert!(p.is_enabled(c), "default profile must still launch {c}"); + } + } + + #[test] + fn hyprcosmic_profile_replaces_the_shell_but_keeps_the_compositor_side() { + let p = Profile::hyprcosmic(); + for c in ["cosmic-panel", "cosmic-launcher", "cosmic-workspaces", "cosmic-bg"] { + assert!(!p.is_enabled(c), "{c} should be replaced"); + } + for c in ["cosmic-osd", "cosmic-idle"] { + assert!(p.is_enabled(c), "{c} has no HyDE equivalent and should stay"); + } + } + + #[test] + fn greeter_is_never_disabled() { + // Losing the greeter means losing the ability to log in. + assert!(Profile::hyprcosmic().is_enabled("cosmic-greeter")); + } + + #[test] + fn extras_parse_ignoring_comments_and_blanks() { + let text = "\n# a comment\nwaybar\n swww-daemon \n\nrofi -show drun # trailing\n"; + assert_eq!( + parse_extras(text), + vec!["waybar", "swww-daemon", "rofi -show drun"] + ); + } + + #[test] + fn missing_extras_file_is_not_an_error() { + assert!(read_extras(std::path::Path::new("/nonexistent/hyprcosmic/autostart")).is_empty()); + } +}