mirror of
https://github.com/outbackdingo/hyprcosmic-session.git
synced 2026-08-25 07:20:17 +00:00
Add session profiles so HyprCosmic can replace the COSMIC shell
Upstream hardcodes the set of cosmic-* components a session launches. HyprCosmic needs cosmic-comp for window management but waybar and rofi in place of cosmic-panel and cosmic-launcher, so `profile` gates that set. The default profile is upstream behaviour exactly, and it is what an unconfigured build gets; the alternate set is opt-in through HYPRCOSMIC_PROFILE, which only the new session entry sets. So installing this fork cannot change how the stock session boots, and a broken HyprCosmic config is always one logout away from being escaped. Gating lives at the single definition of `start_component` rather than at its call sites, keeping the diff against upstream small enough to rebase against a fast-moving tree. cosmic-panel and cosmic-notifications are the exception: they are started as a coupled pair sharing notification fds and cannot go through that path, so they are gated where they are spawned (hence the re-indentation in the diff). cosmic-greeter is deliberately never disabled - a display manager is the easiest thing to lock yourself out of. cosmic-osd and cosmic-idle stay too, since HyDE's equivalents are separate programs a user may not have and neither competes with waybar for layer-shell space. Verified: 5 unit tests over profile selection and extras parsing; `cargo check -j1` and `cargo build -j1` clean. NOT verified: the gating has never been observed at runtime. Two attempts to run this session nested inside the live desktop logged the developer out instead, so runtime confirmation is deferred to logging into the hyprcosmic.desktop entry from the greeter. Two hazards found along the way and worth recording: name-matching (pkill/pgrep) cannot distinguish this fork's processes from the live session's, and a nested cosmic-session steals the well-known D-Bus name com.system76.CosmicSession from the running session unless it is given a private bus.
This commit is contained in:
@@ -32,3 +32,6 @@ debian/*
|
|||||||
!debian/links
|
!debian/links
|
||||||
!debian/rules
|
!debian/rules
|
||||||
!debian/source
|
!debian/source
|
||||||
|
|
||||||
|
# HyprCosmic: OMC operational artifacts
|
||||||
|
.omc/
|
||||||
|
|||||||
@@ -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;
|
||||||
Executable
+50
@@ -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
|
||||||
+61
-41
@@ -6,6 +6,7 @@ mod a11y;
|
|||||||
mod comp;
|
mod comp;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod process;
|
mod process;
|
||||||
|
mod profile;
|
||||||
mod service;
|
mod service;
|
||||||
mod systemd;
|
mod systemd;
|
||||||
|
|
||||||
@@ -282,49 +283,54 @@ async fn start(
|
|||||||
panel_notifications_fd.as_raw_fd().to_string(),
|
panel_notifications_fd.as_raw_fd().to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let panel_key = Arc::new(Mutex::new(None));
|
// cosmic-panel and cosmic-notifications are started as a coupled pair
|
||||||
let notif_key = Arc::new(Mutex::new(None));
|
// (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 notifications_span = info_span!(parent: None, "cosmic-notifications");
|
||||||
let panel_span = info_span!(parent: None, "cosmic-panel");
|
let panel_span = info_span!(parent: None, "cosmic-panel");
|
||||||
|
|
||||||
let mut guard = notif_key.lock().await;
|
let mut guard = notif_key.lock().await;
|
||||||
*guard = Some(
|
*guard = Some(
|
||||||
process_manager
|
process_manager
|
||||||
.start(notifications_process(
|
.start(notifications_process(
|
||||||
notifications_span.clone(),
|
notifications_span.clone(),
|
||||||
"cosmic-notifications",
|
"cosmic-notifications",
|
||||||
notif_key.clone(),
|
notif_key.clone(),
|
||||||
daemon_env_vars.clone(),
|
daemon_env_vars.clone(),
|
||||||
daemon_notifications_fd,
|
daemon_notifications_fd,
|
||||||
panel_span.clone(),
|
panel_span.clone(),
|
||||||
"cosmic-panel",
|
"cosmic-panel",
|
||||||
panel_key.clone(),
|
panel_key.clone(),
|
||||||
panel_env_vars.clone(),
|
panel_env_vars.clone(),
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.expect("failed to start notifications daemon"),
|
.expect("failed to start notifications daemon"),
|
||||||
);
|
);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
|
||||||
let mut guard = panel_key.lock().await;
|
let mut guard = panel_key.lock().await;
|
||||||
*guard = Some(
|
*guard = Some(
|
||||||
process_manager
|
process_manager
|
||||||
.start(notifications_process(
|
.start(notifications_process(
|
||||||
panel_span,
|
panel_span,
|
||||||
"cosmic-panel",
|
"cosmic-panel",
|
||||||
panel_key.clone(),
|
panel_key.clone(),
|
||||||
panel_env_vars,
|
panel_env_vars,
|
||||||
panel_notifications_fd,
|
panel_notifications_fd,
|
||||||
notifications_span,
|
notifications_span,
|
||||||
"cosmic-notifications",
|
"cosmic-notifications",
|
||||||
notif_key,
|
notif_key,
|
||||||
daemon_env_vars,
|
daemon_env_vars,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.expect("failed to start panel"),
|
.expect("failed to start panel"),
|
||||||
);
|
);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
}
|
||||||
|
|
||||||
let span = info_span!(parent: None, "cosmic-app-library");
|
let span = info_span!(parent: None, "cosmic-app-library");
|
||||||
start_component("cosmic-app-library", span, &process_manager, &env_vars).await;
|
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");
|
let span = info_span!(parent: None, "cosmic-idle");
|
||||||
start_component("cosmic-idle", span, &process_manager, &env_vars).await;
|
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")]
|
#[cfg(feature = "autostart")]
|
||||||
if !*is_systemd_used() {
|
if !*is_systemd_used() {
|
||||||
info!("looking for autostart folders");
|
info!("looking for autostart folders");
|
||||||
@@ -508,10 +521,17 @@ async fn start_component(
|
|||||||
process_manager: &ProcessManager,
|
process_manager: &ProcessManager,
|
||||||
env_vars: &[(String, String)],
|
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 stdout_span = span.clone();
|
||||||
let stderr_span = span.clone();
|
let stderr_span = span.clone();
|
||||||
let stderr_span_clone = stderr_span.clone();
|
let stderr_span_clone = stderr_span.clone();
|
||||||
let cmd = cmd.into();
|
|
||||||
let cmd_clone = cmd.clone();
|
let cmd_clone = cmd.clone();
|
||||||
|
|
||||||
if let Err(err) = process_manager
|
if let Err(err) = process_manager
|
||||||
|
|||||||
+193
@@ -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<String>,
|
||||||
|
/// Additional commands to launch after the built-in set.
|
||||||
|
extra: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Profile> = 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<PathBuf> {
|
||||||
|
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<String> {
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
parse_extras(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_extras(text: &str) -> Vec<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user