From bd8baf1651d079b0e312b3660e4b56d837f7b87c Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 02:37:31 -0700 Subject: [PATCH 01/28] feat: initial work on settingsd and app --- CONTRIBUTING.md | 15 +++ src/bin/hearthspace-settings.rs | 81 +++++++++++++ src/bin/hearthspace-settingsd.rs | 165 +++++++++++++++++++++++++ src/lib.rs | 1 + src/settings/mod.rs | 200 +++++++++++++++++++++++++++++++ 5 files changed, 462 insertions(+) create mode 100644 src/bin/hearthspace-settings.rs create mode 100644 src/bin/hearthspace-settingsd.rs create mode 100644 src/settings/mod.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7aaa3e2..fac20f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,21 @@ Native backend runs can be exited with `Ctrl+Alt+Backspace`, `Ctrl+Alt+Esc`, and log interpretation, see [docs/NATIVE_TESTING.md](./docs/NATIVE_TESTING.md). For native backend architecture, see [docs/DRM.md](./docs/DRM.md). +Settings runs use separate Cargo binaries rather than `hearthspace` runtime +flags. Start the backend daemon first, then launch the Xilem frontend: + +```sh +cargo run --bin hearthspace-settingsd +cargo run --bin hearthspace-settings +``` + +The settings daemon writes `~/.config/hearthspace/config.toml` and listens on +`$XDG_RUNTIME_DIR/hearthspace-settingsd.sock`. Its focused tests can be run with: + +```sh +cargo test --bin hearthspace-settingsd +``` + ### Tracing Logs Hearthspace initializes `tracing` automatically. No Cargo feature flags are diff --git a/src/bin/hearthspace-settings.rs b/src/bin/hearthspace-settings.rs new file mode 100644 index 0000000..38100b4 --- /dev/null +++ b/src/bin/hearthspace-settings.rs @@ -0,0 +1,81 @@ +use hearthspace::settings::{Settings, client_get_settings, client_set_test, config_path}; +use xilem::{ + EventLoop, WidgetView, WindowOptions, Xilem, + dpi::LogicalSize, + masonry::{layout::AsUnit, parley::fontique::GenericFamily}, + style::Style, + view::{checkbox, flex_col, label, sized_box}, +}; + +struct SettingsApp { + settings: Settings, + status: String, + config_label: String, +} + +fn main() -> Result<(), Box> { + let state = initial_state(); + let app = Xilem::new_simple( + state, + app_logic, + WindowOptions::new("Hearthspace Settings") + .with_initial_inner_size(LogicalSize::new(420.0, 220.0)) + .with_min_inner_size(LogicalSize::new(320.0, 180.0)), + ); + + app.run_in(EventLoop::with_user_event())?; + Ok(()) +} + +fn initial_state() -> SettingsApp { + let (settings, status) = match client_get_settings() { + Ok(settings) => (settings, "Connected to hearthspace-settingsd".to_string()), + Err(error) => ( + Settings::default(), + format!("Settings backend unavailable: {error}"), + ), + }; + + let config_label = match config_path() { + Ok(path) => format!("Config: {}", path.display()), + Err(error) => format!("Config path unavailable: {error}"), + }; + + SettingsApp { + settings, + status, + config_label, + } +} + +fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { + let test_checked = state.settings.test; + + sized_box( + flex_col(( + label("Hearthspace Settings") + .font(GenericFamily::SystemUi) + .text_size(22.0), + label("This is a minimal scaffold for future settings."), + checkbox("test", test_checked, |state: &mut SettingsApp, checked| { + let previous = state.settings.test; + state.settings.test = checked; + + match client_set_test(checked) { + Ok(settings) => { + state.settings = settings; + state.status = format!("Saved test = {}", settings.test); + } + Err(error) => { + state.settings.test = previous; + state.status = format!("Failed to save setting: {error}"); + } + } + }), + label(state.config_label.clone()), + label(state.status.clone()), + )) + .gap(12.0.px()), + ) + .padding(20.0.px()) +} diff --git a/src/bin/hearthspace-settingsd.rs b/src/bin/hearthspace-settingsd.rs new file mode 100644 index 0000000..a6a4b44 --- /dev/null +++ b/src/bin/hearthspace-settingsd.rs @@ -0,0 +1,165 @@ +use std::{ + fs, io, + io::{BufRead, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::Path, +}; + +use hearthspace::settings::{ + SettingsRequest, config_path, error_response, load_settings_from_path, parse_settings_request, + save_settings_to_path, settings_response, settings_socket_path, +}; +use tracing::{error, info, warn}; + +fn main() -> Result<(), Box> { + initialize_tracing(); + + let listener = bind_settings_socket()?; + info!("hearthspace settings daemon is listening"); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + if let Err(error) = handle_connection(stream) { + warn!(%error, "failed to handle settings client"); + } + } + Err(error) => error!(%error, "failed to accept settings client"), + } + } + + Ok(()) +} + +fn initialize_tracing() { + match tracing_subscriber::EnvFilter::try_from_default_env() { + Ok(env_filter) => tracing_subscriber::fmt().with_env_filter(env_filter).init(), + Err(_) => tracing_subscriber::fmt().init(), + } +} + +fn bind_settings_socket() -> io::Result { + let path = settings_socket_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + if UnixStream::connect(&path).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!("settings daemon is already listening at {}", path.display()), + )); + } + + match fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + UnixListener::bind(path) +} + +fn handle_connection(stream: UnixStream) -> io::Result<()> { + handle_connection_with_config_path(stream, &config_path()?) +} + +fn handle_connection_with_config_path( + mut stream: UnixStream, + config_path: &Path, +) -> io::Result<()> { + let mut request = String::new(); + { + let mut reader = io::BufReader::new(&stream); + reader.read_line(&mut request)?; + } + + let response = match parse_settings_request(&request) { + Ok(request) => handle_request(request, config_path).unwrap_or_else(error_response), + Err(error) => error_response(error), + }; + + stream.write_all(response.as_bytes()) +} + +fn handle_request(request: SettingsRequest, config_path: &Path) -> io::Result { + match request { + SettingsRequest::Get => Ok(settings_response(load_settings_from_path(config_path)?)), + SettingsRequest::SetTest(test) => { + let mut settings = load_settings_from_path(config_path)?; + settings.test = test; + save_settings_to_path(settings, config_path)?; + Ok(settings_response(settings)) + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + io::{Read, Write}, + net::Shutdown, + os::unix::net::UnixStream, + path::Path, + }; + + use super::handle_connection_with_config_path; + + #[test] + fn get_returns_default_settings_when_config_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("hearthspace/config.toml"); + + let response = round_trip("get\n", &config_path); + + assert_eq!(response, "ok test false\n"); + assert!(!config_path.exists()); + } + + #[test] + fn get_reads_existing_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("hearthspace/config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "test = true\n").unwrap(); + + let response = round_trip("get\n", &config_path); + + assert_eq!(response, "ok test true\n"); + } + + #[test] + fn set_test_writes_config_and_returns_saved_settings() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("hearthspace/config.toml"); + + let response = round_trip("set test true\n", &config_path); + + assert_eq!(response, "ok test true\n"); + assert_eq!(fs::read_to_string(config_path).unwrap(), "test = true\n"); + } + + #[test] + fn invalid_request_returns_error_without_writing_config() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("hearthspace/config.toml"); + + let response = round_trip("set nope true\n", &config_path); + + assert!(response.starts_with("err unknown request:")); + assert!(!config_path.exists()); + } + + fn round_trip(request: &str, config_path: &Path) -> String { + let (server, mut client) = UnixStream::pair().unwrap(); + client.write_all(request.as_bytes()).unwrap(); + client.shutdown(Shutdown::Write).unwrap(); + + handle_connection_with_config_path(server, config_path).unwrap(); + + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + response + } +} diff --git a/src/lib.rs b/src/lib.rs index 90ecc32..dbfadcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod accessibility; pub mod compositor; pub mod config; pub mod geometry; +pub mod settings; pub mod shell; #[cfg(feature = "test-apps")] pub mod test_apps; diff --git a/src/settings/mod.rs b/src/settings/mod.rs new file mode 100644 index 0000000..fe548bb --- /dev/null +++ b/src/settings/mod.rs @@ -0,0 +1,200 @@ +use std::{ + env, fs, io, + io::{Read, Write}, + net::Shutdown, + os::unix::net::UnixStream, + path::{Path, PathBuf}, +}; + +const CONFIG_DIR_NAME: &str = "hearthspace"; +const CONFIG_FILE_NAME: &str = "config.toml"; +const SETTINGS_SOCKET_NAME: &str = "hearthspace-settingsd.sock"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Settings { + pub test: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsRequest { + Get, + SetTest(bool), +} + +pub fn config_path() -> io::Result { + Ok(config_home()?.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME)) +} + +pub fn settings_socket_path() -> io::Result { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))?; + Ok(PathBuf::from(runtime_dir).join(SETTINGS_SOCKET_NAME)) +} + +pub fn load_settings() -> io::Result { + load_settings_from_path(&config_path()?) +} + +pub fn load_settings_from_path(path: impl AsRef) -> io::Result { + match fs::read_to_string(path) { + Ok(contents) => parse_settings(&contents), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Settings::default()), + Err(error) => Err(error), + } +} + +pub fn save_settings(settings: Settings) -> io::Result<()> { + save_settings_to_path(settings, config_path()?) +} + +pub fn save_settings_to_path(settings: Settings, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format_settings(settings)) +} + +pub fn client_get_settings() -> io::Result { + send_settings_request("get\n") +} + +pub fn client_set_test(test: bool) -> io::Result { + let value = if test { "true" } else { "false" }; + send_settings_request(&format!("set test {value}\n")) +} + +pub fn parse_settings_request(line: &str) -> Result { + let parts = line.split_whitespace().collect::>(); + match parts.as_slice() { + ["get"] => Ok(SettingsRequest::Get), + ["set", "test", value] => Ok(SettingsRequest::SetTest(parse_bool(value)?)), + [] => Err("empty request".into()), + _ => Err(format!("unknown request: {line:?}")), + } +} + +pub fn settings_response(settings: Settings) -> String { + format!("ok test {}\n", settings.test) +} + +pub fn error_response(error: impl std::fmt::Display) -> String { + format!("err {error}\n") +} + +fn send_settings_request(request: &str) -> io::Result { + let mut stream = UnixStream::connect(settings_socket_path()?)?; + stream.write_all(request.as_bytes())?; + stream.shutdown(Shutdown::Write)?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + parse_settings_response(&response) +} + +fn config_home() -> io::Result { + if let Some(xdg_config_home) = env::var_os("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(xdg_config_home)); + } + + let home = env::var_os("HOME").ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "neither XDG_CONFIG_HOME nor HOME is set", + ) + })?; + Ok(PathBuf::from(home).join(".config")) +} + +fn parse_settings(contents: &str) -> io::Result { + let mut settings = Settings::default(); + for raw_line in contents.lines() { + let line = raw_line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() || line.starts_with('[') { + continue; + } + + let Some((key, value)) = line.split_once('=') else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid settings line: {line:?}"), + )); + }; + + if key.trim() == "test" { + settings.test = parse_bool(value.trim()).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid test setting: {error}"), + ) + })?; + } + } + Ok(settings) +} + +fn format_settings(settings: Settings) -> String { + format!("test = {}\n", settings.test) +} + +fn parse_settings_response(response: &str) -> io::Result { + let line = response.trim(); + let parts = line.split_whitespace().collect::>(); + match parts.as_slice() { + ["ok", "test", value] => Ok(Settings { + test: parse_bool(value).map_err(io::Error::other)?, + }), + ["err", message @ ..] => Err(io::Error::other(message.join(" "))), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid settingsd response: {line:?}"), + )), + } +} + +fn parse_bool(value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(format!("expected true or false, got {value:?}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_test_setting() { + assert_eq!( + parse_settings("test = true\n").unwrap(), + Settings { test: true } + ); + assert_eq!( + parse_settings("test = false\n").unwrap(), + Settings { test: false } + ); + } + + #[test] + fn formats_test_setting() { + assert_eq!(format_settings(Settings { test: true }), "test = true\n"); + } + + #[test] + fn parses_ipc_requests() { + assert_eq!(parse_settings_request("get\n"), Ok(SettingsRequest::Get)); + assert_eq!( + parse_settings_request("set test true\n"), + Ok(SettingsRequest::SetTest(true)) + ); + } + + #[test] + fn parses_ipc_response() { + assert_eq!( + parse_settings_response("ok test true\n").unwrap(), + Settings { test: true } + ); + } +} -- 2.51.2 From 74c11eb9ae08e830deb36e9be38055e1e0964f42 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 02:38:30 -0700 Subject: [PATCH 02/28] docs: add mention of daemon future work --- todos/DAEMONS.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 todos/DAEMONS.md diff --git a/todos/DAEMONS.md b/todos/DAEMONS.md new file mode 100644 index 0000000..61b7db0 --- /dev/null +++ b/todos/DAEMONS.md @@ -0,0 +1,97 @@ +# Daemons + +Hearthspace now has standalone helper daemons, starting with +`hearthspace-settingsd`. These should remain real binaries rather than hidden +`hearthspace` modes so they can be launched, tested, packaged, and eventually +socket-activated independently. + +## Current State + +- `hearthspace-settingsd` is a standalone Cargo binary. +- `hearthspace-settings` is a standalone Xilem frontend binary. +- The settings frontend connects to `$XDG_RUNTIME_DIR/hearthspace-settingsd.sock`. +- The settings daemon writes `~/.config/hearthspace/config.toml`. +- Nothing currently autostarts `hearthspace-settingsd`; it must be launched + manually before `hearthspace-settings` can persist changes. + +## Near-Term Autostart + +The compositor should eventually start Hearthspace-owned daemons during session +startup, alongside the shell. + +For `settingsd`, this likely means: + +- Add daemon spawning to compositor startup, near shell spawning. +- Use `std::env::current_exe()` only for the main `hearthspace` binary; helper + daemons are separate executables, so production startup needs a reliable way to + locate sibling binaries such as `hearthspace-settingsd`. +- Preserve the user's runtime/config environment, especially `XDG_RUNTIME_DIR`, + `HOME`, and `XDG_CONFIG_HOME`. +- Log daemon spawn failures, but do not fail compositor startup. +- Treat an already-running daemon as non-fatal. +- Decide whether the compositor should keep child handles and terminate daemons + on shutdown, or leave process lifetime to the session/runtime manager. + +The minimal acceptable first version is fire-and-forget spawn with clear logging, +provided the daemon exits cleanly when another instance is already listening. + +## Daemon Robustness Requirements + +Before compositor autostart, each daemon should be safe to start repeatedly: + +- Check whether its socket already has a live listener. +- Exit cleanly or return a distinct non-error status when already running. +- Remove stale socket files only after verifying no listener is active. +- Keep protocol errors scoped to the client connection. +- Avoid panicking on malformed client input. +- Keep config writes atomic once settings become more important than the current + scaffold. + +## Frontend Behavior + +Frontend apps should not own settings persistence directly once a daemon exists. + +Settings frontends should: + +- Connect to the daemon socket. +- Show a clear unavailable/backend-disconnected status if the daemon is missing. +- Retry or expose a refresh action once the UI grows beyond the current scaffold. +- Avoid writing `config.toml` directly except in dedicated migration/recovery + tooling. + +## Future Systemd User Integration + +Once Hearthspace has install packaging, daemon startup should move toward systemd +user services or socket activation. + +Likely units: + +- `hearthspace-settingsd.service` +- `hearthspace-settingsd.socket` + +Benefits: + +- Daemons start on demand when a client connects. +- Process lifecycle is managed outside the compositor. +- Crashes and restarts can use standard user-service policy. +- The compositor and frontend only need to know the socket path. + +Open packaging questions: + +- Where unit files are installed. +- Whether Hearthspace enables units during install or first run. +- How native sessions, nested sessions, and development runs should differ. +- Whether daemon sockets should always live under the user's real + `XDG_RUNTIME_DIR` or a Hearthspace-specific nested runtime directory. + +## Testing Expectations + +Daemon changes should have focused tests that do not depend on global user state: + +- Inject config paths rather than mutating `HOME` or `XDG_CONFIG_HOME` when + possible. +- Use temporary directories for config/socket paths. +- Use `UnixStream::pair()` for request/response tests where a real bound socket + is unnecessary. +- Add one smoke test with a real `UnixListener` only when socket binding behavior + itself is under test. -- 2.51.2 From 0a7b58991a82094579331974e55b567c423686ed Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 02:43:50 -0700 Subject: [PATCH 03/28] chore: show settings app in search --- CONTRIBUTING.md | 11 ++++++- .../dev.hearthspace.Settings.desktop | 9 ++++++ src/shell/app_catalog.rs | 31 ++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 data/applications/dev.hearthspace.Settings.desktop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fac20f4..8168156 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,12 +129,21 @@ Settings runs use separate Cargo binaries rather than `hearthspace` runtime flags. Start the backend daemon first, then launch the Xilem frontend: ```sh +cargo build --bin hearthspace-settings cargo run --bin hearthspace-settingsd cargo run --bin hearthspace-settings ``` The settings daemon writes `~/.config/hearthspace/config.toml` and listens on -`$XDG_RUNTIME_DIR/hearthspace-settingsd.sock`. Its focused tests can be run with: +`$XDG_RUNTIME_DIR/hearthspace-settingsd.sock`. The settings frontend is exposed +to the Hearthspace shell through +`data/applications/dev.hearthspace.Settings.desktop`; during source-tree runs, +the app catalog includes `data/` as a low-priority XDG data directory. Build the +settings frontend once with `cargo build --bin hearthspace-settings` before +launching it from shell search so `target/debug/hearthspace-settings` exists next +to the compositor binary. + +The daemon's focused tests can be run with: ```sh cargo test --bin hearthspace-settingsd diff --git a/data/applications/dev.hearthspace.Settings.desktop b/data/applications/dev.hearthspace.Settings.desktop new file mode 100644 index 0000000..b8e661b --- /dev/null +++ b/data/applications/dev.hearthspace.Settings.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Hearthspace Settings +GenericName=Settings +Comment=Configure Hearthspace +Exec=hearthspace-settings +Categories=Settings; +Keywords=Hearthspace;Settings;Preferences;Configuration; +OnlyShowIn=Hearthspace; diff --git a/src/shell/app_catalog.rs b/src/shell/app_catalog.rs index 8e97c38..2c9194c 100644 --- a/src/shell/app_catalog.rs +++ b/src/shell/app_catalog.rs @@ -7,6 +7,7 @@ use std::{ use tracing::debug; const CURRENT_DESKTOP: &str = "Hearthspace"; +const SETTINGS_APP_BINARY: &str = "hearthspace-settings"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DesktopApp { @@ -224,6 +225,7 @@ fn xdg_data_dirs() -> Vec { ] }); dirs.extend(data_dirs); + dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")); dirs } @@ -570,7 +572,7 @@ pub fn spawn_argv_with_env( let Some((program, args)) = argv.split_first() else { return Err("cannot spawn an empty command".to_string()); }; - let mut command = Command::new(program); + let mut command = Command::new(resolve_launch_program(program)); command .args(args) .env("WAYLAND_DISPLAY", wayland_display) @@ -584,6 +586,19 @@ pub fn spawn_argv_with_env( .map_err(|error| format!("failed to spawn {program}: {error}")) } +fn resolve_launch_program(program: &str) -> PathBuf { + let path = Path::new(program); + if program != SETTINGS_APP_BINARY || path.components().count() != 1 { + return path.to_path_buf(); + } + + env::current_exe() + .ok() + .and_then(|current_exe| current_exe.parent().map(|parent| parent.join(program))) + .filter(|candidate| candidate.exists()) + .unwrap_or_else(|| path.to_path_buf()) +} + #[cfg(test)] mod tests { use super::*; @@ -795,4 +810,18 @@ mod tests { Some("Editor (first)") ); } + + #[test] + fn source_tree_settings_desktop_file_is_searchable_and_launchable() { + let catalog = AppCatalog::load_from_data_dirs(vec![ + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"), + ]); + + let app = catalog + .app_by_id("dev.hearthspace.Settings.desktop") + .unwrap(); + assert_eq!(app.name, "Hearthspace Settings"); + assert_eq!(catalog.search("settings", 1)[0].id, app.id); + assert_eq!(app.launch_argv().unwrap(), vec!["hearthspace-settings"]); + } } -- 2.51.2 From c5dde1c798e21cd894bd2996ca35eafb875749fd Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 02:50:17 -0700 Subject: [PATCH 04/28] docs: add session supervisor plan --- todos/SESSION_SUPERVISOR.md | 817 ++++++++++++++++++++++++++++++++++++ 1 file changed, 817 insertions(+) create mode 100644 todos/SESSION_SUPERVISOR.md diff --git a/todos/SESSION_SUPERVISOR.md b/todos/SESSION_SUPERVISOR.md new file mode 100644 index 0000000..1ceadcf --- /dev/null +++ b/todos/SESSION_SUPERVISOR.md @@ -0,0 +1,817 @@ +# Hearthspace Session Supervisor Implementation Plan + +This document describes how to scaffold Hearthspace so that the compositor, shell, settings daemon, and development-only helper processes can be launched together from a single command. + +The intended developer workflow is: + +```sh +cargo run +``` + +from the repository root. + +That command should start a full Hearthspace development session, including: + +- `hearthspace-settingsd` +- `hearthspace-compositor` +- `hearthspace-shell` +- development app registration for apps like `hearthspace-settings` +- optional background rebuild/watch processes for development apps + +The design should also grow cleanly into an installed desktop session later. + +## Goals + +### Primary goals + +- Make `cargo run` start a complete Hearthspace session. +- Keep the compositor, shell, and settings daemon as separate binaries. +- Provide a session supervisor that owns process startup, shutdown, environment variables, and readiness coordination. +- Let the shell launch Hearthspace-owned apps like `hearthspace-settings` through normal app search. +- Support development workflows where Hearthspace apps are rebuilt automatically when their code changes. +- Preserve the ability to run each component directly for debugging. +- Keep the design compatible with future installed sessions, display managers, and systemd user units. + +### Non-goals + +- Do not collapse the compositor, shell, and settings daemon into one binary. +- Do not make `hearthspace-settingsd` responsible for UI. +- Do not require a full system install for development. +- Do not require the settings UI to be embedded in the shell. +- Do not hard-code paths that only work on one developer machine. + +## Proposed workspace layout + +Recommended crate layout: + +```txt +crates/ + hearthspace-session/ # cargo run target; starts the full dev session + hearthspace-compositor/ # Wayland compositor + hearthspace-shell/ # Xilem shell UI + hearthspace-settingsd/ # settings daemon + hearthspace-settings/ # Xilem settings UI app + hearthspace-common/ # shared paths, logging, config, errors, process utilities + hearthspace-ipc/ # shared IPC schemas and client/server helpers +``` + +The root workspace should make `hearthspace-session` the default member so that `cargo run` from the repo root starts the session supervisor. + +Example root `Cargo.toml` shape: + +```toml +[workspace] +resolver = "2" +members = [ + "crates/hearthspace-session", + "crates/hearthspace-compositor", + "crates/hearthspace-shell", + "crates/hearthspace-settingsd", + "crates/hearthspace-settings", + "crates/hearthspace-common", + "crates/hearthspace-ipc", +] + +default-members = ["crates/hearthspace-session"] +``` + +Each component should still remain directly runnable: + +```sh +cargo run -p hearthspace-session +cargo run -p hearthspace-compositor +cargo run -p hearthspace-shell +cargo run -p hearthspace-settingsd +cargo run -p hearthspace-settings +``` + +## Process tree + +During development, `cargo run` should create a process tree like this: + +```txt +hearthspace-session +├─ hearthspace-settingsd +├─ hearthspace-compositor +├─ hearthspace-shell +├─ cargo watch/build process for hearthspace-settings # optional but recommended +└─ other dev helper processes # optional future expansion +``` + +The session supervisor is responsible for: + +- selecting a Wayland socket name +- creating runtime paths +- starting `settingsd` +- starting the compositor +- waiting for compositor readiness +- starting the shell +- registering development apps +- starting file watchers/build helpers +- forwarding relevant environment variables +- shutting down children when the session ends +- reporting clear logs when anything fails + +## Development vs installed mode + +The session supervisor should support two process-launch modes. + +### Development mode + +Development mode is used when running from the repository with: + +```sh +cargo run +``` + +In this mode, the session supervisor may spawn child processes using: + +```sh +cargo run -p -- +``` + +This is slower than launching binaries directly but convenient during early development because it avoids requiring an install step. + +Example: + +```sh +cargo run -p hearthspace-settingsd -- +cargo run -p hearthspace-compositor -- --wayland-display hearthspace-dev-12345 +cargo run -p hearthspace-shell -- +``` + +### Installed mode + +Installed mode is used later by a display manager or installed desktop session. + +In this mode, `hearthspace-session` should spawn installed binaries directly: + +```txt +/usr/bin/hearthspace-settingsd +/usr/bin/hearthspace-compositor +/usr/bin/hearthspace-shell +/usr/bin/hearthspace-settings +``` + +The supervisor should not assume that `cargo` or the source tree exists in installed mode. + +### Recommended mode detection + +For now, use explicit CLI flags: + +```sh +cargo run -- --dev +hearthspace-session --installed +``` + +Default behavior: + +- When run through `cargo run`, default to development mode. +- When launched as an installed binary, default to installed mode. +- Allow override flags either way. + +Potential implementation options: + +- Detect `HEARTHSPACE_DEV=1` +- Detect whether `CARGO_MANIFEST_DIR` or repo root is known +- Use explicit `--dev` / `--installed` flags +- Use `cfg(debug_assertions)` as a fallback, but do not rely on it as the only mechanism + +## Runtime paths + +Use XDG paths and avoid global writable locations. + +Important paths: + +```txt +$XDG_RUNTIME_DIR/hearthspace/ + settings.sock + session.sock + compositor-ready.json + dev-applications/ + +$XDG_RUNTIME_DIR/ + Wayland socket created by compositor +``` + +Recommended per-session runtime directory: + +```txt +$XDG_RUNTIME_DIR/hearthspace/session-/ +``` + +Example: + +```txt +/run/user/1000/hearthspace/session-42691/ + settings.sock + compositor-ready.json + applications/ + hearthspace-settings.desktop +``` + +Use a per-session directory so multiple nested/dev sessions can coexist without stomping each other. + +## Environment variables + +The session supervisor should construct and pass a consistent environment to children. + +Recommended environment variables: + +```txt +WAYLAND_DISPLAY=hearthspace-dev- +XDG_CURRENT_DESKTOP=Hearthspace +DESKTOP_SESSION=hearthspace +HEARTHSPACE_DEV=1 +HEARTHSPACE_SESSION_DIR=$XDG_RUNTIME_DIR/hearthspace/session- +HEARTHSPACE_SETTINGS_SOCKET=$XDG_RUNTIME_DIR/hearthspace/session-/settings.sock +HEARTHSPACE_SESSION_SOCKET=$XDG_RUNTIME_DIR/hearthspace/session-/session.sock +HEARTHSPACE_REPO_ROOT=/absolute/path/to/repo # dev mode only +``` + +For shell-launched apps, the shell should inherit and forward at least: + +```txt +WAYLAND_DISPLAY +XDG_CURRENT_DESKTOP +DESKTOP_SESSION +HEARTHSPACE_SETTINGS_SOCKET +HEARTHSPACE_SESSION_SOCKET +``` + +## Startup order + +Recommended startup order: + +```txt +1. Initialize logging. +2. Resolve runtime paths. +3. Generate session-specific environment. +4. Register dev apps by generating .desktop files. +5. Start hearthspace-settingsd. +6. Wait for settingsd readiness. +7. Start hearthspace-compositor with explicit Wayland display name. +8. Wait for compositor readiness. +9. Start hearthspace-shell. +10. Start optional dev watchers/build processes. +11. Monitor children until the session exits. +12. Shut down remaining children cleanly. +``` + +`settingsd` should start before the compositor and shell so both can query settings on startup. + +The compositor should start before the shell because the shell is a Wayland client. + +The shell should start after the compositor has created its Wayland socket and is ready to accept clients. + +## Readiness + +Avoid arbitrary sleeps. + +### Settings daemon readiness + +`hearthspace-settingsd` should support at least one readiness mechanism: + +- create its Unix socket +- respond to a `ping` IPC request +- optionally write a readiness message to a provided file descriptor + +Recommended first-class readiness behavior: + +```sh +hearthspace-settingsd --socket "$HEARTHSPACE_SETTINGS_SOCKET" --ready-fd 3 +``` + +When ready, it writes a newline-delimited JSON message to the FD: + +```json +{"service":"settingsd","status":"ready"} +``` + +The session supervisor should still support a fallback that waits for the socket file and then sends a ping. + +### Compositor readiness + +The compositor should accept: + +```sh +hearthspace-compositor --wayland-display hearthspace-dev- --ready-fd 3 +``` + +When ready, it writes: + +```json +{"service":"compositor","status":"ready","wayland_display":"hearthspace-dev-42691"} +``` + +Fallback behavior: + +- wait for `$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY` to exist +- optionally try connecting to the Wayland socket + +### Shell readiness + +The shell can optionally support readiness later, but it is not required for the session to continue. + +If implemented: + +```sh +hearthspace-shell --ready-fd 3 +``` + +Ready message: + +```json +{"service":"shell","status":"ready"} +``` + +## Process supervision + +The session supervisor should monitor children and define failure behavior explicitly. + +Recommended behavior: + +### Critical processes + +These are critical: + +- `hearthspace-settingsd` +- `hearthspace-compositor` +- `hearthspace-shell` + +If the compositor exits, shut down the whole session. + +If `settingsd` exits, either: + +- shut down the whole session, or +- restart it if the exit looks crash-like and restart count is below a limit + +If the shell exits, restart it during development unless the user requested session shutdown. + +Suggested development defaults: + +```txt +compositor exits -> shut down session +settingsd exits -> restart up to 3 times, then shut down session +shell exits -> restart up to 3 times, then shut down session +watcher exits -> log warning, do not shut down session +``` + +### Graceful shutdown + +On `SIGINT`, `SIGTERM`, compositor exit, or unrecoverable child failure: + +1. Ask shell to exit. +2. Ask compositor to exit. +3. Ask settingsd to exit. +4. Terminate dev watchers. +5. Wait with timeout. +6. Kill remaining children. +7. Clean up generated runtime files. + +The session supervisor should avoid orphaned `cargo run`, compositor, shell, or settingsd processes. + +## Logging + +Use `tracing` across components. + +The session supervisor should produce clear structured logs: + +```txt +[session] runtime dir: /run/user/1000/hearthspace/session-42691 +[session] WAYLAND_DISPLAY=hearthspace-dev-42691 +[session] starting settingsd +[session] settingsd ready +[session] starting compositor +[session] compositor ready +[session] starting shell +[session] generated desktop entry: hearthspace-settings.desktop +[session] starting settings app watcher +``` + +Child logs should be prefixed by process name if possible: + +```txt +[settingsd] ... +[compositor] ... +[shell] ... +[settings-watch] ... +``` + +If prefixing stdout/stderr is too much for the first implementation, ensure each child initializes `tracing` with its own target/module names. + +## Development app registration + +The session supervisor should generate temporary `.desktop` files for Hearthspace development apps. + +This lets the shell's app search discover and launch Hearthspace apps naturally, without requiring installation. + +Generated desktop directory: + +```txt +$HEARTHSPACE_SESSION_DIR/share/applications/ +``` + +Generated desktop file: + +```txt +$HEARTHSPACE_SESSION_DIR/share/applications/hearthspace-settings.desktop +``` + +Example: + +```ini +[Desktop Entry] +Type=Application +Name=Hearthspace Settings +Comment=Configure Hearthspace +Exec=/absolute/path/to/repo/scripts/dev-run-app hearthspace-settings +Icon=hearthspace +Categories=Settings;System; +X-Hearthspace-Dev=true +``` + +The shell/app launcher should include this directory when searching for applications. + +Recommended environment: + +```txt +XDG_DATA_DIRS=$HEARTHSPACE_SESSION_DIR:/usr/local/share:/usr/share +``` + +This matches the normal XDG lookup shape because the generated file lives under `$HEARTHSPACE_SESSION_DIR/share/applications`. + +## Development app launching + +Use a wrapper script for development-launched Hearthspace apps. + +Example path: + +```txt +scripts/dev-run-app +``` + +Example implementation: + +```sh +#!/usr/bin/env bash +set -euo pipefail + +APP="$1" +REPO_ROOT="${HEARTHSPACE_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + +cd "$REPO_ROOT" + +if [[ ! -x "target/debug/$APP" ]]; then + cargo build -p "$APP" +fi + +exec "$REPO_ROOT/target/debug/$APP" +``` + +The `.desktop` file should call this script. + +This ensures the settings app can still launch even if it has not been built yet. + +## Automatic rebuilds for settings UI app + +To make changes to `hearthspace-settings` available naturally from shell search, the session supervisor should optionally run a watcher. + +Recommended command: + +```sh +cargo watch -w crates/hearthspace-settings -w crates/hearthspace-common -w crates/hearthspace-ipc -x "build -p hearthspace-settings" +``` + +Alternative if using `watchexec`: + +```sh +watchexec \ + -w crates/hearthspace-settings \ + -w crates/hearthspace-common \ + -w crates/hearthspace-ipc \ + -- cargo build -p hearthspace-settings +``` + +The watcher should be optional and development-only. + +Possible CLI flags: + +```sh +cargo run -- --watch-apps +cargo run -- --no-watch-apps +``` + +Recommended default in dev mode: + +```txt +watch apps: enabled if cargo-watch or watchexec is installed +watch apps: disabled with a clear warning if neither is installed +``` + +Example warning: + +```txt +[session] cargo-watch not found; Hearthspace Settings will build on first launch instead of rebuilding automatically +``` + +## App search integration + +The shell's app search should be able to find: + +- normal system apps +- temporary Hearthspace development apps +- future installed Hearthspace apps + +The app indexer should scan: + +```txt +$HEARTHSPACE_SESSION_DIR/share/applications +~/.local/share/applications +/usr/local/share/applications +/usr/share/applications +``` + +If the app indexer relies on `XDG_DATA_DIRS`, ensure the session supervisor passes a modified `XDG_DATA_DIRS` to the shell. + +The generated settings app should show up when searching for: + +```txt +settings +hearthspace +config +preferences +``` + +## IPC expectations + +`settingsd` should expose an IPC API for: + +- get setting +- set setting +- list settings +- watch setting changes +- validate setting +- reset setting to default +- export effective config +- report config file paths + +The settings UI should use the same IPC API as the shell and compositor. + +The settings UI should not write config files directly. + +The compositor and shell should subscribe to relevant setting changes and apply live changes where possible. + +Example logical flow: + +```txt +hearthspace-settings app +→ settingsd.set("canvas.show_grid", false) +→ settingsd validates and writes config atomically +→ settingsd emits change event +→ shell/compositor receive change event +→ canvas grid updates +``` + +## Config file locations + +The session supervisor should not own settings storage, but all components should agree on XDG paths. + +User config: + +```txt +~/.config/hearthspace/config.toml +~/.config/hearthspace/keybindings.toml +~/.config/hearthspace/apps.toml +``` + +Runtime IPC: + +```txt +$XDG_RUNTIME_DIR/hearthspace/session-/settings.sock +``` + +State: + +```txt +~/.local/state/hearthspace/ +``` + +Cache: + +```txt +~/.cache/hearthspace/ +``` + +## CLI shape + +Recommended `hearthspace-session` CLI: + +```txt +Usage: hearthspace-session [OPTIONS] + +Options: + --dev + Run child processes through `cargo run -p ...`. + + --installed + Run installed child binaries directly. + + --wayland-display + Wayland display socket name. Defaults to hearthspace-dev- in dev mode. + + --watch-apps + Start development watchers for Hearthspace apps. + + --no-watch-apps + Do not start development watchers. + + --no-shell-restart + Do not restart shell if it exits. + + --no-settingsd-restart + Do not restart settingsd if it exits. + + --log-level + Set log level for session and children. + + --print-env + Print the generated session environment. + + --dry-run + Print what would be started without starting processes. +``` + +Use `clap` for CLI parsing. + +## Suggested Rust crates + +Likely useful dependencies: + +```toml +anyhow = "1" +camino = "1" +clap = { version = "4", features = ["derive", "env"] } +directories = "6" +nix = { version = "0.30", features = ["signal", "process"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time"] } +tracing = "0.1" +tracing-subscriber = "0.3" +which = "8" +``` + +Adjust versions to match the existing workspace. + +## Session supervisor implementation outline + +High-level pseudo-Rust: + +```rust +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_logging(); + + let args = Args::parse(); + let mode = resolve_mode(&args)?; + let paths = SessionPaths::create(&args)?; + let env = SessionEnv::new(&paths, &args)?; + + generate_dev_desktop_entries(&paths, &env)?; + + let mut supervisor = Supervisor::new(paths, env, mode); + + supervisor.start_settingsd().await?; + supervisor.wait_for_settingsd_ready().await?; + + supervisor.start_compositor().await?; + supervisor.wait_for_compositor_ready().await?; + + supervisor.start_shell().await?; + + if supervisor.should_watch_apps() { + supervisor.start_settings_app_watcher().await?; + } + + supervisor.monitor_until_exit().await?; + supervisor.shutdown().await?; + + Ok(()) +} +``` + +Core types: + +```rust +struct SessionPaths { + runtime_dir: Utf8PathBuf, + session_dir: Utf8PathBuf, + applications_dir: Utf8PathBuf, + settings_socket: Utf8PathBuf, + session_socket: Utf8PathBuf, +} + +struct SessionEnv { + wayland_display: String, + vars: Vec<(String, String)>, +} + +enum RunMode { + DevCargo, + InstalledBinaries, +} + +struct ManagedProcess { + name: String, + critical: bool, + restart_policy: RestartPolicy, + child: tokio::process::Child, +} + +enum RestartPolicy { + Never, + OnCrash { max_restarts: usize }, +} +``` + +## Acceptance criteria + +This task is complete when: + +1. `cargo run` from the workspace root starts `hearthspace-session`. +2. `hearthspace-session` starts `hearthspace-settingsd`. +3. `hearthspace-session` starts `hearthspace-compositor`. +4. `hearthspace-session` waits for compositor readiness without arbitrary sleeps. +5. `hearthspace-session` starts `hearthspace-shell`. +6. The shell can launch normal apps using the compositor's `WAYLAND_DISPLAY`. +7. The session generates a dev `.desktop` entry for `hearthspace-settings`. +8. The shell app search can find and launch `Hearthspace Settings`. +9. The settings app runs as a normal Wayland client inside Hearthspace. +10. The settings app can communicate with `hearthspace-settingsd`. +11. In dev mode, the settings app is rebuilt automatically when its code changes if a watcher tool is available. +12. If watcher tooling is unavailable, launching the settings app builds it on demand. +13. If the compositor exits, the session supervisor shuts down the shell, settings daemon, and watchers. +14. `Ctrl+C` on the session supervisor shuts down all child processes cleanly. +15. Logs clearly show process startup, readiness, and shutdown. +16. Each component remains directly runnable with `cargo run -p `. + +## Future installed-session direction + +Later, this architecture can map to a real desktop session. + +Possible installed files: + +```txt +/usr/bin/hearthspace-session +/usr/bin/hearthspace-compositor +/usr/bin/hearthspace-shell +/usr/bin/hearthspace-settingsd +/usr/bin/hearthspace-settings + +/usr/share/wayland-sessions/hearthspace.desktop +/usr/share/applications/hearthspace-settings.desktop +``` + +Display manager session file: + +```ini +[Desktop Entry] +Name=Hearthspace +Comment=A spatial Linux desktop for persistent, local-first computing +Exec=hearthspace-session --installed +Type=Application +DesktopNames=Hearthspace +``` + +Future systemd user direction: + +```txt +hearthspace-session.target +├─ hearthspace-settingsd.service +├─ hearthspace-compositor.service +└─ hearthspace-shell.service +``` + +Do not start with systemd user units unless they become useful. The Rust session supervisor should remain the main development entrypoint for now. + +## Design principle + +The session supervisor should make development feel like a real desktop session without requiring the project to be installed as a real desktop session. + +The intended experience: + +```sh +cargo run +``` + +Then, inside Hearthspace: + +1. Search for apps. +2. Launch normal Linux apps. +3. Search for “Hearthspace Settings.” +4. Launch the settings UI as a normal app. +5. Edit settings. +6. Watch shell/compositor behavior update through `settingsd`. + +This keeps the compositor, shell, settings daemon, and settings app separate while making the whole system feel integrated and dogfoodable. -- 2.51.2 From 44c5ec5f7565b8a8c46b66146bb3554bce39bfea Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 02:53:46 -0700 Subject: [PATCH 05/28] feat: add session supervisor entrypoint --- Cargo.lock | 121 +++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/bin/hearthspace-session.rs | 61 +++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 src/bin/hearthspace-session.rs diff --git a/Cargo.lock b/Cargo.lock index 2a469e5..fd65bc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -875,6 +925,46 @@ dependencies = [ "num-traits", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -910,6 +1000,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -2137,6 +2233,7 @@ dependencies = [ "async-io", "atspi 0.30.0", "calloop 0.14.4", + "clap", "fontique", "gtk4", "image", @@ -2458,6 +2555,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -3396,6 +3499,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "option-operations" version = "0.6.1" @@ -4608,6 +4717,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -5243,6 +5358,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.4" diff --git a/Cargo.toml b/Cargo.toml index dc24738..8bacc8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" rust-version = "1.93.1" license = "Apache-2.0" +default-run = "hearthspace-session" [features] default = ["winit"] @@ -25,6 +26,7 @@ winit = ["smithay/backend_winit"] async-io = "2.3.3" atspi = { version = "0.30.0", features = ["zbus"] } calloop = { version = "0.14.4", features = ["signals"] } +clap = { version = "4", features = ["derive"] } gtk = { package = "gtk4", version = "0.11", optional = true } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs new file mode 100644 index 0000000..3ecdb8e --- /dev/null +++ b/src/bin/hearthspace-session.rs @@ -0,0 +1,61 @@ +use clap::{ArgAction, Parser}; + +#[derive(Debug, Parser)] +#[command(about = "Start a Hearthspace desktop session")] +struct Args { + /// Run child processes through `cargo run --bin ...`. + #[arg(long, conflicts_with = "installed")] + dev: bool, + + /// Run installed child binaries directly. + #[arg(long, conflicts_with = "dev")] + installed: bool, + + /// Wayland display socket name. + #[arg(long)] + wayland_display: Option, + + /// Start development watchers for Hearthspace apps. + #[arg(long, conflicts_with = "no_watch_apps")] + watch_apps: bool, + + /// Do not start development watchers. + #[arg(long, conflicts_with = "watch_apps")] + no_watch_apps: bool, + + /// Do not restart shell if it exits. + #[arg(long)] + no_shell_restart: bool, + + /// Do not restart settingsd if it exits. + #[arg(long)] + no_settingsd_restart: bool, + + /// Set log level for session and children. + #[arg(long, default_value = "info")] + log_level: String, + + /// Print the generated session environment. + #[arg(long)] + print_env: bool, + + /// Print what would be started without starting processes. + #[arg(long, action = ArgAction::SetTrue)] + dry_run: bool, +} + +fn main() { + let args = Args::parse(); + initialize_tracing(&args.log_level); + tracing::info!(?args, "parsed session supervisor arguments"); +} + +fn initialize_tracing(log_level: &str) { + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .or_else(|_| tracing_subscriber::EnvFilter::try_new(log_level)); + + match env_filter { + Ok(env_filter) => tracing_subscriber::fmt().with_env_filter(env_filter).init(), + Err(_) => tracing_subscriber::fmt().init(), + } +} -- 2.51.2 From c28eca4400eea2bc806c6b838beb99f1b57876a2 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 02:56:05 -0700 Subject: [PATCH 06/28] feat: prepare dev session environment --- scripts/dev-run-app | 13 +++ src/bin/hearthspace-session.rs | 173 ++++++++++++++++++++++++++++++++- src/settings/mod.rs | 5 + 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100755 scripts/dev-run-app diff --git a/scripts/dev-run-app b/scripts/dev-run-app new file mode 100755 index 0000000..8aee15c --- /dev/null +++ b/scripts/dev-run-app @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="${1:?usage: dev-run-app }" +REPO_ROOT="${HEARTHSPACE_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + +cd "$REPO_ROOT" + +if [[ ! -x "target/debug/$APP" ]]; then + cargo build --bin "$APP" +fi + +exec "$REPO_ROOT/target/debug/$APP" diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 3ecdb8e..3f08d3f 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -1,4 +1,11 @@ +use std::{ + env, fs, + path::{Path, PathBuf}, + process, +}; + use clap::{ArgAction, Parser}; +use tracing::info; #[derive(Debug, Parser)] #[command(about = "Start a Hearthspace desktop session")] @@ -44,10 +51,53 @@ struct Args { dry_run: bool, } -fn main() { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RunMode { + DevCargo, + InstalledBinaries, +} + +#[derive(Debug)] +struct SessionPaths { + session_dir: PathBuf, + applications_dir: PathBuf, + settings_socket: PathBuf, + session_socket: PathBuf, +} + +#[derive(Debug)] +struct SessionEnv { + wayland_display: String, + vars: Vec<(String, String)>, +} + +fn main() -> Result<(), Box> { let args = Args::parse(); initialize_tracing(&args.log_level); - tracing::info!(?args, "parsed session supervisor arguments"); + + let mode = resolve_mode(&args); + let paths = create_session_paths()?; + let session_env = create_session_env(&args, mode, &paths)?; + + if mode == RunMode::DevCargo { + generate_dev_desktop_entries(&paths)?; + } + + info!(?mode, session_dir = %paths.session_dir.display(), "prepared Hearthspace session"); + info!(wayland_display = %session_env.wayland_display, "prepared Wayland display"); + + if args.print_env || args.dry_run { + for (key, value) in &session_env.vars { + println!("{key}={value}"); + } + } + + if args.dry_run { + return Ok(()); + } + + info!("session process supervision is not implemented yet"); + Ok(()) } fn initialize_tracing(log_level: &str) { @@ -59,3 +109,122 @@ fn initialize_tracing(log_level: &str) { Err(_) => tracing_subscriber::fmt().init(), } } + +fn resolve_mode(args: &Args) -> RunMode { + if args.dev { + return RunMode::DevCargo; + } + if args.installed { + return RunMode::InstalledBinaries; + } + + if cfg!(debug_assertions) { + RunMode::DevCargo + } else { + RunMode::InstalledBinaries + } +} + +fn create_session_paths() -> Result> { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set")?; + let session_dir = PathBuf::from(runtime_dir) + .join("hearthspace") + .join(format!("session-{}", process::id())); + let applications_dir = session_dir.join("share/applications"); + + fs::create_dir_all(&applications_dir)?; + + Ok(SessionPaths { + settings_socket: session_dir.join("settings.sock"), + session_socket: session_dir.join("session.sock"), + session_dir, + applications_dir, + }) +} + +fn create_session_env( + args: &Args, + mode: RunMode, + paths: &SessionPaths, +) -> Result> { + let wayland_display = args + .wayland_display + .clone() + .unwrap_or_else(|| format!("hearthspace-dev-{}", process::id())); + let session_dir = path_to_string(&paths.session_dir)?; + let settings_socket = path_to_string(&paths.settings_socket)?; + let session_socket = path_to_string(&paths.session_socket)?; + let mut vars = vec![ + ("WAYLAND_DISPLAY".to_string(), wayland_display.clone()), + ( + "XDG_CURRENT_DESKTOP".to_string(), + "Hearthspace".to_string(), + ), + ("DESKTOP_SESSION".to_string(), "hearthspace".to_string()), + ("HEARTHSPACE_SESSION_DIR".to_string(), session_dir.clone()), + ( + "HEARTHSPACE_SETTINGS_SOCKET".to_string(), + settings_socket, + ), + ("HEARTHSPACE_SESSION_SOCKET".to_string(), session_socket), + ("RUST_LOG".to_string(), args.log_level.clone()), + ("XDG_DATA_DIRS".to_string(), xdg_data_dirs(&session_dir)?), + ]; + + if mode == RunMode::DevCargo { + vars.push(("HEARTHSPACE_DEV".to_string(), "1".to_string())); + vars.push(( + "HEARTHSPACE_REPO_ROOT".to_string(), + env!("CARGO_MANIFEST_DIR").to_string(), + )); + } + + Ok(SessionEnv { + wayland_display, + vars, + }) +} + +fn path_to_string(path: &Path) -> Result> { + Ok(path + .to_str() + .ok_or_else(|| format!("path is not valid UTF-8: {}", path.display()))? + .to_string()) +} + +fn xdg_data_dirs(session_dir: &str) -> Result> { + let mut dirs = vec![PathBuf::from(session_dir)]; + if let Some(existing) = env::var_os("XDG_DATA_DIRS") { + dirs.extend(env::split_paths(&existing)); + } else { + dirs.push(PathBuf::from("/usr/local/share")); + dirs.push(PathBuf::from("/usr/share")); + } + Ok(env::join_paths(dirs)?.to_string_lossy().into_owned()) +} + +fn generate_dev_desktop_entries(paths: &SessionPaths) -> Result<(), Box> { + let exec = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/dev-run-app"); + let desktop_entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Hearthspace Settings\n\ + Comment=Configure Hearthspace\n\ + Exec=\"{}\" hearthspace-settings\n\ + Icon=hearthspace\n\ + Categories=Settings;System;\n\ + Keywords=hearthspace;settings;config;preferences;\n\ + X-Hearthspace-Dev=true\n", + escape_exec_path(&exec) + ); + let path = paths.applications_dir.join("hearthspace-settings.desktop"); + fs::write(&path, desktop_entry)?; + info!(path = %path.display(), "generated development desktop entry"); + Ok(()) +} + +fn escape_exec_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index fe548bb..91d0003 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -9,6 +9,7 @@ use std::{ const CONFIG_DIR_NAME: &str = "hearthspace"; const CONFIG_FILE_NAME: &str = "config.toml"; const SETTINGS_SOCKET_NAME: &str = "hearthspace-settingsd.sock"; +const SETTINGS_SOCKET_ENV: &str = "HEARTHSPACE_SETTINGS_SOCKET"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Settings { @@ -26,6 +27,10 @@ pub fn config_path() -> io::Result { } pub fn settings_socket_path() -> io::Result { + if let Some(socket_path) = env::var_os(SETTINGS_SOCKET_ENV) { + return Ok(PathBuf::from(socket_path)); + } + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))?; Ok(PathBuf::from(runtime_dir).join(SETTINGS_SOCKET_NAME)) -- 2.51.2 From 6f9661703f569d23e60809fdfa91498f6cffd2e4 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:05:51 -0700 Subject: [PATCH 07/28] feat: supervise session processes --- Cargo.lock | 86 +++++- Cargo.toml | 3 + src/bin/hearthspace-compositor.rs | 183 ++++++++++++ src/bin/hearthspace-session.rs | 436 +++++++++++++++++++++++++++- src/bin/hearthspace-shell.rs | 31 ++ src/compositor/mod.rs | 15 +- src/compositor/shell_integration.rs | 18 +- src/config.rs | 8 + src/shell/app_catalog.rs | 3 +- 9 files changed, 743 insertions(+), 40 deletions(-) create mode 100644 src/bin/hearthspace-compositor.rs create mode 100644 src/bin/hearthspace-shell.rs diff --git a/Cargo.lock b/Cargo.lock index fd65bc9..bdbd940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -749,6 +749,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.2" @@ -850,7 +859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.0", - "nix", + "nix 0.31.3", "polling", "rustix 1.1.4", "slab", @@ -1178,6 +1187,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1212,6 +1232,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -2234,11 +2266,13 @@ dependencies = [ "atspi 0.30.0", "calloop 0.14.4", "clap", + "ctrlc", "fontique", "gtk4", "image", "masonry", "naga", + "nix 0.30.1", "png", "proptest", "rstest", @@ -2252,6 +2286,7 @@ dependencies = [ "waydriver-hearthspace", "wayland-protocols", "wayland-server", + "which", "xcursor", "xilem", ] @@ -3119,6 +3154,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -3284,7 +3331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -3300,7 +3347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3312,7 +3359,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3324,7 +3371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3344,7 +3391,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3356,7 +3403,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -3385,7 +3432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3407,7 +3454,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation 0.2.2", @@ -3420,7 +3467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3432,7 +3479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3455,7 +3502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -3475,7 +3522,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3487,7 +3534,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -5949,6 +5996,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +dependencies = [ + "libc", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -6235,7 +6291,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.13.0", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", diff --git a/Cargo.toml b/Cargo.toml index 8bacc8a..8da0677 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ async-io = "2.3.3" atspi = { version = "0.30.0", features = ["zbus"] } calloop = { version = "0.14.4", features = ["signals"] } clap = { version = "4", features = ["derive"] } +ctrlc = "3" gtk = { package = "gtk4", version = "0.11", optional = true } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds @@ -40,6 +41,7 @@ xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" # depend on it (wgpu-core, vello_shaders) enable `wgsl-in` without it. We add a # direct dependency purely to union `termcolor` into the shared naga build. naga = { version = "28.0.0", default-features = false, features = ["termcolor"] } +nix = { version = "0.30", features = ["signal", "process"] } # Parley's fontique discovers system fonts via fontconfig. Enabling its # `fontconfig-dlopen` code path makes it load libfontconfig at runtime instead # of link-time, which avoids needing fontconfig dev headers at build time. @@ -56,6 +58,7 @@ wayland-protocols = { version = "0.32.8", features = ["server"] } wayland-server = "0.31.9" waydriver = { version = "0.3.9", optional = true } waydriver-hearthspace = { path = "crates/waydriver-hearthspace", optional = true } +which = "8" xcursor = "0.3.10" [dev-dependencies] diff --git a/src/bin/hearthspace-compositor.rs b/src/bin/hearthspace-compositor.rs new file mode 100644 index 0000000..333f96b --- /dev/null +++ b/src/bin/hearthspace-compositor.rs @@ -0,0 +1,183 @@ +use std::fs::OpenOptions; + +use tracing::{debug, info}; + +fn main() -> Result<(), Box> { + initialize_tracing(); + + let args = std::env::args().collect::>(); + debug!(?args, "parsed compositor arguments"); + + let options = hearthspace::RunOptions { + scroll_zooms_without_super: args + .iter() + .any(|arg| arg == hearthspace::config::SCROLL_ZOOMS_FLAG), + backend: parse_backend_selection(&args)?, + headless_output_size: parse_headless_output_size(&args)?, + headless_output_scale: parse_headless_output_scale(&args)?, + exit_after: parse_exit_after(&args)?, + start_shell: !args + .iter() + .any(|arg| arg == hearthspace::config::NO_SHELL_FLAG), + }; + info!(?options, "starting Hearthspace compositor"); + hearthspace::run_with_options(options) +} + +fn initialize_tracing() { + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env(); + if let Some(log_path) = std::env::var_os(hearthspace::config::LOG_FILE_ENV) { + let make_writer = move || { + OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .expect("failed to open Hearthspace log file") + }; + let subscriber = tracing_subscriber::fmt().with_writer(make_writer); + match env_filter { + Ok(env_filter) => subscriber.with_env_filter(env_filter).init(), + Err(_) => subscriber.init(), + } + return; + } + + match env_filter { + Ok(env_filter) => tracing_subscriber::fmt().with_env_filter(env_filter).init(), + Err(_) => tracing_subscriber::fmt().init(), + } +} + +fn parse_backend_selection(args: &[String]) -> Result { + let mut selected = Vec::new(); + if args + .iter() + .any(|arg| arg == hearthspace::config::HEADLESS_FLAG) + { + selected.push(( + hearthspace::config::HEADLESS_FLAG, + hearthspace::BackendSelection::Headless, + )); + } + if args.iter().any(|arg| arg == hearthspace::config::TTY_FLAG) { + selected.push(( + hearthspace::config::TTY_FLAG, + hearthspace::BackendSelection::Udev, + )); + } + if args + .iter() + .any(|arg| arg == hearthspace::config::WINIT_FLAG) + { + selected.push(( + hearthspace::config::WINIT_FLAG, + hearthspace::BackendSelection::Winit, + )); + } + + match selected.as_slice() { + [] => Ok(hearthspace::BackendSelection::Auto), + [(_, backend)] => Ok(*backend), + _ => Err(format!( + "choose only one backend flag: {}, {}, or {}", + hearthspace::config::HEADLESS_FLAG, + hearthspace::config::TTY_FLAG, + hearthspace::config::WINIT_FLAG + )), + } +} + +fn parse_exit_after(args: &[String]) -> Result, String> { + for (index, arg) in args.iter().enumerate() { + if arg == hearthspace::config::EXIT_AFTER_MS_FLAG { + let Some(value) = args.get(index + 1) else { + return Err(format!( + "{} requires a positive integer", + hearthspace::config::EXIT_AFTER_MS_FLAG + )); + }; + return parse_positive_duration_ms(value).map(Some); + } + + if let Some(value) = + arg.strip_prefix(&format!("{}=", hearthspace::config::EXIT_AFTER_MS_FLAG)) + { + return parse_positive_duration_ms(value).map(Some); + } + } + + Ok(None) +} + +fn parse_headless_output_size(args: &[String]) -> Result, String> { + for (index, arg) in args.iter().enumerate() { + if arg == hearthspace::config::HEADLESS_SIZE_FLAG { + let Some(value) = args.get(index + 1) else { + return Err(format!( + "{} requires WIDTHxHEIGHT", + hearthspace::config::HEADLESS_SIZE_FLAG + )); + }; + return parse_size(value).map(Some); + } + + if let Some(value) = + arg.strip_prefix(&format!("{}=", hearthspace::config::HEADLESS_SIZE_FLAG)) + { + return parse_size(value).map(Some); + } + } + + Ok(None) +} + +fn parse_headless_output_scale(args: &[String]) -> Result, String> { + for (index, arg) in args.iter().enumerate() { + if arg == hearthspace::config::HEADLESS_SCALE_FLAG { + let Some(value) = args.get(index + 1) else { + return Err(format!( + "{} requires a positive integer", + hearthspace::config::HEADLESS_SCALE_FLAG + )); + }; + return parse_positive_dimension(value, "scale").map(Some); + } + + if let Some(value) = + arg.strip_prefix(&format!("{}=", hearthspace::config::HEADLESS_SCALE_FLAG)) + { + return parse_positive_dimension(value, "scale").map(Some); + } + } + + Ok(None) +} + +fn parse_size(value: &str) -> Result<(i32, i32), String> { + let Some((width, height)) = value.split_once('x').or_else(|| value.split_once('X')) else { + return Err(format!("invalid size {value:?}; expected WIDTHxHEIGHT")); + }; + let width = parse_positive_dimension(width, "width")?; + let height = parse_positive_dimension(height, "height")?; + Ok((width, height)) +} + +fn parse_positive_dimension(value: &str, name: &str) -> Result { + let parsed = value + .parse::() + .map_err(|_| format!("invalid {name} {value:?}"))?; + if parsed <= 0 { + return Err(format!("{name} must be positive")); + } + Ok(parsed) +} + +fn parse_positive_duration_ms(value: &str) -> Result { + let parsed = value + .parse::() + .map_err(|_| format!("invalid duration {value:?}"))?; + if parsed == 0 { + return Err("duration must be positive".into()); + } + Ok(std::time::Duration::from_millis(parsed)) +} diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 3f08d3f..a8f4064 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -1,11 +1,21 @@ use std::{ env, fs, + io::{Read, Write}, + net::Shutdown, + os::unix::net::UnixStream, path::{Path, PathBuf}, - process, + process::{self, Child, Command, ExitStatus, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, }; use clap::{ArgAction, Parser}; -use tracing::info; +use nix::{sys::signal, unistd::Pid}; +use tracing::{info, warn}; #[derive(Debug, Parser)] #[command(about = "Start a Hearthspace desktop session")] @@ -59,6 +69,7 @@ enum RunMode { #[derive(Debug)] struct SessionPaths { + runtime_dir: PathBuf, session_dir: PathBuf, applications_dir: PathBuf, settings_socket: PathBuf, @@ -71,6 +82,33 @@ struct SessionEnv { vars: Vec<(String, String)>, } +#[derive(Debug, Clone)] +struct ProcessSpec { + name: &'static str, + program: String, + args: Vec, + envs: Vec<(String, String)>, + current_dir: Option, +} + +struct ManagedProcess { + spec: ProcessSpec, + child: Child, + restarts: usize, + max_restarts: usize, +} + +struct Supervisor { + mode: RunMode, + paths: SessionPaths, + session_env: SessionEnv, + shutdown_requested: Arc, + settingsd: Option, + compositor: Option, + shell: Option, + watcher: Option, +} + fn main() -> Result<(), Box> { let args = Args::parse(); initialize_tracing(&args.log_level); @@ -96,8 +134,19 @@ fn main() -> Result<(), Box> { return Ok(()); } - info!("session process supervision is not implemented yet"); - Ok(()) + let shutdown_requested = install_signal_handler()?; + let mut supervisor = Supervisor::new(mode, paths, session_env, shutdown_requested); + + supervisor.start_settingsd(if args.no_settingsd_restart { 0 } else { 3 })?; + supervisor.wait_for_settingsd_ready()?; + supervisor.start_compositor()?; + supervisor.wait_for_compositor_ready()?; + supervisor.start_shell(if args.no_shell_restart { 0 } else { 3 })?; + supervisor.start_app_watcher_if_available(&args)?; + + let monitor_result = supervisor.monitor_until_exit(); + supervisor.shutdown(); + monitor_result } fn initialize_tracing(log_level: &str) { @@ -127,7 +176,8 @@ fn resolve_mode(args: &Args) -> RunMode { fn create_session_paths() -> Result> { let runtime_dir = env::var_os("XDG_RUNTIME_DIR").ok_or("XDG_RUNTIME_DIR is not set")?; - let session_dir = PathBuf::from(runtime_dir) + let runtime_dir = PathBuf::from(runtime_dir); + let session_dir = runtime_dir .join("hearthspace") .join(format!("session-{}", process::id())); let applications_dir = session_dir.join("share/applications"); @@ -135,6 +185,7 @@ fn create_session_paths() -> Result> { fs::create_dir_all(&applications_dir)?; Ok(SessionPaths { + runtime_dir, settings_socket: session_dir.join("settings.sock"), session_socket: session_dir.join("session.sock"), session_dir, @@ -228,3 +279,378 @@ fn escape_exec_path(path: &Path) -> String { .replace('\\', "\\\\") .replace('"', "\\\"") } + +fn install_signal_handler() -> Result, Box> { + let shutdown_requested = Arc::new(AtomicBool::new(false)); + let signal_shutdown = Arc::clone(&shutdown_requested); + ctrlc::set_handler(move || { + signal_shutdown.store(true, Ordering::SeqCst); + })?; + Ok(shutdown_requested) +} + +impl Supervisor { + fn new( + mode: RunMode, + paths: SessionPaths, + session_env: SessionEnv, + shutdown_requested: Arc, + ) -> Self { + Self { + mode, + paths, + session_env, + shutdown_requested, + settingsd: None, + compositor: None, + shell: None, + watcher: None, + } + } + + fn start_settingsd(&mut self, max_restarts: usize) -> Result<(), Box> { + let spec = self.binary_spec("hearthspace-settingsd", &[], true); + self.settingsd = Some(ManagedProcess::spawn(spec, max_restarts)?); + Ok(()) + } + + fn start_compositor(&mut self) -> Result<(), Box> { + let spec = self.binary_spec( + "hearthspace-compositor", + &[hearthspace::config::NO_SHELL_FLAG], + false, + ); + self.compositor = Some(ManagedProcess::spawn(spec, 0)?); + Ok(()) + } + + fn start_shell(&mut self, max_restarts: usize) -> Result<(), Box> { + let spec = self.binary_spec("hearthspace-shell", &[], true); + self.shell = Some(ManagedProcess::spawn(spec, max_restarts)?); + Ok(()) + } + + fn start_app_watcher_if_available( + &mut self, + args: &Args, + ) -> Result<(), Box> { + if self.mode != RunMode::DevCargo || args.no_watch_apps { + return Ok(()); + } + + let Some(spec) = self.watcher_spec() else { + if args.watch_apps { + warn!("app watcher requested, but neither cargo-watch nor watchexec was found"); + } else { + warn!("cargo-watch/watchexec not found; Hearthspace Settings will build on first launch"); + } + return Ok(()); + }; + + self.watcher = Some(ManagedProcess::spawn(spec, 0)?); + Ok(()) + } + + fn wait_for_settingsd_ready(&mut self) -> Result<(), Box> { + info!(socket = %self.paths.settings_socket.display(), "waiting for settingsd readiness"); + let deadline = Instant::now() + Duration::from_secs(120); + while Instant::now() < deadline { + if let Some(status) = poll_process(&mut self.settingsd)? { + return Err(format!("settingsd exited before readiness: {status}").into()); + } + if settingsd_ping(&self.paths.settings_socket).is_ok() { + info!("settingsd ready"); + return Ok(()); + } + thread::sleep(Duration::from_millis(50)); + } + Err("timed out waiting for settingsd readiness".into()) + } + + fn wait_for_compositor_ready(&mut self) -> Result<(), Box> { + let socket = self.paths.runtime_dir.join(&self.session_env.wayland_display); + info!(socket = %socket.display(), "waiting for compositor readiness"); + let deadline = Instant::now() + Duration::from_secs(120); + while Instant::now() < deadline { + if let Some(status) = poll_process(&mut self.compositor)? { + return Err(format!("compositor exited before readiness: {status}").into()); + } + if socket.exists() { + info!("compositor ready"); + return Ok(()); + } + thread::sleep(Duration::from_millis(50)); + } + Err("timed out waiting for compositor readiness".into()) + } + + fn monitor_until_exit(&mut self) -> Result<(), Box> { + info!("monitoring Hearthspace session processes"); + loop { + if self.shutdown_requested.load(Ordering::SeqCst) { + info!("shutdown requested"); + return Ok(()); + } + + if let Some(status) = poll_process(&mut self.compositor)? { + info!(%status, "compositor exited; ending session"); + return Ok(()); + } + + if let Some(status) = poll_process(&mut self.settingsd)? { + if self.restart_process("settingsd", status)? { + self.wait_for_settingsd_ready()?; + } else { + return Err(format!("settingsd exited: {status}").into()); + } + } + + if let Some(status) = poll_process(&mut self.shell)? + && !self.restart_process("shell", status)? + { + return Err(format!("shell exited: {status}").into()); + } + + if let Some(status) = poll_process(&mut self.watcher)? { + warn!(%status, "app watcher exited; continuing session"); + self.watcher = None; + } + + thread::sleep(Duration::from_millis(100)); + } + } + + fn shutdown(&mut self) { + info!("shutting down Hearthspace session processes"); + terminate_process(&mut self.shell); + terminate_process(&mut self.compositor); + terminate_process(&mut self.settingsd); + terminate_process(&mut self.watcher); + + wait_then_kill_process(&mut self.shell); + wait_then_kill_process(&mut self.compositor); + wait_then_kill_process(&mut self.settingsd); + wait_then_kill_process(&mut self.watcher); + + match fs::remove_dir_all(&self.paths.session_dir) { + Ok(()) => info!(path = %self.paths.session_dir.display(), "removed session runtime directory"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => warn!(path = %self.paths.session_dir.display(), %error, "failed to remove session runtime directory"), + } + } + + fn restart_process( + &mut self, + name: &'static str, + status: ExitStatus, + ) -> Result> { + let process = match name { + "settingsd" => &mut self.settingsd, + "shell" => &mut self.shell, + _ => return Ok(false), + }; + let Some(process) = process else { + return Ok(false); + }; + if process.restarts >= process.max_restarts { + return Ok(false); + } + warn!(name, %status, restart = process.restarts + 1, max_restarts = process.max_restarts, "restarting process"); + process.restart()?; + Ok(true) + } + + fn binary_spec( + &self, + binary: &'static str, + binary_args: &[&str], + include_client_wayland: bool, + ) -> ProcessSpec { + let mut envs = self.child_env(include_client_wayland); + if !include_client_wayland { + envs.push(( + hearthspace::config::HEARTHSPACE_WAYLAND_DISPLAY_ENV.to_string(), + self.session_env.wayland_display.clone(), + )); + } + + let current_dir = (self.mode == RunMode::DevCargo) + .then(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + match self.mode { + RunMode::DevCargo => { + let mut args = vec!["run".to_string(), "--bin".to_string(), binary.to_string()]; + args.push("--".to_string()); + args.extend(binary_args.iter().map(|arg| (*arg).to_string())); + ProcessSpec { + name: binary, + program: "cargo".to_string(), + args, + envs, + current_dir, + } + } + RunMode::InstalledBinaries => ProcessSpec { + name: binary, + program: binary.to_string(), + args: binary_args.iter().map(|arg| (*arg).to_string()).collect(), + envs, + current_dir, + }, + } + } + + fn child_env(&self, include_client_wayland: bool) -> Vec<(String, String)> { + self.session_env + .vars + .iter() + .filter(|(key, _)| include_client_wayland || key != hearthspace::config::WAYLAND_DISPLAY_ENV) + .cloned() + .collect() + } + + fn watcher_spec(&self) -> Option { + let envs = self.child_env(true); + let current_dir = Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + if which::which("cargo-watch").is_ok() { + return Some(ProcessSpec { + name: "settings-watch", + program: "cargo".to_string(), + args: vec![ + "watch".to_string(), + "-w".to_string(), + "src/bin/hearthspace-settings.rs".to_string(), + "-w".to_string(), + "src/settings".to_string(), + "-x".to_string(), + "build --bin hearthspace-settings".to_string(), + ], + envs, + current_dir, + }); + } + if which::which("watchexec").is_ok() { + return Some(ProcessSpec { + name: "settings-watch", + program: "watchexec".to_string(), + args: vec![ + "-w".to_string(), + "src/bin/hearthspace-settings.rs".to_string(), + "-w".to_string(), + "src/settings".to_string(), + "--".to_string(), + "cargo".to_string(), + "build".to_string(), + "--bin".to_string(), + "hearthspace-settings".to_string(), + ], + envs, + current_dir, + }); + } + None + } +} + +impl ManagedProcess { + fn spawn(spec: ProcessSpec, max_restarts: usize) -> Result> { + let child = spawn_child(&spec)?; + Ok(Self { + spec, + child, + restarts: 0, + max_restarts, + }) + } + + fn restart(&mut self) -> Result<(), Box> { + self.restarts += 1; + self.child = spawn_child(&self.spec)?; + Ok(()) + } +} + +fn spawn_child(spec: &ProcessSpec) -> Result> { + info!(name = spec.name, program = %spec.program, args = ?spec.args, "starting process"); + let mut command = Command::new(&spec.program); + command + .args(&spec.args) + .envs(spec.envs.iter().map(|(key, value)| (key, value))) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(current_dir) = &spec.current_dir { + command.current_dir(current_dir); + } + Ok(command.spawn()?) +} + +fn poll_process( + process: &mut Option, +) -> Result, Box> { + let Some(process) = process else { + return Ok(None); + }; + Ok(process.child.try_wait()?) +} + +fn settingsd_ping(socket_path: &Path) -> std::io::Result<()> { + if !socket_path.exists() { + return Err(std::io::ErrorKind::NotFound.into()); + } + let mut stream = UnixStream::connect(socket_path)?; + stream.write_all(b"get\n")?; + stream.shutdown(Shutdown::Write)?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + if response.starts_with("ok ") { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "unexpected settingsd response: {response:?}" + ))) + } +} + +fn terminate_process(process: &mut Option) { + let Some(process) = process else { + return; + }; + if matches!(process.child.try_wait(), Ok(Some(_))) { + return; + } + let Ok(raw_pid) = i32::try_from(process.child.id()) else { + warn!(name = process.spec.name, pid = process.child.id(), "cannot signal process with oversized pid"); + return; + }; + match signal::kill(Pid::from_raw(raw_pid), signal::Signal::SIGTERM) { + Ok(()) => info!(name = process.spec.name, pid = raw_pid, "sent SIGTERM"), + Err(error) => warn!(name = process.spec.name, pid = raw_pid, %error, "failed to send SIGTERM"), + } +} + +fn wait_then_kill_process(process: &mut Option) { + let Some(process) = process else { + return; + }; + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + match process.child.try_wait() { + Ok(Some(status)) => { + info!(name = process.spec.name, %status, "process exited during shutdown"); + return; + } + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(error) => { + warn!(name = process.spec.name, %error, "failed to poll process during shutdown"); + return; + } + } + } + + match process.child.kill() { + Ok(()) => info!(name = process.spec.name, "killed process after shutdown timeout"), + Err(error) => warn!(name = process.spec.name, %error, "failed to kill process after shutdown timeout"), + } + let _ = process.child.wait(); +} diff --git a/src/bin/hearthspace-shell.rs b/src/bin/hearthspace-shell.rs new file mode 100644 index 0000000..d9fa831 --- /dev/null +++ b/src/bin/hearthspace-shell.rs @@ -0,0 +1,31 @@ +use std::fs::OpenOptions; + +fn main() -> Result<(), Box> { + initialize_tracing(); + tracing::info!("starting Hearthspace shell"); + hearthspace::shell::xilem_shell::run() +} + +fn initialize_tracing() { + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env(); + if let Some(log_path) = std::env::var_os(hearthspace::config::LOG_FILE_ENV) { + let make_writer = move || { + OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .expect("failed to open Hearthspace log file") + }; + let subscriber = tracing_subscriber::fmt().with_writer(make_writer); + match env_filter { + Ok(env_filter) => subscriber.with_env_filter(env_filter).init(), + Err(_) => subscriber.init(), + } + return; + } + + match env_filter { + Ok(env_filter) => tracing_subscriber::fmt().with_env_filter(env_filter).init(), + Err(_) => tracing_subscriber::fmt().init(), + } +} diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index 6f5ecb0..b11d239 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -286,10 +286,8 @@ pub fn run_winit(options: RunOptions) -> Result<(), Box> WinitEvent::Focus(_) => {} })?; - info!( - wayland_display = WAYLAND_DISPLAY_NAME, - "Hearthspace compositor running" - ); + let wayland_display = crate::config::wayland_display_name(); + info!(wayland_display, "Hearthspace compositor running"); if state.scroll_zooms_without_super { info!("scroll zoom testing mode enabled: vertical scroll zooms without Super"); } @@ -348,10 +346,8 @@ pub fn run_headless(options: RunOptions) -> Result<(), Box std::io::Result { let snap_runtime_dir = runtime_path(&format!("snap.{instance_name}")); fs::create_dir_all(&snap_runtime_dir)?; - let snap_socket_path = snap_runtime_dir.join(WAYLAND_DISPLAY_NAME); + let wayland_display = wayland_display_name(); + let snap_socket_path = snap_runtime_dir.join(&wayland_display); match fs::remove_file(&snap_socket_path) { Ok(()) => {} @@ -65,11 +66,8 @@ fn ensure_snap_wayland_socket(instance_name: &str) -> std::io::Result { Err(error) => return Err(error), } - symlink( - PathBuf::from("..").join(WAYLAND_DISPLAY_NAME), - &snap_socket_path, - )?; - Ok(WAYLAND_DISPLAY_NAME.to_string()) + symlink(PathBuf::from("..").join(&wayland_display), &snap_socket_path)?; + Ok(wayland_display) } fn launch_environment_for_app(app: &DesktopApp) -> std::io::Result> { @@ -146,7 +144,7 @@ pub(super) fn spawn_shell(command_socket_path: &PathBuf) { if let Err(error) = Command::new(¤t_exe) .arg(SHELL_FLAG) - .env("WAYLAND_DISPLAY", WAYLAND_DISPLAY_NAME) + .env(WAYLAND_DISPLAY_ENV, wayland_display_name()) .env(SHELL_COMMAND_SOCKET_ENV, command_socket_path) .spawn() { @@ -435,7 +433,7 @@ impl App { let mut command = Command::new(current_exe); command .arg(GTK_TEST_APP_FLAG) - .env("WAYLAND_DISPLAY", WAYLAND_DISPLAY_NAME) + .env(WAYLAND_DISPLAY_ENV, wayland_display_name()) .env("GDK_BACKEND", "wayland"); apply_gtk_client_environment(&mut command); @@ -453,7 +451,7 @@ impl App { self.prepare_spawn_position(); match Command::new("foot") - .env("WAYLAND_DISPLAY", WAYLAND_DISPLAY_NAME) + .env(WAYLAND_DISPLAY_ENV, wayland_display_name()) .spawn() { Ok(child) => info!(pid = child.id(), "spawned foot terminal"), @@ -496,7 +494,7 @@ impl App { } } } else { - WAYLAND_DISPLAY_NAME.to_string() + wayland_display_name() }; let launch_env = match launch_environment_for_app(app) { diff --git a/src/config.rs b/src/config.rs index ba1f354..fdeb247 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,8 @@ use std::time::Duration; pub const WAYLAND_DISPLAY_NAME: &str = "wayland-99"; +pub const WAYLAND_DISPLAY_ENV: &str = "WAYLAND_DISPLAY"; +pub const HEARTHSPACE_WAYLAND_DISPLAY_ENV: &str = "HEARTHSPACE_WAYLAND_DISPLAY"; pub const GTK_TEST_APP_FLAG: &str = "--gtk-test-app"; pub const SHELL_FLAG: &str = "--shell"; pub const HEADLESS_FLAG: &str = "--headless"; @@ -57,6 +59,12 @@ pub const WINDOW_IDLE_THRESHOLDS: [Duration; 3] = [ pub const ANIMATION_FRAME_INTERVAL: Duration = Duration::from_millis(16); +pub fn wayland_display_name() -> String { + std::env::var(HEARTHSPACE_WAYLAND_DISPLAY_ENV) + .or_else(|_| std::env::var(WAYLAND_DISPLAY_ENV)) + .unwrap_or_else(|_| WAYLAND_DISPLAY_NAME.to_string()) +} + /// Background dot grid. The dots live in canvas space, so they pan and zoom with /// the viewport and frame canvas movement even when no windows are open. /// `BACKGROUND_DOT_SPACING` is the (intentionally large) gap between dots in diff --git a/src/shell/app_catalog.rs b/src/shell/app_catalog.rs index 2c9194c..6268e16 100644 --- a/src/shell/app_catalog.rs +++ b/src/shell/app_catalog.rs @@ -554,7 +554,8 @@ fn xdg_config_dirs() -> Vec { } pub fn spawn_argv(argv: &[String]) -> Result<(), String> { - spawn_argv_with_wayland_display(argv, crate::config::WAYLAND_DISPLAY_NAME) + let wayland_display = crate::config::wayland_display_name(); + spawn_argv_with_wayland_display(argv, &wayland_display) } pub fn spawn_argv_with_wayland_display( -- 2.51.2 From 0717bda97ee28b27323ef83825a4a7b8c4248857 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:07:37 -0700 Subject: [PATCH 08/28] test: cover session settings launcher entry --- src/shell/app_catalog.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/shell/app_catalog.rs b/src/shell/app_catalog.rs index 6268e16..2352747 100644 --- a/src/shell/app_catalog.rs +++ b/src/shell/app_catalog.rs @@ -825,4 +825,34 @@ mod tests { assert_eq!(catalog.search("settings", 1)[0].id, app.id); assert_eq!(app.launch_argv().unwrap(), vec!["hearthspace-settings"]); } + + #[test] + fn session_settings_desktop_file_is_searchable_and_launchable() { + let dir = tempfile::tempdir().unwrap(); + let apps_dir = dir.path().join("share/applications"); + fs::create_dir_all(&apps_dir).unwrap(); + fs::write( + apps_dir.join("hearthspace-settings.desktop"), + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Hearthspace Settings\n\ + Comment=Configure Hearthspace\n\ + Exec=\"/repo/scripts/dev-run-app\" hearthspace-settings\n\ + Categories=Settings;System;\n\ + Keywords=hearthspace;settings;config;preferences;\n\ + X-Hearthspace-Dev=true\n", + ) + .unwrap(); + + let catalog = AppCatalog::load_from_data_dirs(vec![dir.path().join("share")]); + + let app = catalog.app_by_id("hearthspace-settings.desktop").unwrap(); + assert_eq!(app.name, "Hearthspace Settings"); + assert_eq!(catalog.search("hearthspace", 1)[0].id, app.id); + assert_eq!(catalog.search("preferences", 1)[0].id, app.id); + assert_eq!( + app.launch_argv().unwrap(), + vec!["/repo/scripts/dev-run-app", "hearthspace-settings"] + ); + } } -- 2.51.2 From bf7ae18328b2a703ac175ef86172824f98f48919 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:15:48 -0700 Subject: [PATCH 09/28] fix: pass shell command socket in sessions --- src/bin/hearthspace-session.rs | 7 +++++++ src/compositor/shell_integration.rs | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index a8f4064..9ce8d67 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -74,6 +74,7 @@ struct SessionPaths { applications_dir: PathBuf, settings_socket: PathBuf, session_socket: PathBuf, + shell_command_socket: PathBuf, } #[derive(Debug)] @@ -188,6 +189,7 @@ fn create_session_paths() -> Result> { runtime_dir, settings_socket: session_dir.join("settings.sock"), session_socket: session_dir.join("session.sock"), + shell_command_socket: session_dir.join("shell-command.sock"), session_dir, applications_dir, }) @@ -205,6 +207,7 @@ fn create_session_env( let session_dir = path_to_string(&paths.session_dir)?; let settings_socket = path_to_string(&paths.settings_socket)?; let session_socket = path_to_string(&paths.session_socket)?; + let shell_command_socket = path_to_string(&paths.shell_command_socket)?; let mut vars = vec![ ("WAYLAND_DISPLAY".to_string(), wayland_display.clone()), ( @@ -218,6 +221,10 @@ fn create_session_env( settings_socket, ), ("HEARTHSPACE_SESSION_SOCKET".to_string(), session_socket), + ( + hearthspace::config::SHELL_COMMAND_SOCKET_ENV.to_string(), + shell_command_socket, + ), ("RUST_LOG".to_string(), args.log_level.clone()), ("XDG_DATA_DIRS".to_string(), xdg_data_dirs(&session_dir)?), ]; diff --git a/src/compositor/shell_integration.rs b/src/compositor/shell_integration.rs index f32e923..bccae88 100644 --- a/src/compositor/shell_integration.rs +++ b/src/compositor/shell_integration.rs @@ -28,6 +28,10 @@ use crate::{ use super::{App, CalloopData}; pub(super) fn command_socket_path() -> PathBuf { + if let Some(socket_path) = env::var_os(SHELL_COMMAND_SOCKET_ENV) { + return PathBuf::from(socket_path); + } + runtime_path(SHELL_COMMAND_SOCKET_NAME) } -- 2.51.2 From cc8b16bed7c18203f204f7791003791c320704a5 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:24:23 -0700 Subject: [PATCH 10/28] fix: launch dev settings through session entry --- scripts/dev-run-app | 4 +-- src/bin/hearthspace-session.rs | 5 +++- src/shell/app_catalog.rs | 53 ++++++++++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/scripts/dev-run-app b/scripts/dev-run-app index 8aee15c..df99667 100755 --- a/scripts/dev-run-app +++ b/scripts/dev-run-app @@ -6,8 +6,6 @@ REPO_ROOT="${HEARTHSPACE_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" cd "$REPO_ROOT" -if [[ ! -x "target/debug/$APP" ]]; then - cargo build --bin "$APP" -fi +cargo build --bin "$APP" exec "$REPO_ROOT/target/debug/$APP" diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 9ce8d67..32349ed 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -272,10 +272,13 @@ fn generate_dev_desktop_entries(paths: &SessionPaths) -> Result<(), Box &'static [&'static str] { + &[ + "XDG_CURRENT_DESKTOP", + "DESKTOP_SESSION", + "HEARTHSPACE_SETTINGS_SOCKET", + "HEARTHSPACE_SESSION_SOCKET", + "HEARTHSPACE_REPO_ROOT", + ] +} + fn resolve_launch_program(program: &str) -> PathBuf { let path = Path::new(program); if program != SETTINGS_APP_BINARY || path.components().count() != 1 { @@ -832,7 +847,7 @@ mod tests { let apps_dir = dir.path().join("share/applications"); fs::create_dir_all(&apps_dir).unwrap(); fs::write( - apps_dir.join("hearthspace-settings.desktop"), + apps_dir.join("dev.hearthspace.Settings.desktop"), "[Desktop Entry]\n\ Type=Application\n\ Name=Hearthspace Settings\n\ @@ -840,13 +855,14 @@ mod tests { Exec=\"/repo/scripts/dev-run-app\" hearthspace-settings\n\ Categories=Settings;System;\n\ Keywords=hearthspace;settings;config;preferences;\n\ + OnlyShowIn=Hearthspace;\n\ X-Hearthspace-Dev=true\n", ) .unwrap(); let catalog = AppCatalog::load_from_data_dirs(vec![dir.path().join("share")]); - let app = catalog.app_by_id("hearthspace-settings.desktop").unwrap(); + let app = catalog.app_by_id("dev.hearthspace.Settings.desktop").unwrap(); assert_eq!(app.name, "Hearthspace Settings"); assert_eq!(catalog.search("hearthspace", 1)[0].id, app.id); assert_eq!(catalog.search("preferences", 1)[0].id, app.id); @@ -855,4 +871,37 @@ mod tests { vec!["/repo/scripts/dev-run-app", "hearthspace-settings"] ); } + + #[test] + fn session_settings_entry_overrides_source_tree_entry() { + let session = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + let write = |dir: &Path, exec: &str| { + let apps_dir = dir.join("applications"); + fs::create_dir_all(&apps_dir).unwrap(); + fs::write( + apps_dir.join("dev.hearthspace.Settings.desktop"), + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=Hearthspace Settings\n\ + Exec={exec}\n\ + Categories=Settings;\n" + ), + ) + .unwrap(); + }; + write(session.path(), "\"/repo/scripts/dev-run-app\" hearthspace-settings"); + write(source.path(), "hearthspace-settings"); + + let catalog = AppCatalog::load_from_data_dirs(vec![ + session.path().to_path_buf(), + source.path().to_path_buf(), + ]); + + assert_eq!( + catalog.search("settings", 1)[0].launch_argv().unwrap(), + vec!["/repo/scripts/dev-run-app", "hearthspace-settings"] + ); + } } -- 2.51.2 From 7e65549de8e61156c1a0713bc2feb61edc25d3ea Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:28:41 -0700 Subject: [PATCH 11/28] fix: expose session applications data dir --- src/bin/hearthspace-session.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 32349ed..329dfd2 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -226,7 +226,10 @@ fn create_session_env( shell_command_socket, ), ("RUST_LOG".to_string(), args.log_level.clone()), - ("XDG_DATA_DIRS".to_string(), xdg_data_dirs(&session_dir)?), + ( + "XDG_DATA_DIRS".to_string(), + xdg_data_dirs(&path_to_string(&paths.session_dir.join("share"))?)?, + ), ]; if mode == RunMode::DevCargo { @@ -250,8 +253,8 @@ fn path_to_string(path: &Path) -> Result> { .to_string()) } -fn xdg_data_dirs(session_dir: &str) -> Result> { - let mut dirs = vec![PathBuf::from(session_dir)]; +fn xdg_data_dirs(session_share_dir: &str) -> Result> { + let mut dirs = vec![PathBuf::from(session_share_dir)]; if let Some(existing) = env::var_os("XDG_DATA_DIRS") { dirs.extend(env::split_paths(&existing)); } else { -- 2.51.2 From 84cd6d6bebc41d1a5ad31bbb3f2a34b5d52a9cf1 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:31:58 -0700 Subject: [PATCH 12/28] chore: define workspace root --- Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 8da0677..2fa0259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,14 @@ rust-version = "1.93.1" license = "Apache-2.0" default-run = "hearthspace-session" +[workspace] +resolver = "2" +members = [ + ".", + "crates/waydriver-hearthspace", +] +default-members = ["."] + [features] default = ["winit"] e2e = ["dep:waydriver", "dep:waydriver-hearthspace"] -- 2.51.2 From ae8ff514f9fa096c89d5bb5efce83a31f4684491 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:36:39 -0700 Subject: [PATCH 13/28] refactor: extract settings binaries into crates --- Cargo.lock | 18 +++++++++++++++++ Cargo.toml | 2 ++ crates/hearthspace-settings/Cargo.toml | 16 +++++++++++++++ .../hearthspace-settings/src/main.rs | 0 crates/hearthspace-settingsd/Cargo.toml | 20 +++++++++++++++++++ .../hearthspace-settingsd/src/main.rs | 0 scripts/dev-run-app | 2 +- src/bin/hearthspace-session.rs | 19 +++++++++++++----- 8 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 crates/hearthspace-settings/Cargo.toml rename src/bin/hearthspace-settings.rs => crates/hearthspace-settings/src/main.rs (100%) create mode 100644 crates/hearthspace-settingsd/Cargo.toml rename src/bin/hearthspace-settingsd.rs => crates/hearthspace-settingsd/src/main.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index bdbd940..453e87d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2291,6 +2291,24 @@ dependencies = [ "xilem", ] +[[package]] +name = "hearthspace-settings" +version = "0.1.0" +dependencies = [ + "hearthspace", + "xilem", +] + +[[package]] +name = "hearthspace-settingsd" +version = "0.1.0" +dependencies = [ + "hearthspace", + "tempfile", + "tracing", + "tracing-subscriber", +] + [[package]] name = "heck" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 2fa0259..7036062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ default-run = "hearthspace-session" resolver = "2" members = [ ".", + "crates/hearthspace-settings", + "crates/hearthspace-settingsd", "crates/waydriver-hearthspace", ] default-members = ["."] diff --git a/crates/hearthspace-settings/Cargo.toml b/crates/hearthspace-settings/Cargo.toml new file mode 100644 index 0000000..d6e03e9 --- /dev/null +++ b/crates/hearthspace-settings/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "hearthspace-settings" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +hearthspace = { path = "../.." } +xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/src/bin/hearthspace-settings.rs b/crates/hearthspace-settings/src/main.rs similarity index 100% rename from src/bin/hearthspace-settings.rs rename to crates/hearthspace-settings/src/main.rs diff --git a/crates/hearthspace-settingsd/Cargo.toml b/crates/hearthspace-settingsd/Cargo.toml new file mode 100644 index 0000000..1c3bbbb --- /dev/null +++ b/crates/hearthspace-settingsd/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hearthspace-settingsd" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +hearthspace = { path = "../.." } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3" + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/src/bin/hearthspace-settingsd.rs b/crates/hearthspace-settingsd/src/main.rs similarity index 100% rename from src/bin/hearthspace-settingsd.rs rename to crates/hearthspace-settingsd/src/main.rs diff --git a/scripts/dev-run-app b/scripts/dev-run-app index df99667..5a404ba 100755 --- a/scripts/dev-run-app +++ b/scripts/dev-run-app @@ -6,6 +6,6 @@ REPO_ROOT="${HEARTHSPACE_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" cd "$REPO_ROOT" -cargo build --bin "$APP" +cargo build -p "$APP" exec "$REPO_ROOT/target/debug/$APP" diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 329dfd2..53d40d8 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -491,7 +491,12 @@ impl Supervisor { .then(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); match self.mode { RunMode::DevCargo => { - let mut args = vec!["run".to_string(), "--bin".to_string(), binary.to_string()]; + let mut args = vec!["run".to_string()]; + if cargo_package_exists(binary) { + args.extend(["-p".to_string(), binary.to_string()]); + } else { + args.extend(["--bin".to_string(), binary.to_string()]); + } args.push("--".to_string()); args.extend(binary_args.iter().map(|arg| (*arg).to_string())); ProcessSpec { @@ -531,11 +536,11 @@ impl Supervisor { args: vec![ "watch".to_string(), "-w".to_string(), - "src/bin/hearthspace-settings.rs".to_string(), + "crates/hearthspace-settings".to_string(), "-w".to_string(), "src/settings".to_string(), "-x".to_string(), - "build --bin hearthspace-settings".to_string(), + "build -p hearthspace-settings".to_string(), ], envs, current_dir, @@ -547,13 +552,13 @@ impl Supervisor { program: "watchexec".to_string(), args: vec![ "-w".to_string(), - "src/bin/hearthspace-settings.rs".to_string(), + "crates/hearthspace-settings".to_string(), "-w".to_string(), "src/settings".to_string(), "--".to_string(), "cargo".to_string(), "build".to_string(), - "--bin".to_string(), + "-p".to_string(), "hearthspace-settings".to_string(), ], envs, @@ -564,6 +569,10 @@ impl Supervisor { } } +fn cargo_package_exists(package: &str) -> bool { + matches!(package, "hearthspace-settings" | "hearthspace-settingsd") +} + impl ManagedProcess { fn spawn(spec: ProcessSpec, max_restarts: usize) -> Result> { let child = spawn_child(&spec)?; -- 2.51.2 From eb916b92615a50af579d428da90b0901935c59a8 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:38:52 -0700 Subject: [PATCH 14/28] refactor: extract shared session constants --- Cargo.lock | 5 +++++ Cargo.toml | 2 ++ crates/hearthspace-common/Cargo.toml | 12 ++++++++++++ crates/hearthspace-common/src/lib.rs | 19 ++++++++++++++++++ src/bin/hearthspace-session.rs | 29 ++++++++++++++++++---------- src/config.rs | 16 ++++----------- src/settings/mod.rs | 3 +-- src/shell/app_catalog.rs | 6 +++--- 8 files changed, 65 insertions(+), 27 deletions(-) create mode 100644 crates/hearthspace-common/Cargo.toml create mode 100644 crates/hearthspace-common/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 453e87d..2996d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2269,6 +2269,7 @@ dependencies = [ "ctrlc", "fontique", "gtk4", + "hearthspace-common", "image", "masonry", "naga", @@ -2291,6 +2292,10 @@ dependencies = [ "xilem", ] +[[package]] +name = "hearthspace-common" +version = "0.1.0" + [[package]] name = "hearthspace-settings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7036062..611fc12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ default-run = "hearthspace-session" resolver = "2" members = [ ".", + "crates/hearthspace-common", "crates/hearthspace-settings", "crates/hearthspace-settingsd", "crates/waydriver-hearthspace", @@ -39,6 +40,7 @@ calloop = { version = "0.14.4", features = ["signals"] } clap = { version = "4", features = ["derive"] } ctrlc = "3" gtk = { package = "gtk4", version = "0.11", optional = true } +hearthspace-common = { path = "crates/hearthspace-common" } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds # `WindowOptionsExtLinux::with_name` so a Xilem window can advertise a Wayland diff --git a/crates/hearthspace-common/Cargo.toml b/crates/hearthspace-common/Cargo.toml new file mode 100644 index 0000000..a077da2 --- /dev/null +++ b/crates/hearthspace-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "hearthspace-common" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/crates/hearthspace-common/src/lib.rs b/crates/hearthspace-common/src/lib.rs new file mode 100644 index 0000000..a0136f4 --- /dev/null +++ b/crates/hearthspace-common/src/lib.rs @@ -0,0 +1,19 @@ +pub const WAYLAND_DISPLAY_NAME: &str = "wayland-99"; +pub const WAYLAND_DISPLAY_ENV: &str = "WAYLAND_DISPLAY"; +pub const HEARTHSPACE_WAYLAND_DISPLAY_ENV: &str = "HEARTHSPACE_WAYLAND_DISPLAY"; +pub const SHELL_COMMAND_SOCKET_NAME: &str = "hearthspace-shell.sock"; +pub const SHELL_COMMAND_SOCKET_ENV: &str = "HEARTHSPACE_COMMAND_SOCKET"; +pub const LOG_FILE_ENV: &str = "HEARTHSPACE_LOG_FILE"; +pub const SETTINGS_SOCKET_ENV: &str = "HEARTHSPACE_SETTINGS_SOCKET"; +pub const SESSION_SOCKET_ENV: &str = "HEARTHSPACE_SESSION_SOCKET"; +pub const SESSION_DIR_ENV: &str = "HEARTHSPACE_SESSION_DIR"; +pub const REPO_ROOT_ENV: &str = "HEARTHSPACE_REPO_ROOT"; +pub const DEV_ENV: &str = "HEARTHSPACE_DEV"; +pub const CURRENT_DESKTOP: &str = "Hearthspace"; +pub const DESKTOP_SESSION: &str = "hearthspace"; + +pub fn wayland_display_name() -> String { + std::env::var(HEARTHSPACE_WAYLAND_DISPLAY_ENV) + .or_else(|_| std::env::var(WAYLAND_DISPLAY_ENV)) + .unwrap_or_else(|_| WAYLAND_DISPLAY_NAME.to_string()) +} diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 53d40d8..8e7c867 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -212,17 +212,26 @@ fn create_session_env( ("WAYLAND_DISPLAY".to_string(), wayland_display.clone()), ( "XDG_CURRENT_DESKTOP".to_string(), - "Hearthspace".to_string(), + hearthspace_common::CURRENT_DESKTOP.to_string(), ), - ("DESKTOP_SESSION".to_string(), "hearthspace".to_string()), - ("HEARTHSPACE_SESSION_DIR".to_string(), session_dir.clone()), ( - "HEARTHSPACE_SETTINGS_SOCKET".to_string(), + "DESKTOP_SESSION".to_string(), + hearthspace_common::DESKTOP_SESSION.to_string(), + ), + ( + hearthspace_common::SESSION_DIR_ENV.to_string(), + session_dir.clone(), + ), + ( + hearthspace_common::SETTINGS_SOCKET_ENV.to_string(), settings_socket, ), - ("HEARTHSPACE_SESSION_SOCKET".to_string(), session_socket), ( - hearthspace::config::SHELL_COMMAND_SOCKET_ENV.to_string(), + hearthspace_common::SESSION_SOCKET_ENV.to_string(), + session_socket, + ), + ( + hearthspace_common::SHELL_COMMAND_SOCKET_ENV.to_string(), shell_command_socket, ), ("RUST_LOG".to_string(), args.log_level.clone()), @@ -233,9 +242,9 @@ fn create_session_env( ]; if mode == RunMode::DevCargo { - vars.push(("HEARTHSPACE_DEV".to_string(), "1".to_string())); + vars.push((hearthspace_common::DEV_ENV.to_string(), "1".to_string())); vars.push(( - "HEARTHSPACE_REPO_ROOT".to_string(), + hearthspace_common::REPO_ROOT_ENV.to_string(), env!("CARGO_MANIFEST_DIR").to_string(), )); } @@ -482,7 +491,7 @@ impl Supervisor { let mut envs = self.child_env(include_client_wayland); if !include_client_wayland { envs.push(( - hearthspace::config::HEARTHSPACE_WAYLAND_DISPLAY_ENV.to_string(), + hearthspace_common::HEARTHSPACE_WAYLAND_DISPLAY_ENV.to_string(), self.session_env.wayland_display.clone(), )); } @@ -521,7 +530,7 @@ impl Supervisor { self.session_env .vars .iter() - .filter(|(key, _)| include_client_wayland || key != hearthspace::config::WAYLAND_DISPLAY_ENV) + .filter(|(key, _)| include_client_wayland || key != hearthspace_common::WAYLAND_DISPLAY_ENV) .cloned() .collect() } diff --git a/src/config.rs b/src/config.rs index fdeb247..ba99ba9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,9 @@ use std::time::Duration; -pub const WAYLAND_DISPLAY_NAME: &str = "wayland-99"; -pub const WAYLAND_DISPLAY_ENV: &str = "WAYLAND_DISPLAY"; -pub const HEARTHSPACE_WAYLAND_DISPLAY_ENV: &str = "HEARTHSPACE_WAYLAND_DISPLAY"; +pub use hearthspace_common::{ + HEARTHSPACE_WAYLAND_DISPLAY_ENV, LOG_FILE_ENV, SHELL_COMMAND_SOCKET_ENV, + SHELL_COMMAND_SOCKET_NAME, WAYLAND_DISPLAY_ENV, WAYLAND_DISPLAY_NAME, wayland_display_name, +}; pub const GTK_TEST_APP_FLAG: &str = "--gtk-test-app"; pub const SHELL_FLAG: &str = "--shell"; pub const HEADLESS_FLAG: &str = "--headless"; @@ -14,9 +15,6 @@ pub const SHELL_BAR_APP_ID: &str = "dev.hearthspace.shell-bar"; /// Wayland `app_id` of the launcher palette: a short-lived shell surface the /// shell opens just below the bar to show app-search results as a dropdown. pub const LAUNCHER_APP_ID: &str = "dev.hearthspace.launcher"; -pub const SHELL_COMMAND_SOCKET_NAME: &str = "hearthspace-shell.sock"; -pub const SHELL_COMMAND_SOCKET_ENV: &str = "HEARTHSPACE_COMMAND_SOCKET"; -pub const LOG_FILE_ENV: &str = "HEARTHSPACE_LOG_FILE"; pub const GTK_CLIENT_CONFIG_DIR_NAME: &str = "hearthspace-gtk-client-config"; pub const SCROLL_ZOOMS_FLAG: &str = "--scroll-zooms"; pub const HEADLESS_SIZE_FLAG: &str = "--headless-size"; @@ -59,12 +57,6 @@ pub const WINDOW_IDLE_THRESHOLDS: [Duration; 3] = [ pub const ANIMATION_FRAME_INTERVAL: Duration = Duration::from_millis(16); -pub fn wayland_display_name() -> String { - std::env::var(HEARTHSPACE_WAYLAND_DISPLAY_ENV) - .or_else(|_| std::env::var(WAYLAND_DISPLAY_ENV)) - .unwrap_or_else(|_| WAYLAND_DISPLAY_NAME.to_string()) -} - /// Background dot grid. The dots live in canvas space, so they pan and zoom with /// the viewport and frame canvas movement even when no windows are open. /// `BACKGROUND_DOT_SPACING` is the (intentionally large) gap between dots in diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 91d0003..10185dd 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -9,7 +9,6 @@ use std::{ const CONFIG_DIR_NAME: &str = "hearthspace"; const CONFIG_FILE_NAME: &str = "config.toml"; const SETTINGS_SOCKET_NAME: &str = "hearthspace-settingsd.sock"; -const SETTINGS_SOCKET_ENV: &str = "HEARTHSPACE_SETTINGS_SOCKET"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Settings { @@ -27,7 +26,7 @@ pub fn config_path() -> io::Result { } pub fn settings_socket_path() -> io::Result { - if let Some(socket_path) = env::var_os(SETTINGS_SOCKET_ENV) { + if let Some(socket_path) = env::var_os(hearthspace_common::SETTINGS_SOCKET_ENV) { return Ok(PathBuf::from(socket_path)); } diff --git a/src/shell/app_catalog.rs b/src/shell/app_catalog.rs index d85e8fc..debc0c1 100644 --- a/src/shell/app_catalog.rs +++ b/src/shell/app_catalog.rs @@ -596,9 +596,9 @@ fn session_environment_keys() -> &'static [&'static str] { &[ "XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", - "HEARTHSPACE_SETTINGS_SOCKET", - "HEARTHSPACE_SESSION_SOCKET", - "HEARTHSPACE_REPO_ROOT", + hearthspace_common::SETTINGS_SOCKET_ENV, + hearthspace_common::SESSION_SOCKET_ENV, + hearthspace_common::REPO_ROOT_ENV, ] } -- 2.51.2 From ff3d61af06e23ba5b8611c0e985ad923c816bcd7 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:42:43 -0700 Subject: [PATCH 15/28] refactor: extract settings ipc crate --- Cargo.lock | 13 ++- Cargo.toml | 1 + crates/hearthspace-common/src/lib.rs | 20 ++++ crates/hearthspace-ipc/Cargo.toml | 15 +++ .../hearthspace-ipc/src/lib.rs | 100 +----------------- crates/hearthspace-settings/Cargo.toml | 3 +- crates/hearthspace-settings/src/main.rs | 4 +- crates/hearthspace-settingsd/Cargo.toml | 3 +- crates/hearthspace-settingsd/src/main.rs | 63 ++++++++++- src/bin/hearthspace-session.rs | 8 +- src/lib.rs | 1 - 11 files changed, 121 insertions(+), 110 deletions(-) create mode 100644 crates/hearthspace-ipc/Cargo.toml rename src/settings/mod.rs => crates/hearthspace-ipc/src/lib.rs (51%) diff --git a/Cargo.lock b/Cargo.lock index 2996d7f..6eb45e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2296,11 +2296,19 @@ dependencies = [ name = "hearthspace-common" version = "0.1.0" +[[package]] +name = "hearthspace-ipc" +version = "0.1.0" +dependencies = [ + "hearthspace-common", +] + [[package]] name = "hearthspace-settings" version = "0.1.0" dependencies = [ - "hearthspace", + "hearthspace-common", + "hearthspace-ipc", "xilem", ] @@ -2308,7 +2316,8 @@ dependencies = [ name = "hearthspace-settingsd" version = "0.1.0" dependencies = [ - "hearthspace", + "hearthspace-common", + "hearthspace-ipc", "tempfile", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 611fc12..d6c61e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ resolver = "2" members = [ ".", "crates/hearthspace-common", + "crates/hearthspace-ipc", "crates/hearthspace-settings", "crates/hearthspace-settingsd", "crates/waydriver-hearthspace", diff --git a/crates/hearthspace-common/src/lib.rs b/crates/hearthspace-common/src/lib.rs index a0136f4..b176078 100644 --- a/crates/hearthspace-common/src/lib.rs +++ b/crates/hearthspace-common/src/lib.rs @@ -11,6 +11,26 @@ pub const REPO_ROOT_ENV: &str = "HEARTHSPACE_REPO_ROOT"; pub const DEV_ENV: &str = "HEARTHSPACE_DEV"; pub const CURRENT_DESKTOP: &str = "Hearthspace"; pub const DESKTOP_SESSION: &str = "hearthspace"; +const CONFIG_DIR_NAME: &str = "hearthspace"; +const CONFIG_FILE_NAME: &str = "config.toml"; + +pub fn config_path() -> std::io::Result { + Ok(config_home()?.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME)) +} + +fn config_home() -> std::io::Result { + if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") { + return Ok(std::path::PathBuf::from(xdg_config_home)); + } + + let home = std::env::var_os("HOME").ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "neither XDG_CONFIG_HOME nor HOME is set", + ) + })?; + Ok(std::path::PathBuf::from(home).join(".config")) +} pub fn wayland_display_name() -> String { std::env::var(HEARTHSPACE_WAYLAND_DISPLAY_ENV) diff --git a/crates/hearthspace-ipc/Cargo.toml b/crates/hearthspace-ipc/Cargo.toml new file mode 100644 index 0000000..b26cc63 --- /dev/null +++ b/crates/hearthspace-ipc/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hearthspace-ipc" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +hearthspace-common = { path = "../hearthspace-common" } + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/src/settings/mod.rs b/crates/hearthspace-ipc/src/lib.rs similarity index 51% rename from src/settings/mod.rs rename to crates/hearthspace-ipc/src/lib.rs index 10185dd..e3af913 100644 --- a/src/settings/mod.rs +++ b/crates/hearthspace-ipc/src/lib.rs @@ -1,15 +1,11 @@ use std::{ - env, fs, io, + env, io, io::{Read, Write}, net::Shutdown, os::unix::net::UnixStream, - path::{Path, PathBuf}, + path::PathBuf, }; -const CONFIG_DIR_NAME: &str = "hearthspace"; -const CONFIG_FILE_NAME: &str = "config.toml"; -const SETTINGS_SOCKET_NAME: &str = "hearthspace-settingsd.sock"; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct Settings { pub test: bool, @@ -21,10 +17,6 @@ pub enum SettingsRequest { SetTest(bool), } -pub fn config_path() -> io::Result { - Ok(config_home()?.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME)) -} - pub fn settings_socket_path() -> io::Result { if let Some(socket_path) = env::var_os(hearthspace_common::SETTINGS_SOCKET_ENV) { return Ok(PathBuf::from(socket_path)); @@ -32,31 +24,7 @@ pub fn settings_socket_path() -> io::Result { let runtime_dir = env::var_os("XDG_RUNTIME_DIR") .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))?; - Ok(PathBuf::from(runtime_dir).join(SETTINGS_SOCKET_NAME)) -} - -pub fn load_settings() -> io::Result { - load_settings_from_path(&config_path()?) -} - -pub fn load_settings_from_path(path: impl AsRef) -> io::Result { - match fs::read_to_string(path) { - Ok(contents) => parse_settings(&contents), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Settings::default()), - Err(error) => Err(error), - } -} - -pub fn save_settings(settings: Settings) -> io::Result<()> { - save_settings_to_path(settings, config_path()?) -} - -pub fn save_settings_to_path(settings: Settings, path: impl AsRef) -> io::Result<()> { - let path = path.as_ref(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, format_settings(settings)) + Ok(PathBuf::from(runtime_dir).join("hearthspace-settingsd.sock")) } pub fn client_get_settings() -> io::Result { @@ -96,51 +64,6 @@ fn send_settings_request(request: &str) -> io::Result { parse_settings_response(&response) } -fn config_home() -> io::Result { - if let Some(xdg_config_home) = env::var_os("XDG_CONFIG_HOME") { - return Ok(PathBuf::from(xdg_config_home)); - } - - let home = env::var_os("HOME").ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "neither XDG_CONFIG_HOME nor HOME is set", - ) - })?; - Ok(PathBuf::from(home).join(".config")) -} - -fn parse_settings(contents: &str) -> io::Result { - let mut settings = Settings::default(); - for raw_line in contents.lines() { - let line = raw_line.split('#').next().unwrap_or_default().trim(); - if line.is_empty() || line.starts_with('[') { - continue; - } - - let Some((key, value)) = line.split_once('=') else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid settings line: {line:?}"), - )); - }; - - if key.trim() == "test" { - settings.test = parse_bool(value.trim()).map_err(|error| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid test setting: {error}"), - ) - })?; - } - } - Ok(settings) -} - -fn format_settings(settings: Settings) -> String { - format!("test = {}\n", settings.test) -} - fn parse_settings_response(response: &str) -> io::Result { let line = response.trim(); let parts = line.split_whitespace().collect::>(); @@ -168,23 +91,6 @@ fn parse_bool(value: &str) -> Result { mod tests { use super::*; - #[test] - fn parses_test_setting() { - assert_eq!( - parse_settings("test = true\n").unwrap(), - Settings { test: true } - ); - assert_eq!( - parse_settings("test = false\n").unwrap(), - Settings { test: false } - ); - } - - #[test] - fn formats_test_setting() { - assert_eq!(format_settings(Settings { test: true }), "test = true\n"); - } - #[test] fn parses_ipc_requests() { assert_eq!(parse_settings_request("get\n"), Ok(SettingsRequest::Get)); diff --git a/crates/hearthspace-settings/Cargo.toml b/crates/hearthspace-settings/Cargo.toml index d6e03e9..880f7c5 100644 --- a/crates/hearthspace-settings/Cargo.toml +++ b/crates/hearthspace-settings/Cargo.toml @@ -6,7 +6,8 @@ rust-version = "1.93.1" license = "Apache-2.0" [dependencies] -hearthspace = { path = "../.." } +hearthspace-common = { path = "../hearthspace-common" } +hearthspace-ipc = { path = "../hearthspace-ipc" } xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } [lints.rust] diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 38100b4..8c0fd2c 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -1,4 +1,4 @@ -use hearthspace::settings::{Settings, client_get_settings, client_set_test, config_path}; +use hearthspace_ipc::{Settings, client_get_settings, client_set_test}; use xilem::{ EventLoop, WidgetView, WindowOptions, Xilem, dpi::LogicalSize, @@ -36,7 +36,7 @@ fn initial_state() -> SettingsApp { ), }; - let config_label = match config_path() { + let config_label = match hearthspace_common::config_path() { Ok(path) => format!("Config: {}", path.display()), Err(error) => format!("Config path unavailable: {error}"), }; diff --git a/crates/hearthspace-settingsd/Cargo.toml b/crates/hearthspace-settingsd/Cargo.toml index 1c3bbbb..4b8b30d 100644 --- a/crates/hearthspace-settingsd/Cargo.toml +++ b/crates/hearthspace-settingsd/Cargo.toml @@ -6,7 +6,8 @@ rust-version = "1.93.1" license = "Apache-2.0" [dependencies] -hearthspace = { path = "../.." } +hearthspace-common = { path = "../hearthspace-common" } +hearthspace-ipc = { path = "../hearthspace-ipc" } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/crates/hearthspace-settingsd/src/main.rs b/crates/hearthspace-settingsd/src/main.rs index a6a4b44..bf08cb8 100644 --- a/crates/hearthspace-settingsd/src/main.rs +++ b/crates/hearthspace-settingsd/src/main.rs @@ -5,9 +5,9 @@ use std::{ path::Path, }; -use hearthspace::settings::{ - SettingsRequest, config_path, error_response, load_settings_from_path, parse_settings_request, - save_settings_to_path, settings_response, settings_socket_path, +use hearthspace_ipc::{ + Settings, SettingsRequest, error_response, parse_settings_request, settings_response, + settings_socket_path, }; use tracing::{error, info, warn}; @@ -61,7 +61,7 @@ fn bind_settings_socket() -> io::Result { } fn handle_connection(stream: UnixStream) -> io::Result<()> { - handle_connection_with_config_path(stream, &config_path()?) + handle_connection_with_config_path(stream, &hearthspace_common::config_path()?) } fn handle_connection_with_config_path( @@ -94,6 +94,61 @@ fn handle_request(request: SettingsRequest, config_path: &Path) -> io::Result) -> io::Result { + match fs::read_to_string(path) { + Ok(contents) => parse_settings(&contents), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Settings::default()), + Err(error) => Err(error), + } +} + +fn save_settings_to_path(settings: Settings, path: impl AsRef) -> io::Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format_settings(settings)) +} + +fn parse_settings(contents: &str) -> io::Result { + let mut settings = Settings::default(); + for raw_line in contents.lines() { + let line = raw_line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() || line.starts_with('[') { + continue; + } + + let Some((key, value)) = line.split_once('=') else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid settings line: {line:?}"), + )); + }; + + if key.trim() == "test" { + settings.test = parse_bool(value.trim()).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid test setting: {error}"), + ) + })?; + } + } + Ok(settings) +} + +fn format_settings(settings: Settings) -> String { + format!("test = {}\n", settings.test) +} + +fn parse_bool(value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(format!("expected true or false, got {value:?}")), + } +} + #[cfg(test)] mod tests { use std::{ diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 8e7c867..7c1ce10 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -547,7 +547,9 @@ impl Supervisor { "-w".to_string(), "crates/hearthspace-settings".to_string(), "-w".to_string(), - "src/settings".to_string(), + "crates/hearthspace-ipc".to_string(), + "-w".to_string(), + "crates/hearthspace-common".to_string(), "-x".to_string(), "build -p hearthspace-settings".to_string(), ], @@ -563,7 +565,9 @@ impl Supervisor { "-w".to_string(), "crates/hearthspace-settings".to_string(), "-w".to_string(), - "src/settings".to_string(), + "crates/hearthspace-ipc".to_string(), + "-w".to_string(), + "crates/hearthspace-common".to_string(), "--".to_string(), "cargo".to_string(), "build".to_string(), diff --git a/src/lib.rs b/src/lib.rs index dbfadcb..90ecc32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod accessibility; pub mod compositor; pub mod config; pub mod geometry; -pub mod settings; pub mod shell; #[cfg(feature = "test-apps")] pub mod test_apps; -- 2.51.2 From 19dbb895dcc589ec7307fb1889f51610ef24ce10 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:47:03 -0700 Subject: [PATCH 16/28] refactor: extract shell crate --- Cargo.lock | 13 +++++++++++ Cargo.toml | 2 ++ crates/hearthspace-common/src/lib.rs | 3 +++ crates/hearthspace-shell/Cargo.toml | 22 +++++++++++++++++++ .../hearthspace-shell/src}/app_catalog.rs | 19 +++++++++------- .../hearthspace-shell/src}/command.rs | 0 .../hearthspace-shell/src/lib.rs | 0 .../hearthspace-shell/src/main.rs | 4 ++-- .../hearthspace-shell/src}/xilem_shell.rs | 10 ++++----- src/bin/hearthspace-session.rs | 5 ++++- src/compositor/mod.rs | 3 ++- src/compositor/shell_integration.rs | 10 ++++----- src/config.rs | 10 +++------ src/lib.rs | 1 - src/main.rs | 2 +- 15 files changed, 73 insertions(+), 31 deletions(-) create mode 100644 crates/hearthspace-shell/Cargo.toml rename {src/shell => crates/hearthspace-shell/src}/app_catalog.rs (98%) rename {src/shell => crates/hearthspace-shell/src}/command.rs (100%) rename src/shell/mod.rs => crates/hearthspace-shell/src/lib.rs (100%) rename src/bin/hearthspace-shell.rs => crates/hearthspace-shell/src/main.rs (88%) rename {src/shell => crates/hearthspace-shell/src}/xilem_shell.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index 6eb45e9..54caf4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2270,6 +2270,7 @@ dependencies = [ "fontique", "gtk4", "hearthspace-common", + "hearthspace-shell", "image", "masonry", "naga", @@ -2323,6 +2324,18 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hearthspace-shell" +version = "0.1.0" +dependencies = [ + "hearthspace-common", + "rstest", + "tempfile", + "tracing", + "tracing-subscriber", + "xilem", +] + [[package]] name = "heck" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index d6c61e5..d8481f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/hearthspace-ipc", "crates/hearthspace-settings", "crates/hearthspace-settingsd", + "crates/hearthspace-shell", "crates/waydriver-hearthspace", ] default-members = ["."] @@ -42,6 +43,7 @@ clap = { version = "4", features = ["derive"] } ctrlc = "3" gtk = { package = "gtk4", version = "0.11", optional = true } hearthspace-common = { path = "crates/hearthspace-common" } +hearthspace-shell = { path = "crates/hearthspace-shell" } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds # `WindowOptionsExtLinux::with_name` so a Xilem window can advertise a Wayland diff --git a/crates/hearthspace-common/src/lib.rs b/crates/hearthspace-common/src/lib.rs index b176078..26c04aa 100644 --- a/crates/hearthspace-common/src/lib.rs +++ b/crates/hearthspace-common/src/lib.rs @@ -3,6 +3,9 @@ pub const WAYLAND_DISPLAY_ENV: &str = "WAYLAND_DISPLAY"; pub const HEARTHSPACE_WAYLAND_DISPLAY_ENV: &str = "HEARTHSPACE_WAYLAND_DISPLAY"; pub const SHELL_COMMAND_SOCKET_NAME: &str = "hearthspace-shell.sock"; pub const SHELL_COMMAND_SOCKET_ENV: &str = "HEARTHSPACE_COMMAND_SOCKET"; +pub const SHELL_BAR_APP_ID: &str = "dev.hearthspace.shell-bar"; +pub const LAUNCHER_APP_ID: &str = "dev.hearthspace.launcher"; +pub const CONTROL_BAR_HEIGHT: i32 = 48; pub const LOG_FILE_ENV: &str = "HEARTHSPACE_LOG_FILE"; pub const SETTINGS_SOCKET_ENV: &str = "HEARTHSPACE_SETTINGS_SOCKET"; pub const SESSION_SOCKET_ENV: &str = "HEARTHSPACE_SESSION_SOCKET"; diff --git a/crates/hearthspace-shell/Cargo.toml b/crates/hearthspace-shell/Cargo.toml new file mode 100644 index 0000000..ff07e47 --- /dev/null +++ b/crates/hearthspace-shell/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "hearthspace-shell" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +hearthspace-common = { path = "../hearthspace-common" } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } + +[dev-dependencies] +rstest = "0.26" +tempfile = "3" + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/src/shell/app_catalog.rs b/crates/hearthspace-shell/src/app_catalog.rs similarity index 98% rename from src/shell/app_catalog.rs rename to crates/hearthspace-shell/src/app_catalog.rs index debc0c1..ab64078 100644 --- a/src/shell/app_catalog.rs +++ b/crates/hearthspace-shell/src/app_catalog.rs @@ -6,7 +6,6 @@ use std::{ }; use tracing::debug; -const CURRENT_DESKTOP: &str = "Hearthspace"; const SETTINGS_APP_BINARY: &str = "hearthspace-settings"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -225,10 +224,14 @@ fn xdg_data_dirs() -> Vec { ] }); dirs.extend(data_dirs); - dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")); + dirs.push(source_tree_data_dir()); dirs } +fn source_tree_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data") +} + fn desktop_files_under(dir: &Path) -> std::io::Result> { let mut paths = Vec::new(); collect_desktop_files(dir, &mut paths)?; @@ -337,7 +340,7 @@ fn only_show_in_allows(value: Option<&String>) -> bool { parse_semicolon_list(value) .iter() - .any(|desktop| desktop == CURRENT_DESKTOP) + .any(|desktop| desktop == hearthspace_common::CURRENT_DESKTOP) } fn not_show_in_allows(value: Option<&String>) -> bool { @@ -347,7 +350,7 @@ fn not_show_in_allows(value: Option<&String>) -> bool { !parse_semicolon_list(value) .iter() - .any(|desktop| desktop == CURRENT_DESKTOP) + .any(|desktop| desktop == hearthspace_common::CURRENT_DESKTOP) } fn try_exec_resolves(try_exec: &str) -> bool { @@ -554,7 +557,7 @@ fn xdg_config_dirs() -> Vec { } pub fn spawn_argv(argv: &[String]) -> Result<(), String> { - let wayland_display = crate::config::wayland_display_name(); + let wayland_display = hearthspace_common::wayland_display_name(); spawn_argv_with_wayland_display(argv, &wayland_display) } @@ -594,8 +597,8 @@ pub fn spawn_argv_with_env( fn session_environment_keys() -> &'static [&'static str] { &[ - "XDG_CURRENT_DESKTOP", - "DESKTOP_SESSION", + "XDG_CURRENT_DESKTOP", + "DESKTOP_SESSION", hearthspace_common::SETTINGS_SOCKET_ENV, hearthspace_common::SESSION_SOCKET_ENV, hearthspace_common::REPO_ROOT_ENV, @@ -830,7 +833,7 @@ mod tests { #[test] fn source_tree_settings_desktop_file_is_searchable_and_launchable() { let catalog = AppCatalog::load_from_data_dirs(vec![ - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"), + source_tree_data_dir(), ]); let app = catalog diff --git a/src/shell/command.rs b/crates/hearthspace-shell/src/command.rs similarity index 100% rename from src/shell/command.rs rename to crates/hearthspace-shell/src/command.rs diff --git a/src/shell/mod.rs b/crates/hearthspace-shell/src/lib.rs similarity index 100% rename from src/shell/mod.rs rename to crates/hearthspace-shell/src/lib.rs diff --git a/src/bin/hearthspace-shell.rs b/crates/hearthspace-shell/src/main.rs similarity index 88% rename from src/bin/hearthspace-shell.rs rename to crates/hearthspace-shell/src/main.rs index d9fa831..2a91171 100644 --- a/src/bin/hearthspace-shell.rs +++ b/crates/hearthspace-shell/src/main.rs @@ -3,12 +3,12 @@ use std::fs::OpenOptions; fn main() -> Result<(), Box> { initialize_tracing(); tracing::info!("starting Hearthspace shell"); - hearthspace::shell::xilem_shell::run() + hearthspace_shell::xilem_shell::run() } fn initialize_tracing() { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env(); - if let Some(log_path) = std::env::var_os(hearthspace::config::LOG_FILE_ENV) { + if let Some(log_path) = std::env::var_os(hearthspace_common::LOG_FILE_ENV) { let make_writer = move || { OpenOptions::new() .create(true) diff --git a/src/shell/xilem_shell.rs b/crates/hearthspace-shell/src/xilem_shell.rs similarity index 98% rename from src/shell/xilem_shell.rs rename to crates/hearthspace-shell/src/xilem_shell.rs index f58d663..3ab6788 100644 --- a/src/shell/xilem_shell.rs +++ b/crates/hearthspace-shell/src/xilem_shell.rs @@ -42,11 +42,11 @@ use xilem::{ }; use crate::{ - config::{CONTROL_BAR_HEIGHT, LAUNCHER_APP_ID, SHELL_BAR_APP_ID, SHELL_COMMAND_SOCKET_ENV}, - shell::{ - ShellCommand, - app_catalog::{AppCatalog, DesktopApp}, - }, + ShellCommand, + app_catalog::{AppCatalog, DesktopApp}, +}; +use hearthspace_common::{ + CONTROL_BAR_HEIGHT, LAUNCHER_APP_ID, SHELL_BAR_APP_ID, SHELL_COMMAND_SOCKET_ENV, }; /// Maximum number of app search results to surface at once. diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index 7c1ce10..ccb6c49 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -583,7 +583,10 @@ impl Supervisor { } fn cargo_package_exists(package: &str) -> bool { - matches!(package, "hearthspace-settings" | "hearthspace-settingsd") + matches!( + package, + "hearthspace-settings" | "hearthspace-settingsd" | "hearthspace-shell" + ) } impl ManagedProcess { diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs index b11d239..9214917 100644 --- a/src/compositor/mod.rs +++ b/src/compositor/mod.rs @@ -5,7 +5,8 @@ use std::{ sync::Arc, }; -use crate::{RunOptions, config::*, geometry::CanvasPoint, shell::app_catalog::AppCatalog}; +use crate::{RunOptions, config::*, geometry::CanvasPoint}; +use hearthspace_shell::app_catalog::AppCatalog; mod cursor; mod handlers; diff --git a/src/compositor/shell_integration.rs b/src/compositor/shell_integration.rs index bccae88..70f061b 100644 --- a/src/compositor/shell_integration.rs +++ b/src/compositor/shell_integration.rs @@ -19,10 +19,10 @@ use tracing::{debug, error, info, trace, warn}; use crate::{ config::*, geometry::CanvasPoint, - shell::{ - ShellCommand, SpawnTarget, - app_catalog::{DesktopApp, spawn_argv_with_env}, - }, +}; +use hearthspace_shell::{ + ShellCommand, SpawnTarget, + app_catalog::{DesktopApp, spawn_argv_with_env}, }; use super::{App, CalloopData}; @@ -525,7 +525,7 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::shell::SpawnTarget; + use hearthspace_shell::SpawnTarget; #[test] fn drains_only_complete_newline_terminated_commands() { diff --git a/src/config.rs b/src/config.rs index ba99ba9..611c118 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,9 @@ use std::time::Duration; pub use hearthspace_common::{ - HEARTHSPACE_WAYLAND_DISPLAY_ENV, LOG_FILE_ENV, SHELL_COMMAND_SOCKET_ENV, - SHELL_COMMAND_SOCKET_NAME, WAYLAND_DISPLAY_ENV, WAYLAND_DISPLAY_NAME, wayland_display_name, + CONTROL_BAR_HEIGHT, HEARTHSPACE_WAYLAND_DISPLAY_ENV, LAUNCHER_APP_ID, LOG_FILE_ENV, + SHELL_BAR_APP_ID, SHELL_COMMAND_SOCKET_ENV, SHELL_COMMAND_SOCKET_NAME, WAYLAND_DISPLAY_ENV, + WAYLAND_DISPLAY_NAME, wayland_display_name, }; pub const GTK_TEST_APP_FLAG: &str = "--gtk-test-app"; pub const SHELL_FLAG: &str = "--shell"; @@ -11,10 +12,6 @@ pub const TTY_FLAG: &str = "--tty"; pub const WINIT_FLAG: &str = "--winit"; pub const GTK_TEST_APP_ID: &str = "dev.hearthspace.A11yTest"; pub const GTK_TEST_APP_TITLE: &str = "Hearthspace Research Demo"; -pub const SHELL_BAR_APP_ID: &str = "dev.hearthspace.shell-bar"; -/// Wayland `app_id` of the launcher palette: a short-lived shell surface the -/// shell opens just below the bar to show app-search results as a dropdown. -pub const LAUNCHER_APP_ID: &str = "dev.hearthspace.launcher"; pub const GTK_CLIENT_CONFIG_DIR_NAME: &str = "hearthspace-gtk-client-config"; pub const SCROLL_ZOOMS_FLAG: &str = "--scroll-zooms"; pub const HEADLESS_SIZE_FLAG: &str = "--headless-size"; @@ -27,7 +24,6 @@ pub const HEADLESS_OUTPUT_HEIGHT: i32 = 720; pub const KEYBOARD_REPEAT_DELAY_MS: i32 = 600; pub const KEYBOARD_REPEAT_RATE: i32 = 25; -pub const CONTROL_BAR_HEIGHT: i32 = 48; pub const TITLE_BAR_HEIGHT: i32 = 30; pub const CLOSE_BUTTON_SIZE: i32 = 18; pub const CLOSE_BUTTON_MARGIN: i32 = 6; diff --git a/src/lib.rs b/src/lib.rs index 90ecc32..763f56b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod accessibility; pub mod compositor; pub mod config; pub mod geometry; -pub mod shell; #[cfg(feature = "test-apps")] pub mod test_apps; diff --git a/src/main.rs b/src/main.rs index dbbd816..37bd7c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,7 @@ fn main() -> Result<(), Box> { .any(|arg| arg == hearthspace::config::SHELL_FLAG) { info!("starting shell client"); - hearthspace::shell::xilem_shell::run() + hearthspace_shell::xilem_shell::run() } else { let backend = parse_backend_selection(&args)?; let headless_output_size = parse_headless_output_size(&args)?; -- 2.51.2 From b6be1ad64d3046b2bda5143fcff34afbacf3cb53 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 03:56:18 -0700 Subject: [PATCH 17/28] refactor: extract compositor crate --- Cargo.lock | 23 +++-- Cargo.toml | 77 +-------------- crates/hearthspace-common/src/lib.rs | 1 + crates/hearthspace-compositor/Cargo.toml | 96 +++++++++++++++++++ .../hearthspace-compositor/build.rs | 0 .../src}/accessibility.rs | 0 .../src/bin/hearthspace.rs | 19 ++-- .../src}/compositor/cursor.rs | 2 +- .../src}/compositor/handlers.rs | 0 .../src}/compositor/idle.rs | 0 .../src}/compositor/input.rs | 0 .../src}/compositor/masonry_titlebar.rs | 0 .../src}/compositor/mod.rs | 0 .../src}/compositor/output.rs | 0 .../src}/compositor/rendering.rs | 0 .../src}/compositor/runtime.rs | 0 .../src}/compositor/shell_integration.rs | 0 .../src}/compositor/udev.rs | 0 .../src}/compositor/udev/device.rs | 0 .../src}/compositor/udev/input.rs | 0 .../src}/compositor/viewport.rs | 0 .../src}/compositor/windows.rs | 0 .../src}/compositor/windows/geometry.rs | 0 .../src}/compositor/windows/ordering.rs | 0 .../hearthspace-compositor/src}/config.rs | 5 +- .../hearthspace-compositor/src}/geometry.rs | 0 .../hearthspace-compositor/src}/lib.rs | 0 .../hearthspace-compositor/src/main.rs | 67 +++++++------ .../src}/test_apps/gtk.rs | 0 .../src}/test_apps/mod.rs | 0 .../tests}/headless_control.rs | 6 +- .../tests}/waydriver_hearthspace.rs | 0 src/bin/hearthspace-session.rs | 7 +- 33 files changed, 177 insertions(+), 126 deletions(-) create mode 100644 crates/hearthspace-compositor/Cargo.toml rename build.rs => crates/hearthspace-compositor/build.rs (100%) rename {src => crates/hearthspace-compositor/src}/accessibility.rs (100%) rename src/main.rs => crates/hearthspace-compositor/src/bin/hearthspace.rs (93%) rename {src => crates/hearthspace-compositor/src}/compositor/cursor.rs (94%) rename {src => crates/hearthspace-compositor/src}/compositor/handlers.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/idle.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/input.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/masonry_titlebar.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/mod.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/output.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/rendering.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/runtime.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/shell_integration.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/udev.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/udev/device.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/udev/input.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/viewport.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/windows.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/windows/geometry.rs (100%) rename {src => crates/hearthspace-compositor/src}/compositor/windows/ordering.rs (100%) rename {src => crates/hearthspace-compositor/src}/config.rs (93%) rename {src => crates/hearthspace-compositor/src}/geometry.rs (100%) rename {src => crates/hearthspace-compositor/src}/lib.rs (100%) rename src/bin/hearthspace-compositor.rs => crates/hearthspace-compositor/src/main.rs (67%) rename {src => crates/hearthspace-compositor/src}/test_apps/gtk.rs (100%) rename {src => crates/hearthspace-compositor/src}/test_apps/mod.rs (100%) rename {tests => crates/hearthspace-compositor/tests}/headless_control.rs (97%) rename {tests => crates/hearthspace-compositor/tests}/waydriver_hearthspace.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 54caf4c..fb16755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2259,14 +2259,16 @@ dependencies = [ ] [[package]] -name = "hearthspace" +name = "hearthspace-common" +version = "0.1.0" + +[[package]] +name = "hearthspace-compositor" version = "0.1.0" dependencies = [ "async-io", "atspi 0.30.0", "calloop 0.14.4", - "clap", - "ctrlc", "fontique", "gtk4", "hearthspace-common", @@ -2274,7 +2276,6 @@ dependencies = [ "image", "masonry", "naga", - "nix 0.30.1", "png", "proptest", "rstest", @@ -2288,20 +2289,28 @@ dependencies = [ "waydriver-hearthspace", "wayland-protocols", "wayland-server", - "which", "xcursor", "xilem", ] [[package]] -name = "hearthspace-common" +name = "hearthspace-ipc" version = "0.1.0" +dependencies = [ + "hearthspace-common", +] [[package]] -name = "hearthspace-ipc" +name = "hearthspace-session" version = "0.1.0" dependencies = [ + "clap", + "ctrlc", "hearthspace-common", + "nix 0.30.1", + "tracing", + "tracing-subscriber", + "which", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d8481f9..32ef7dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "hearthspace" +name = "hearthspace-session" version = "0.1.0" edition = "2024" rust-version = "1.93.1" @@ -10,6 +10,7 @@ default-run = "hearthspace-session" resolver = "2" members = [ ".", + "crates/hearthspace-compositor", "crates/hearthspace-common", "crates/hearthspace-ipc", "crates/hearthspace-settings", @@ -19,88 +20,14 @@ members = [ ] default-members = ["."] -[features] -default = ["winit"] -e2e = ["dep:waydriver", "dep:waydriver-hearthspace"] -test-apps = ["dep:gtk"] -udev = [ - "smithay/backend_drm", - "smithay/backend_gbm", - "smithay/backend_libinput", - "smithay/backend_session_libseat", - "smithay/backend_udev", - # Native mode still renders through one primary GPU, but Smithay's dmabuf - # import path needs renderer_multi available for client buffers. - "smithay/renderer_multi", -] -winit = ["smithay/backend_winit"] - [dependencies] -async-io = "2.3.3" -atspi = { version = "0.30.0", features = ["zbus"] } -calloop = { version = "0.14.4", features = ["signals"] } clap = { version = "4", features = ["derive"] } ctrlc = "3" -gtk = { package = "gtk4", version = "0.11", optional = true } hearthspace-common = { path = "crates/hearthspace-common" } -hearthspace-shell = { path = "crates/hearthspace-shell" } -# Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our -# linebender/xilem fork branch backing PR linebender/xilem#1830, which adds -# `WindowOptionsExtLinux::with_name` so a Xilem window can advertise a Wayland -# `app_id` (and X11 `WM_CLASS`). Repoint to upstream once the PR lands. -# See SETUP.md for details. -masonry = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id", features = ["testing"] } -xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } -# naga 28 (pulled in transitively via wgpu/vello) only compiles its WGSL -# error-emit path when the `termcolor` feature is enabled, but the crates that -# depend on it (wgpu-core, vello_shaders) enable `wgsl-in` without it. We add a -# direct dependency purely to union `termcolor` into the shared naga build. -naga = { version = "28.0.0", default-features = false, features = ["termcolor"] } nix = { version = "0.30", features = ["signal", "process"] } -# Parley's fontique discovers system fonts via fontconfig. Enabling its -# `fontconfig-dlopen` code path makes it load libfontconfig at runtime instead -# of link-time, which avoids needing fontconfig dev headers at build time. -fontique = { version = "0.8.0", features = ["fontconfig-dlopen"] } -# Masonry's CPU render harness returns an `image::RgbaImage`; name the type -# directly so the title-text raster path is testable. Pulled in transitively -# already, so this only fixes the version we link against. -image = { version = "0.25", default-features = false } -png = "0.18.1" -smithay = { version = "0.7.0", default-features = false, features = ["desktop", "renderer_gl", "wayland_frontend"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -wayland-protocols = { version = "0.32.8", features = ["server"] } -wayland-server = "0.31.9" -waydriver = { version = "0.3.9", optional = true } -waydriver-hearthspace = { path = "crates/waydriver-hearthspace", optional = true } which = "8" -xcursor = "0.3.10" - -[dev-dependencies] -proptest = "1" -rstest = "0.26" -tempfile = "3" -tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } -tokio-util = "0.7" - -[[test]] -name = "headless_control" -path = "tests/headless_control.rs" -required-features = ["e2e"] - -[[test]] -name = "waydriver_hearthspace" -path = "tests/waydriver_hearthspace.rs" -required-features = ["e2e"] - -# Workaround for an upstream incompatibility pulled in via Xilem/Masonry: -# `vello_shaders` 0.8's build script depends on `naga` 28 with the `wgsl-in` -# feature but without `termcolor`, yet naga 28's WGSL error-emit path only -# compiles when `termcolor` is enabled. Declaring naga in our own build graph -# with `termcolor` makes Cargo unify that feature in, so the vello shader build -# script compiles. The empty `build.rs` exists solely to activate this entry. -[build-dependencies] -naga = { version = "28.0.0", default-features = false, features = ["wgsl-in", "termcolor"] } # Centralized lint policy so local `cargo clippy`/`cargo build` enforce the same # discipline as CI (which also passes `-D warnings` to escalate rustc warnings). diff --git a/crates/hearthspace-common/src/lib.rs b/crates/hearthspace-common/src/lib.rs index 26c04aa..23d3fab 100644 --- a/crates/hearthspace-common/src/lib.rs +++ b/crates/hearthspace-common/src/lib.rs @@ -14,6 +14,7 @@ pub const REPO_ROOT_ENV: &str = "HEARTHSPACE_REPO_ROOT"; pub const DEV_ENV: &str = "HEARTHSPACE_DEV"; pub const CURRENT_DESKTOP: &str = "Hearthspace"; pub const DESKTOP_SESSION: &str = "hearthspace"; +pub const NO_SHELL_FLAG: &str = "--no-shell"; const CONFIG_DIR_NAME: &str = "hearthspace"; const CONFIG_FILE_NAME: &str = "config.toml"; diff --git a/crates/hearthspace-compositor/Cargo.toml b/crates/hearthspace-compositor/Cargo.toml new file mode 100644 index 0000000..925ea10 --- /dev/null +++ b/crates/hearthspace-compositor/Cargo.toml @@ -0,0 +1,96 @@ +[package] +name = "hearthspace-compositor" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" +default-run = "hearthspace-compositor" + +[features] +default = ["winit"] +e2e = ["dep:waydriver", "dep:waydriver-hearthspace"] +test-apps = ["dep:gtk"] +udev = [ + "smithay/backend_drm", + "smithay/backend_gbm", + "smithay/backend_libinput", + "smithay/backend_session_libseat", + "smithay/backend_udev", + # Native mode still renders through one primary GPU, but Smithay's dmabuf + # import path needs renderer_multi available for client buffers. + "smithay/renderer_multi", +] +winit = ["smithay/backend_winit"] + +[[bin]] +name = "hearthspace" +path = "src/bin/hearthspace.rs" + +[dependencies] +async-io = "2.3.3" +atspi = { version = "0.30.0", features = ["zbus"] } +calloop = { version = "0.14.4", features = ["signals"] } +gtk = { package = "gtk4", version = "0.11", optional = true } +hearthspace-common = { path = "../hearthspace-common" } +hearthspace-shell = { path = "../hearthspace-shell" } +# Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our +# linebender/xilem fork branch backing PR linebender/xilem#1830, which adds +# `WindowOptionsExtLinux::with_name` so a Xilem window can advertise a Wayland +# `app_id` (and X11 `WM_CLASS`). Repoint to upstream once the PR lands. +# See SETUP.md for details. +masonry = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id", features = ["testing"] } +xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } +# naga 28 (pulled in transitively via wgpu/vello) only compiles its WGSL +# error-emit path when the `termcolor` feature is enabled, but the crates that +# depend on it (wgpu-core, vello_shaders) enable `wgsl-in` without it. We add a +# direct dependency purely to union `termcolor` into the shared naga build. +naga = { version = "28.0.0", default-features = false, features = ["termcolor"] } +# Parley's fontique discovers system fonts via fontconfig. Enabling its +# `fontconfig-dlopen` code path makes it load libfontconfig at runtime instead +# of link-time, which avoids needing fontconfig dev headers at build time. +fontique = { version = "0.8.0", features = ["fontconfig-dlopen"] } +# Masonry's CPU render harness returns an `image::RgbaImage`; name the type +# directly so the title-text raster path is testable. Pulled in transitively +# already, so this only fixes the version we link against. +image = { version = "0.25", default-features = false } +png = "0.18.1" +smithay = { version = "0.7.0", default-features = false, features = ["desktop", "renderer_gl", "wayland_frontend"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +wayland-protocols = { version = "0.32.8", features = ["server"] } +wayland-server = "0.31.9" +waydriver = { version = "0.3.9", optional = true } +waydriver-hearthspace = { path = "../waydriver-hearthspace", optional = true } +xcursor = "0.3.10" + +[dev-dependencies] +proptest = "1" +rstest = "0.26" +tempfile = "3" +tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "time"] } +tokio-util = "0.7" + +[[test]] +name = "headless_control" +path = "tests/headless_control.rs" +required-features = ["e2e"] + +[[test]] +name = "waydriver_hearthspace" +path = "tests/waydriver_hearthspace.rs" +required-features = ["e2e"] + +# Workaround for an upstream incompatibility pulled in via Xilem/Masonry: +# `vello_shaders` 0.8's build script depends on `naga` 28 with the `wgsl-in` +# feature but without `termcolor`, yet naga 28's WGSL error-emit path only +# compiles when `termcolor` is enabled. Declaring naga in our own build graph +# with `termcolor` makes Cargo unify that feature in, so the vello shader build +# script compiles. The empty `build.rs` exists solely to activate this entry. +[build-dependencies] +naga = { version = "28.0.0", default-features = false, features = ["wgsl-in", "termcolor"] } + +[lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = { level = "deny", priority = -1 } diff --git a/build.rs b/crates/hearthspace-compositor/build.rs similarity index 100% rename from build.rs rename to crates/hearthspace-compositor/build.rs diff --git a/src/accessibility.rs b/crates/hearthspace-compositor/src/accessibility.rs similarity index 100% rename from src/accessibility.rs rename to crates/hearthspace-compositor/src/accessibility.rs diff --git a/src/main.rs b/crates/hearthspace-compositor/src/bin/hearthspace.rs similarity index 93% rename from src/main.rs rename to crates/hearthspace-compositor/src/bin/hearthspace.rs index 37bd7c4..eddcf7f 100644 --- a/src/main.rs +++ b/crates/hearthspace-compositor/src/bin/hearthspace.rs @@ -1,5 +1,6 @@ use std::fs::OpenOptions; +use hearthspace_compositor as hearthspace; use tracing::{debug, info}; fn main() -> Result<(), Box> { @@ -10,13 +11,13 @@ fn main() -> Result<(), Box> { if args .iter() - .any(|arg| arg == hearthspace::config::GTK_TEST_APP_FLAG) + .any(|arg| arg == hearthspace_compositor::config::GTK_TEST_APP_FLAG) { info!("starting GTK test app client"); run_gtk_test_app() } else if args .iter() - .any(|arg| arg == hearthspace::config::SHELL_FLAG) + .any(|arg| arg == hearthspace_compositor::config::SHELL_FLAG) { info!("starting shell client"); hearthspace_shell::xilem_shell::run() @@ -25,26 +26,26 @@ fn main() -> Result<(), Box> { let headless_output_size = parse_headless_output_size(&args)?; let headless_output_scale = parse_headless_output_scale(&args)?; let exit_after = parse_exit_after(&args)?; - let options = hearthspace::RunOptions { + let options = hearthspace_compositor::RunOptions { scroll_zooms_without_super: args .iter() - .any(|arg| arg == hearthspace::config::SCROLL_ZOOMS_FLAG), + .any(|arg| arg == hearthspace_compositor::config::SCROLL_ZOOMS_FLAG), backend, headless_output_size, headless_output_scale, exit_after, start_shell: !args .iter() - .any(|arg| arg == hearthspace::config::NO_SHELL_FLAG), + .any(|arg| arg == hearthspace_compositor::config::NO_SHELL_FLAG), }; info!(?options, "starting compositor"); - hearthspace::run_with_options(options) + hearthspace_compositor::run_with_options(options) } } fn initialize_tracing() { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env(); - if let Some(log_path) = std::env::var_os(hearthspace::config::LOG_FILE_ENV) { + if let Some(log_path) = std::env::var_os(hearthspace_compositor::config::LOG_FILE_ENV) { let make_writer = move || { OpenOptions::new() .create(true) @@ -88,7 +89,9 @@ fn parse_exit_after(args: &[String]) -> Result, Stri Ok(None) } -fn parse_backend_selection(args: &[String]) -> Result { +fn parse_backend_selection( + args: &[String], +) -> Result { let mut selected = Vec::new(); if args .iter() diff --git a/src/compositor/cursor.rs b/crates/hearthspace-compositor/src/compositor/cursor.rs similarity index 94% rename from src/compositor/cursor.rs rename to crates/hearthspace-compositor/src/compositor/cursor.rs index 567fd84..6a7923b 100644 --- a/src/compositor/cursor.rs +++ b/crates/hearthspace-compositor/src/compositor/cursor.rs @@ -22,7 +22,7 @@ pub(super) struct SoftwareCursor { pub(super) hotspot: (i32, i32), } -const DEFAULT_CURSOR: &[u8] = include_bytes!("../../assets/cursors/default"); +const DEFAULT_CURSOR: &[u8] = include_bytes!("../../../../assets/cursors/default"); const DESIRED_CURSOR_SIZE: u32 = 24; pub(super) fn standard_software_cursor() -> SoftwareCursor { diff --git a/src/compositor/handlers.rs b/crates/hearthspace-compositor/src/compositor/handlers.rs similarity index 100% rename from src/compositor/handlers.rs rename to crates/hearthspace-compositor/src/compositor/handlers.rs diff --git a/src/compositor/idle.rs b/crates/hearthspace-compositor/src/compositor/idle.rs similarity index 100% rename from src/compositor/idle.rs rename to crates/hearthspace-compositor/src/compositor/idle.rs diff --git a/src/compositor/input.rs b/crates/hearthspace-compositor/src/compositor/input.rs similarity index 100% rename from src/compositor/input.rs rename to crates/hearthspace-compositor/src/compositor/input.rs diff --git a/src/compositor/masonry_titlebar.rs b/crates/hearthspace-compositor/src/compositor/masonry_titlebar.rs similarity index 100% rename from src/compositor/masonry_titlebar.rs rename to crates/hearthspace-compositor/src/compositor/masonry_titlebar.rs diff --git a/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs similarity index 100% rename from src/compositor/mod.rs rename to crates/hearthspace-compositor/src/compositor/mod.rs diff --git a/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs similarity index 100% rename from src/compositor/output.rs rename to crates/hearthspace-compositor/src/compositor/output.rs diff --git a/src/compositor/rendering.rs b/crates/hearthspace-compositor/src/compositor/rendering.rs similarity index 100% rename from src/compositor/rendering.rs rename to crates/hearthspace-compositor/src/compositor/rendering.rs diff --git a/src/compositor/runtime.rs b/crates/hearthspace-compositor/src/compositor/runtime.rs similarity index 100% rename from src/compositor/runtime.rs rename to crates/hearthspace-compositor/src/compositor/runtime.rs diff --git a/src/compositor/shell_integration.rs b/crates/hearthspace-compositor/src/compositor/shell_integration.rs similarity index 100% rename from src/compositor/shell_integration.rs rename to crates/hearthspace-compositor/src/compositor/shell_integration.rs diff --git a/src/compositor/udev.rs b/crates/hearthspace-compositor/src/compositor/udev.rs similarity index 100% rename from src/compositor/udev.rs rename to crates/hearthspace-compositor/src/compositor/udev.rs diff --git a/src/compositor/udev/device.rs b/crates/hearthspace-compositor/src/compositor/udev/device.rs similarity index 100% rename from src/compositor/udev/device.rs rename to crates/hearthspace-compositor/src/compositor/udev/device.rs diff --git a/src/compositor/udev/input.rs b/crates/hearthspace-compositor/src/compositor/udev/input.rs similarity index 100% rename from src/compositor/udev/input.rs rename to crates/hearthspace-compositor/src/compositor/udev/input.rs diff --git a/src/compositor/viewport.rs b/crates/hearthspace-compositor/src/compositor/viewport.rs similarity index 100% rename from src/compositor/viewport.rs rename to crates/hearthspace-compositor/src/compositor/viewport.rs diff --git a/src/compositor/windows.rs b/crates/hearthspace-compositor/src/compositor/windows.rs similarity index 100% rename from src/compositor/windows.rs rename to crates/hearthspace-compositor/src/compositor/windows.rs diff --git a/src/compositor/windows/geometry.rs b/crates/hearthspace-compositor/src/compositor/windows/geometry.rs similarity index 100% rename from src/compositor/windows/geometry.rs rename to crates/hearthspace-compositor/src/compositor/windows/geometry.rs diff --git a/src/compositor/windows/ordering.rs b/crates/hearthspace-compositor/src/compositor/windows/ordering.rs similarity index 100% rename from src/compositor/windows/ordering.rs rename to crates/hearthspace-compositor/src/compositor/windows/ordering.rs diff --git a/src/config.rs b/crates/hearthspace-compositor/src/config.rs similarity index 93% rename from src/config.rs rename to crates/hearthspace-compositor/src/config.rs index 611c118..7784c98 100644 --- a/src/config.rs +++ b/crates/hearthspace-compositor/src/config.rs @@ -2,8 +2,8 @@ use std::time::Duration; pub use hearthspace_common::{ CONTROL_BAR_HEIGHT, HEARTHSPACE_WAYLAND_DISPLAY_ENV, LAUNCHER_APP_ID, LOG_FILE_ENV, - SHELL_BAR_APP_ID, SHELL_COMMAND_SOCKET_ENV, SHELL_COMMAND_SOCKET_NAME, WAYLAND_DISPLAY_ENV, - WAYLAND_DISPLAY_NAME, wayland_display_name, + NO_SHELL_FLAG, SHELL_BAR_APP_ID, SHELL_COMMAND_SOCKET_ENV, SHELL_COMMAND_SOCKET_NAME, + WAYLAND_DISPLAY_ENV, WAYLAND_DISPLAY_NAME, wayland_display_name, }; pub const GTK_TEST_APP_FLAG: &str = "--gtk-test-app"; pub const SHELL_FLAG: &str = "--shell"; @@ -16,7 +16,6 @@ pub const GTK_CLIENT_CONFIG_DIR_NAME: &str = "hearthspace-gtk-client-config"; pub const SCROLL_ZOOMS_FLAG: &str = "--scroll-zooms"; pub const HEADLESS_SIZE_FLAG: &str = "--headless-size"; pub const HEADLESS_SCALE_FLAG: &str = "--headless-scale"; -pub const NO_SHELL_FLAG: &str = "--no-shell"; pub const EXIT_AFTER_MS_FLAG: &str = "--exit-after-ms"; pub const HEADLESS_OUTPUT_WIDTH: i32 = 1280; pub const HEADLESS_OUTPUT_HEIGHT: i32 = 720; diff --git a/src/geometry.rs b/crates/hearthspace-compositor/src/geometry.rs similarity index 100% rename from src/geometry.rs rename to crates/hearthspace-compositor/src/geometry.rs diff --git a/src/lib.rs b/crates/hearthspace-compositor/src/lib.rs similarity index 100% rename from src/lib.rs rename to crates/hearthspace-compositor/src/lib.rs diff --git a/src/bin/hearthspace-compositor.rs b/crates/hearthspace-compositor/src/main.rs similarity index 67% rename from src/bin/hearthspace-compositor.rs rename to crates/hearthspace-compositor/src/main.rs index 333f96b..e9de705 100644 --- a/src/bin/hearthspace-compositor.rs +++ b/crates/hearthspace-compositor/src/main.rs @@ -8,25 +8,25 @@ fn main() -> Result<(), Box> { let args = std::env::args().collect::>(); debug!(?args, "parsed compositor arguments"); - let options = hearthspace::RunOptions { + let options = hearthspace_compositor::RunOptions { scroll_zooms_without_super: args .iter() - .any(|arg| arg == hearthspace::config::SCROLL_ZOOMS_FLAG), + .any(|arg| arg == hearthspace_compositor::config::SCROLL_ZOOMS_FLAG), backend: parse_backend_selection(&args)?, headless_output_size: parse_headless_output_size(&args)?, headless_output_scale: parse_headless_output_scale(&args)?, exit_after: parse_exit_after(&args)?, start_shell: !args .iter() - .any(|arg| arg == hearthspace::config::NO_SHELL_FLAG), + .any(|arg| arg == hearthspace_compositor::config::NO_SHELL_FLAG), }; info!(?options, "starting Hearthspace compositor"); - hearthspace::run_with_options(options) + hearthspace_compositor::run_with_options(options) } fn initialize_tracing() { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env(); - if let Some(log_path) = std::env::var_os(hearthspace::config::LOG_FILE_ENV) { + if let Some(log_path) = std::env::var_os(hearthspace_compositor::config::LOG_FILE_ENV) { let make_writer = move || { OpenOptions::new() .create(true) @@ -48,59 +48,64 @@ fn initialize_tracing() { } } -fn parse_backend_selection(args: &[String]) -> Result { +fn parse_backend_selection( + args: &[String], +) -> Result { let mut selected = Vec::new(); if args .iter() - .any(|arg| arg == hearthspace::config::HEADLESS_FLAG) + .any(|arg| arg == hearthspace_compositor::config::HEADLESS_FLAG) { selected.push(( - hearthspace::config::HEADLESS_FLAG, - hearthspace::BackendSelection::Headless, + hearthspace_compositor::config::HEADLESS_FLAG, + hearthspace_compositor::BackendSelection::Headless, )); } - if args.iter().any(|arg| arg == hearthspace::config::TTY_FLAG) { + if args.iter().any(|arg| arg == hearthspace_compositor::config::TTY_FLAG) { selected.push(( - hearthspace::config::TTY_FLAG, - hearthspace::BackendSelection::Udev, + hearthspace_compositor::config::TTY_FLAG, + hearthspace_compositor::BackendSelection::Udev, )); } if args .iter() - .any(|arg| arg == hearthspace::config::WINIT_FLAG) + .any(|arg| arg == hearthspace_compositor::config::WINIT_FLAG) { selected.push(( - hearthspace::config::WINIT_FLAG, - hearthspace::BackendSelection::Winit, + hearthspace_compositor::config::WINIT_FLAG, + hearthspace_compositor::BackendSelection::Winit, )); } match selected.as_slice() { - [] => Ok(hearthspace::BackendSelection::Auto), + [] => Ok(hearthspace_compositor::BackendSelection::Auto), [(_, backend)] => Ok(*backend), _ => Err(format!( "choose only one backend flag: {}, {}, or {}", - hearthspace::config::HEADLESS_FLAG, - hearthspace::config::TTY_FLAG, - hearthspace::config::WINIT_FLAG + hearthspace_compositor::config::HEADLESS_FLAG, + hearthspace_compositor::config::TTY_FLAG, + hearthspace_compositor::config::WINIT_FLAG )), } } fn parse_exit_after(args: &[String]) -> Result, String> { for (index, arg) in args.iter().enumerate() { - if arg == hearthspace::config::EXIT_AFTER_MS_FLAG { + if arg == hearthspace_compositor::config::EXIT_AFTER_MS_FLAG { let Some(value) = args.get(index + 1) else { return Err(format!( "{} requires a positive integer", - hearthspace::config::EXIT_AFTER_MS_FLAG + hearthspace_compositor::config::EXIT_AFTER_MS_FLAG )); }; return parse_positive_duration_ms(value).map(Some); } if let Some(value) = - arg.strip_prefix(&format!("{}=", hearthspace::config::EXIT_AFTER_MS_FLAG)) + arg.strip_prefix(&format!( + "{}=", + hearthspace_compositor::config::EXIT_AFTER_MS_FLAG + )) { return parse_positive_duration_ms(value).map(Some); } @@ -111,18 +116,21 @@ fn parse_exit_after(args: &[String]) -> Result, Stri fn parse_headless_output_size(args: &[String]) -> Result, String> { for (index, arg) in args.iter().enumerate() { - if arg == hearthspace::config::HEADLESS_SIZE_FLAG { + if arg == hearthspace_compositor::config::HEADLESS_SIZE_FLAG { let Some(value) = args.get(index + 1) else { return Err(format!( "{} requires WIDTHxHEIGHT", - hearthspace::config::HEADLESS_SIZE_FLAG + hearthspace_compositor::config::HEADLESS_SIZE_FLAG )); }; return parse_size(value).map(Some); } if let Some(value) = - arg.strip_prefix(&format!("{}=", hearthspace::config::HEADLESS_SIZE_FLAG)) + arg.strip_prefix(&format!( + "{}=", + hearthspace_compositor::config::HEADLESS_SIZE_FLAG + )) { return parse_size(value).map(Some); } @@ -133,18 +141,21 @@ fn parse_headless_output_size(args: &[String]) -> Result, Str fn parse_headless_output_scale(args: &[String]) -> Result, String> { for (index, arg) in args.iter().enumerate() { - if arg == hearthspace::config::HEADLESS_SCALE_FLAG { + if arg == hearthspace_compositor::config::HEADLESS_SCALE_FLAG { let Some(value) = args.get(index + 1) else { return Err(format!( "{} requires a positive integer", - hearthspace::config::HEADLESS_SCALE_FLAG + hearthspace_compositor::config::HEADLESS_SCALE_FLAG )); }; return parse_positive_dimension(value, "scale").map(Some); } if let Some(value) = - arg.strip_prefix(&format!("{}=", hearthspace::config::HEADLESS_SCALE_FLAG)) + arg.strip_prefix(&format!( + "{}=", + hearthspace_compositor::config::HEADLESS_SCALE_FLAG + )) { return parse_positive_dimension(value, "scale").map(Some); } diff --git a/src/test_apps/gtk.rs b/crates/hearthspace-compositor/src/test_apps/gtk.rs similarity index 100% rename from src/test_apps/gtk.rs rename to crates/hearthspace-compositor/src/test_apps/gtk.rs diff --git a/src/test_apps/mod.rs b/crates/hearthspace-compositor/src/test_apps/mod.rs similarity index 100% rename from src/test_apps/mod.rs rename to crates/hearthspace-compositor/src/test_apps/mod.rs diff --git a/tests/headless_control.rs b/crates/hearthspace-compositor/tests/headless_control.rs similarity index 97% rename from tests/headless_control.rs rename to crates/hearthspace-compositor/tests/headless_control.rs index c84deab..4122631 100644 --- a/tests/headless_control.rs +++ b/crates/hearthspace-compositor/tests/headless_control.rs @@ -196,8 +196,10 @@ fn wait_for_screenshot_change(previous: &[u8]) -> Vec { fn wait_for_accessible_term(term: &str) -> bool { let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { - if async_io::block_on(hearthspace::accessibility::accessibility_tree_contains_term(term)) - .expect("query AT-SPI tree") + if async_io::block_on( + hearthspace_compositor::accessibility::accessibility_tree_contains_term(term), + ) + .expect("query AT-SPI tree") { return true; } diff --git a/tests/waydriver_hearthspace.rs b/crates/hearthspace-compositor/tests/waydriver_hearthspace.rs similarity index 100% rename from tests/waydriver_hearthspace.rs rename to crates/hearthspace-compositor/tests/waydriver_hearthspace.rs diff --git a/src/bin/hearthspace-session.rs b/src/bin/hearthspace-session.rs index ccb6c49..e1bf257 100644 --- a/src/bin/hearthspace-session.rs +++ b/src/bin/hearthspace-session.rs @@ -339,7 +339,7 @@ impl Supervisor { fn start_compositor(&mut self) -> Result<(), Box> { let spec = self.binary_spec( "hearthspace-compositor", - &[hearthspace::config::NO_SHELL_FLAG], + &[hearthspace_common::NO_SHELL_FLAG], false, ); self.compositor = Some(ManagedProcess::spawn(spec, 0)?); @@ -585,7 +585,10 @@ impl Supervisor { fn cargo_package_exists(package: &str) -> bool { matches!( package, - "hearthspace-settings" | "hearthspace-settingsd" | "hearthspace-shell" + "hearthspace-compositor" + | "hearthspace-settings" + | "hearthspace-settingsd" + | "hearthspace-shell" ) } -- 2.51.2 From 4044276fa1154b593f4240263b8c49428a89ea9b Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 04:00:01 -0700 Subject: [PATCH 18/28] refactor: extract session crate --- Cargo.toml | 25 +++---------------- crates/hearthspace-session/Cargo.toml | 18 +++++++++++++ .../hearthspace-session/src/main.rs | 14 +++++++---- 3 files changed, 31 insertions(+), 26 deletions(-) create mode 100644 crates/hearthspace-session/Cargo.toml rename src/bin/hearthspace-session.rs => crates/hearthspace-session/src/main.rs (98%) diff --git a/Cargo.toml b/Cargo.toml index 32ef7dd..e5da483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,38 +1,21 @@ -[package] -name = "hearthspace-session" -version = "0.1.0" -edition = "2024" -rust-version = "1.93.1" -license = "Apache-2.0" -default-run = "hearthspace-session" - [workspace] resolver = "2" members = [ - ".", "crates/hearthspace-compositor", "crates/hearthspace-common", "crates/hearthspace-ipc", + "crates/hearthspace-session", "crates/hearthspace-settings", "crates/hearthspace-settingsd", "crates/hearthspace-shell", "crates/waydriver-hearthspace", ] -default-members = ["."] - -[dependencies] -clap = { version = "4", features = ["derive"] } -ctrlc = "3" -hearthspace-common = { path = "crates/hearthspace-common" } -nix = { version = "0.30", features = ["signal", "process"] } -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -which = "8" +default-members = ["crates/hearthspace-session"] # Centralized lint policy so local `cargo clippy`/`cargo build` enforce the same # discipline as CI (which also passes `-D warnings` to escalate rustc warnings). -[lints.rust] +[workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" -[lints.clippy] +[workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/crates/hearthspace-session/Cargo.toml b/crates/hearthspace-session/Cargo.toml new file mode 100644 index 0000000..9d2f4b2 --- /dev/null +++ b/crates/hearthspace-session/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hearthspace-session" +version = "0.1.0" +edition = "2024" +rust-version = "1.93.1" +license = "Apache-2.0" + +[dependencies] +clap = { version = "4", features = ["derive"] } +ctrlc = "3" +hearthspace-common = { path = "../hearthspace-common" } +nix = { version = "0.30", features = ["signal", "process"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +which = "8" + +[lints] +workspace = true diff --git a/src/bin/hearthspace-session.rs b/crates/hearthspace-session/src/main.rs similarity index 98% rename from src/bin/hearthspace-session.rs rename to crates/hearthspace-session/src/main.rs index e1bf257..96403f5 100644 --- a/src/bin/hearthspace-session.rs +++ b/crates/hearthspace-session/src/main.rs @@ -245,7 +245,7 @@ fn create_session_env( vars.push((hearthspace_common::DEV_ENV.to_string(), "1".to_string())); vars.push(( hearthspace_common::REPO_ROOT_ENV.to_string(), - env!("CARGO_MANIFEST_DIR").to_string(), + path_to_string(&repo_root())?, )); } @@ -273,8 +273,13 @@ fn xdg_data_dirs(session_share_dir: &str) -> Result PathBuf { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + root.canonicalize().unwrap_or(root) +} + fn generate_dev_desktop_entries(paths: &SessionPaths) -> Result<(), Box> { - let exec = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/dev-run-app"); + let exec = repo_root().join("scripts/dev-run-app"); let desktop_entry = format!( "[Desktop Entry]\n\ Type=Application\n\ @@ -496,8 +501,7 @@ impl Supervisor { )); } - let current_dir = (self.mode == RunMode::DevCargo) - .then(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + let current_dir = (self.mode == RunMode::DevCargo).then(repo_root); match self.mode { RunMode::DevCargo => { let mut args = vec!["run".to_string()]; @@ -537,7 +541,7 @@ impl Supervisor { fn watcher_spec(&self) -> Option { let envs = self.child_env(true); - let current_dir = Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + let current_dir = Some(repo_root()); if which::which("cargo-watch").is_ok() { return Some(ProcessSpec { name: "settings-watch", -- 2.51.2 From 9e86fd677297a3e9bb0674d9d2d589d7563b38f4 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 04:04:19 -0700 Subject: [PATCH 19/28] chore: complete session supervisor todo --- crates/hearthspace-session/src/main.rs | 2 +- crates/waydriver-hearthspace/src/lib.rs | 21 +- todos/SESSION_SUPERVISOR.md | 817 ------------------------ 3 files changed, 11 insertions(+), 829 deletions(-) delete mode 100644 todos/SESSION_SUPERVISOR.md diff --git a/crates/hearthspace-session/src/main.rs b/crates/hearthspace-session/src/main.rs index 96403f5..d1d9f44 100644 --- a/crates/hearthspace-session/src/main.rs +++ b/crates/hearthspace-session/src/main.rs @@ -20,7 +20,7 @@ use tracing::{info, warn}; #[derive(Debug, Parser)] #[command(about = "Start a Hearthspace desktop session")] struct Args { - /// Run child processes through `cargo run --bin ...`. + /// Run child processes through `cargo run -p ...`. #[arg(long, conflicts_with = "installed")] dev: bool, diff --git a/crates/waydriver-hearthspace/src/lib.rs b/crates/waydriver-hearthspace/src/lib.rs index 49c12cc..4474703 100644 --- a/crates/waydriver-hearthspace/src/lib.rs +++ b/crates/waydriver-hearthspace/src/lib.rs @@ -298,15 +298,14 @@ async fn wait_for_socket(child: Option<&mut Child>, path: &Path) -> Result<()> { let mut last_error = None; while tokio::time::Instant::now() < deadline { - if let Some(child) = child.as_deref_mut() { - if let Some(status) = child + if let Some(child) = child.as_deref_mut() + && let Some(status) = child .try_wait() .map_err(|e| Error::process_with("poll hearthspace", e))? - { - return Err(Error::process(format!( - "hearthspace exited before accepting commands: {status}" - ))); - } + { + return Err(Error::process(format!( + "hearthspace exited before accepting commands: {status}" + ))); } match UnixStream::connect(path).await { @@ -434,10 +433,10 @@ fn scale_to_integer(scale: Option) -> Result> { } fn keysym_to_evdev(keysym: u32) -> Result { - if let Ok(byte) = u8::try_from(keysym) { - if let Some(keycode) = ascii_keysym_to_evdev(byte) { - return Ok(keycode); - } + if let Ok(byte) = u8::try_from(keysym) + && let Some(keycode) = ascii_keysym_to_evdev(byte) + { + return Ok(keycode); } let keycode = match keysym { diff --git a/todos/SESSION_SUPERVISOR.md b/todos/SESSION_SUPERVISOR.md deleted file mode 100644 index 1ceadcf..0000000 --- a/todos/SESSION_SUPERVISOR.md +++ /dev/null @@ -1,817 +0,0 @@ -# Hearthspace Session Supervisor Implementation Plan - -This document describes how to scaffold Hearthspace so that the compositor, shell, settings daemon, and development-only helper processes can be launched together from a single command. - -The intended developer workflow is: - -```sh -cargo run -``` - -from the repository root. - -That command should start a full Hearthspace development session, including: - -- `hearthspace-settingsd` -- `hearthspace-compositor` -- `hearthspace-shell` -- development app registration for apps like `hearthspace-settings` -- optional background rebuild/watch processes for development apps - -The design should also grow cleanly into an installed desktop session later. - -## Goals - -### Primary goals - -- Make `cargo run` start a complete Hearthspace session. -- Keep the compositor, shell, and settings daemon as separate binaries. -- Provide a session supervisor that owns process startup, shutdown, environment variables, and readiness coordination. -- Let the shell launch Hearthspace-owned apps like `hearthspace-settings` through normal app search. -- Support development workflows where Hearthspace apps are rebuilt automatically when their code changes. -- Preserve the ability to run each component directly for debugging. -- Keep the design compatible with future installed sessions, display managers, and systemd user units. - -### Non-goals - -- Do not collapse the compositor, shell, and settings daemon into one binary. -- Do not make `hearthspace-settingsd` responsible for UI. -- Do not require a full system install for development. -- Do not require the settings UI to be embedded in the shell. -- Do not hard-code paths that only work on one developer machine. - -## Proposed workspace layout - -Recommended crate layout: - -```txt -crates/ - hearthspace-session/ # cargo run target; starts the full dev session - hearthspace-compositor/ # Wayland compositor - hearthspace-shell/ # Xilem shell UI - hearthspace-settingsd/ # settings daemon - hearthspace-settings/ # Xilem settings UI app - hearthspace-common/ # shared paths, logging, config, errors, process utilities - hearthspace-ipc/ # shared IPC schemas and client/server helpers -``` - -The root workspace should make `hearthspace-session` the default member so that `cargo run` from the repo root starts the session supervisor. - -Example root `Cargo.toml` shape: - -```toml -[workspace] -resolver = "2" -members = [ - "crates/hearthspace-session", - "crates/hearthspace-compositor", - "crates/hearthspace-shell", - "crates/hearthspace-settingsd", - "crates/hearthspace-settings", - "crates/hearthspace-common", - "crates/hearthspace-ipc", -] - -default-members = ["crates/hearthspace-session"] -``` - -Each component should still remain directly runnable: - -```sh -cargo run -p hearthspace-session -cargo run -p hearthspace-compositor -cargo run -p hearthspace-shell -cargo run -p hearthspace-settingsd -cargo run -p hearthspace-settings -``` - -## Process tree - -During development, `cargo run` should create a process tree like this: - -```txt -hearthspace-session -├─ hearthspace-settingsd -├─ hearthspace-compositor -├─ hearthspace-shell -├─ cargo watch/build process for hearthspace-settings # optional but recommended -└─ other dev helper processes # optional future expansion -``` - -The session supervisor is responsible for: - -- selecting a Wayland socket name -- creating runtime paths -- starting `settingsd` -- starting the compositor -- waiting for compositor readiness -- starting the shell -- registering development apps -- starting file watchers/build helpers -- forwarding relevant environment variables -- shutting down children when the session ends -- reporting clear logs when anything fails - -## Development vs installed mode - -The session supervisor should support two process-launch modes. - -### Development mode - -Development mode is used when running from the repository with: - -```sh -cargo run -``` - -In this mode, the session supervisor may spawn child processes using: - -```sh -cargo run -p -- -``` - -This is slower than launching binaries directly but convenient during early development because it avoids requiring an install step. - -Example: - -```sh -cargo run -p hearthspace-settingsd -- -cargo run -p hearthspace-compositor -- --wayland-display hearthspace-dev-12345 -cargo run -p hearthspace-shell -- -``` - -### Installed mode - -Installed mode is used later by a display manager or installed desktop session. - -In this mode, `hearthspace-session` should spawn installed binaries directly: - -```txt -/usr/bin/hearthspace-settingsd -/usr/bin/hearthspace-compositor -/usr/bin/hearthspace-shell -/usr/bin/hearthspace-settings -``` - -The supervisor should not assume that `cargo` or the source tree exists in installed mode. - -### Recommended mode detection - -For now, use explicit CLI flags: - -```sh -cargo run -- --dev -hearthspace-session --installed -``` - -Default behavior: - -- When run through `cargo run`, default to development mode. -- When launched as an installed binary, default to installed mode. -- Allow override flags either way. - -Potential implementation options: - -- Detect `HEARTHSPACE_DEV=1` -- Detect whether `CARGO_MANIFEST_DIR` or repo root is known -- Use explicit `--dev` / `--installed` flags -- Use `cfg(debug_assertions)` as a fallback, but do not rely on it as the only mechanism - -## Runtime paths - -Use XDG paths and avoid global writable locations. - -Important paths: - -```txt -$XDG_RUNTIME_DIR/hearthspace/ - settings.sock - session.sock - compositor-ready.json - dev-applications/ - -$XDG_RUNTIME_DIR/ - Wayland socket created by compositor -``` - -Recommended per-session runtime directory: - -```txt -$XDG_RUNTIME_DIR/hearthspace/session-/ -``` - -Example: - -```txt -/run/user/1000/hearthspace/session-42691/ - settings.sock - compositor-ready.json - applications/ - hearthspace-settings.desktop -``` - -Use a per-session directory so multiple nested/dev sessions can coexist without stomping each other. - -## Environment variables - -The session supervisor should construct and pass a consistent environment to children. - -Recommended environment variables: - -```txt -WAYLAND_DISPLAY=hearthspace-dev- -XDG_CURRENT_DESKTOP=Hearthspace -DESKTOP_SESSION=hearthspace -HEARTHSPACE_DEV=1 -HEARTHSPACE_SESSION_DIR=$XDG_RUNTIME_DIR/hearthspace/session- -HEARTHSPACE_SETTINGS_SOCKET=$XDG_RUNTIME_DIR/hearthspace/session-/settings.sock -HEARTHSPACE_SESSION_SOCKET=$XDG_RUNTIME_DIR/hearthspace/session-/session.sock -HEARTHSPACE_REPO_ROOT=/absolute/path/to/repo # dev mode only -``` - -For shell-launched apps, the shell should inherit and forward at least: - -```txt -WAYLAND_DISPLAY -XDG_CURRENT_DESKTOP -DESKTOP_SESSION -HEARTHSPACE_SETTINGS_SOCKET -HEARTHSPACE_SESSION_SOCKET -``` - -## Startup order - -Recommended startup order: - -```txt -1. Initialize logging. -2. Resolve runtime paths. -3. Generate session-specific environment. -4. Register dev apps by generating .desktop files. -5. Start hearthspace-settingsd. -6. Wait for settingsd readiness. -7. Start hearthspace-compositor with explicit Wayland display name. -8. Wait for compositor readiness. -9. Start hearthspace-shell. -10. Start optional dev watchers/build processes. -11. Monitor children until the session exits. -12. Shut down remaining children cleanly. -``` - -`settingsd` should start before the compositor and shell so both can query settings on startup. - -The compositor should start before the shell because the shell is a Wayland client. - -The shell should start after the compositor has created its Wayland socket and is ready to accept clients. - -## Readiness - -Avoid arbitrary sleeps. - -### Settings daemon readiness - -`hearthspace-settingsd` should support at least one readiness mechanism: - -- create its Unix socket -- respond to a `ping` IPC request -- optionally write a readiness message to a provided file descriptor - -Recommended first-class readiness behavior: - -```sh -hearthspace-settingsd --socket "$HEARTHSPACE_SETTINGS_SOCKET" --ready-fd 3 -``` - -When ready, it writes a newline-delimited JSON message to the FD: - -```json -{"service":"settingsd","status":"ready"} -``` - -The session supervisor should still support a fallback that waits for the socket file and then sends a ping. - -### Compositor readiness - -The compositor should accept: - -```sh -hearthspace-compositor --wayland-display hearthspace-dev- --ready-fd 3 -``` - -When ready, it writes: - -```json -{"service":"compositor","status":"ready","wayland_display":"hearthspace-dev-42691"} -``` - -Fallback behavior: - -- wait for `$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY` to exist -- optionally try connecting to the Wayland socket - -### Shell readiness - -The shell can optionally support readiness later, but it is not required for the session to continue. - -If implemented: - -```sh -hearthspace-shell --ready-fd 3 -``` - -Ready message: - -```json -{"service":"shell","status":"ready"} -``` - -## Process supervision - -The session supervisor should monitor children and define failure behavior explicitly. - -Recommended behavior: - -### Critical processes - -These are critical: - -- `hearthspace-settingsd` -- `hearthspace-compositor` -- `hearthspace-shell` - -If the compositor exits, shut down the whole session. - -If `settingsd` exits, either: - -- shut down the whole session, or -- restart it if the exit looks crash-like and restart count is below a limit - -If the shell exits, restart it during development unless the user requested session shutdown. - -Suggested development defaults: - -```txt -compositor exits -> shut down session -settingsd exits -> restart up to 3 times, then shut down session -shell exits -> restart up to 3 times, then shut down session -watcher exits -> log warning, do not shut down session -``` - -### Graceful shutdown - -On `SIGINT`, `SIGTERM`, compositor exit, or unrecoverable child failure: - -1. Ask shell to exit. -2. Ask compositor to exit. -3. Ask settingsd to exit. -4. Terminate dev watchers. -5. Wait with timeout. -6. Kill remaining children. -7. Clean up generated runtime files. - -The session supervisor should avoid orphaned `cargo run`, compositor, shell, or settingsd processes. - -## Logging - -Use `tracing` across components. - -The session supervisor should produce clear structured logs: - -```txt -[session] runtime dir: /run/user/1000/hearthspace/session-42691 -[session] WAYLAND_DISPLAY=hearthspace-dev-42691 -[session] starting settingsd -[session] settingsd ready -[session] starting compositor -[session] compositor ready -[session] starting shell -[session] generated desktop entry: hearthspace-settings.desktop -[session] starting settings app watcher -``` - -Child logs should be prefixed by process name if possible: - -```txt -[settingsd] ... -[compositor] ... -[shell] ... -[settings-watch] ... -``` - -If prefixing stdout/stderr is too much for the first implementation, ensure each child initializes `tracing` with its own target/module names. - -## Development app registration - -The session supervisor should generate temporary `.desktop` files for Hearthspace development apps. - -This lets the shell's app search discover and launch Hearthspace apps naturally, without requiring installation. - -Generated desktop directory: - -```txt -$HEARTHSPACE_SESSION_DIR/share/applications/ -``` - -Generated desktop file: - -```txt -$HEARTHSPACE_SESSION_DIR/share/applications/hearthspace-settings.desktop -``` - -Example: - -```ini -[Desktop Entry] -Type=Application -Name=Hearthspace Settings -Comment=Configure Hearthspace -Exec=/absolute/path/to/repo/scripts/dev-run-app hearthspace-settings -Icon=hearthspace -Categories=Settings;System; -X-Hearthspace-Dev=true -``` - -The shell/app launcher should include this directory when searching for applications. - -Recommended environment: - -```txt -XDG_DATA_DIRS=$HEARTHSPACE_SESSION_DIR:/usr/local/share:/usr/share -``` - -This matches the normal XDG lookup shape because the generated file lives under `$HEARTHSPACE_SESSION_DIR/share/applications`. - -## Development app launching - -Use a wrapper script for development-launched Hearthspace apps. - -Example path: - -```txt -scripts/dev-run-app -``` - -Example implementation: - -```sh -#!/usr/bin/env bash -set -euo pipefail - -APP="$1" -REPO_ROOT="${HEARTHSPACE_REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" - -cd "$REPO_ROOT" - -if [[ ! -x "target/debug/$APP" ]]; then - cargo build -p "$APP" -fi - -exec "$REPO_ROOT/target/debug/$APP" -``` - -The `.desktop` file should call this script. - -This ensures the settings app can still launch even if it has not been built yet. - -## Automatic rebuilds for settings UI app - -To make changes to `hearthspace-settings` available naturally from shell search, the session supervisor should optionally run a watcher. - -Recommended command: - -```sh -cargo watch -w crates/hearthspace-settings -w crates/hearthspace-common -w crates/hearthspace-ipc -x "build -p hearthspace-settings" -``` - -Alternative if using `watchexec`: - -```sh -watchexec \ - -w crates/hearthspace-settings \ - -w crates/hearthspace-common \ - -w crates/hearthspace-ipc \ - -- cargo build -p hearthspace-settings -``` - -The watcher should be optional and development-only. - -Possible CLI flags: - -```sh -cargo run -- --watch-apps -cargo run -- --no-watch-apps -``` - -Recommended default in dev mode: - -```txt -watch apps: enabled if cargo-watch or watchexec is installed -watch apps: disabled with a clear warning if neither is installed -``` - -Example warning: - -```txt -[session] cargo-watch not found; Hearthspace Settings will build on first launch instead of rebuilding automatically -``` - -## App search integration - -The shell's app search should be able to find: - -- normal system apps -- temporary Hearthspace development apps -- future installed Hearthspace apps - -The app indexer should scan: - -```txt -$HEARTHSPACE_SESSION_DIR/share/applications -~/.local/share/applications -/usr/local/share/applications -/usr/share/applications -``` - -If the app indexer relies on `XDG_DATA_DIRS`, ensure the session supervisor passes a modified `XDG_DATA_DIRS` to the shell. - -The generated settings app should show up when searching for: - -```txt -settings -hearthspace -config -preferences -``` - -## IPC expectations - -`settingsd` should expose an IPC API for: - -- get setting -- set setting -- list settings -- watch setting changes -- validate setting -- reset setting to default -- export effective config -- report config file paths - -The settings UI should use the same IPC API as the shell and compositor. - -The settings UI should not write config files directly. - -The compositor and shell should subscribe to relevant setting changes and apply live changes where possible. - -Example logical flow: - -```txt -hearthspace-settings app -→ settingsd.set("canvas.show_grid", false) -→ settingsd validates and writes config atomically -→ settingsd emits change event -→ shell/compositor receive change event -→ canvas grid updates -``` - -## Config file locations - -The session supervisor should not own settings storage, but all components should agree on XDG paths. - -User config: - -```txt -~/.config/hearthspace/config.toml -~/.config/hearthspace/keybindings.toml -~/.config/hearthspace/apps.toml -``` - -Runtime IPC: - -```txt -$XDG_RUNTIME_DIR/hearthspace/session-/settings.sock -``` - -State: - -```txt -~/.local/state/hearthspace/ -``` - -Cache: - -```txt -~/.cache/hearthspace/ -``` - -## CLI shape - -Recommended `hearthspace-session` CLI: - -```txt -Usage: hearthspace-session [OPTIONS] - -Options: - --dev - Run child processes through `cargo run -p ...`. - - --installed - Run installed child binaries directly. - - --wayland-display - Wayland display socket name. Defaults to hearthspace-dev- in dev mode. - - --watch-apps - Start development watchers for Hearthspace apps. - - --no-watch-apps - Do not start development watchers. - - --no-shell-restart - Do not restart shell if it exits. - - --no-settingsd-restart - Do not restart settingsd if it exits. - - --log-level - Set log level for session and children. - - --print-env - Print the generated session environment. - - --dry-run - Print what would be started without starting processes. -``` - -Use `clap` for CLI parsing. - -## Suggested Rust crates - -Likely useful dependencies: - -```toml -anyhow = "1" -camino = "1" -clap = { version = "4", features = ["derive", "env"] } -directories = "6" -nix = { version = "0.30", features = ["signal", "process"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time"] } -tracing = "0.1" -tracing-subscriber = "0.3" -which = "8" -``` - -Adjust versions to match the existing workspace. - -## Session supervisor implementation outline - -High-level pseudo-Rust: - -```rust -#[tokio::main] -async fn main() -> anyhow::Result<()> { - init_logging(); - - let args = Args::parse(); - let mode = resolve_mode(&args)?; - let paths = SessionPaths::create(&args)?; - let env = SessionEnv::new(&paths, &args)?; - - generate_dev_desktop_entries(&paths, &env)?; - - let mut supervisor = Supervisor::new(paths, env, mode); - - supervisor.start_settingsd().await?; - supervisor.wait_for_settingsd_ready().await?; - - supervisor.start_compositor().await?; - supervisor.wait_for_compositor_ready().await?; - - supervisor.start_shell().await?; - - if supervisor.should_watch_apps() { - supervisor.start_settings_app_watcher().await?; - } - - supervisor.monitor_until_exit().await?; - supervisor.shutdown().await?; - - Ok(()) -} -``` - -Core types: - -```rust -struct SessionPaths { - runtime_dir: Utf8PathBuf, - session_dir: Utf8PathBuf, - applications_dir: Utf8PathBuf, - settings_socket: Utf8PathBuf, - session_socket: Utf8PathBuf, -} - -struct SessionEnv { - wayland_display: String, - vars: Vec<(String, String)>, -} - -enum RunMode { - DevCargo, - InstalledBinaries, -} - -struct ManagedProcess { - name: String, - critical: bool, - restart_policy: RestartPolicy, - child: tokio::process::Child, -} - -enum RestartPolicy { - Never, - OnCrash { max_restarts: usize }, -} -``` - -## Acceptance criteria - -This task is complete when: - -1. `cargo run` from the workspace root starts `hearthspace-session`. -2. `hearthspace-session` starts `hearthspace-settingsd`. -3. `hearthspace-session` starts `hearthspace-compositor`. -4. `hearthspace-session` waits for compositor readiness without arbitrary sleeps. -5. `hearthspace-session` starts `hearthspace-shell`. -6. The shell can launch normal apps using the compositor's `WAYLAND_DISPLAY`. -7. The session generates a dev `.desktop` entry for `hearthspace-settings`. -8. The shell app search can find and launch `Hearthspace Settings`. -9. The settings app runs as a normal Wayland client inside Hearthspace. -10. The settings app can communicate with `hearthspace-settingsd`. -11. In dev mode, the settings app is rebuilt automatically when its code changes if a watcher tool is available. -12. If watcher tooling is unavailable, launching the settings app builds it on demand. -13. If the compositor exits, the session supervisor shuts down the shell, settings daemon, and watchers. -14. `Ctrl+C` on the session supervisor shuts down all child processes cleanly. -15. Logs clearly show process startup, readiness, and shutdown. -16. Each component remains directly runnable with `cargo run -p `. - -## Future installed-session direction - -Later, this architecture can map to a real desktop session. - -Possible installed files: - -```txt -/usr/bin/hearthspace-session -/usr/bin/hearthspace-compositor -/usr/bin/hearthspace-shell -/usr/bin/hearthspace-settingsd -/usr/bin/hearthspace-settings - -/usr/share/wayland-sessions/hearthspace.desktop -/usr/share/applications/hearthspace-settings.desktop -``` - -Display manager session file: - -```ini -[Desktop Entry] -Name=Hearthspace -Comment=A spatial Linux desktop for persistent, local-first computing -Exec=hearthspace-session --installed -Type=Application -DesktopNames=Hearthspace -``` - -Future systemd user direction: - -```txt -hearthspace-session.target -├─ hearthspace-settingsd.service -├─ hearthspace-compositor.service -└─ hearthspace-shell.service -``` - -Do not start with systemd user units unless they become useful. The Rust session supervisor should remain the main development entrypoint for now. - -## Design principle - -The session supervisor should make development feel like a real desktop session without requiring the project to be installed as a real desktop session. - -The intended experience: - -```sh -cargo run -``` - -Then, inside Hearthspace: - -1. Search for apps. -2. Launch normal Linux apps. -3. Search for “Hearthspace Settings.” -4. Launch the settings UI as a normal app. -5. Edit settings. -6. Watch shell/compositor behavior update through `settingsd`. - -This keeps the compositor, shell, settings daemon, and settings app separate while making the whole system feel integrated and dogfoodable. -- 2.51.2 From d46b34979312fbabee455f4d63a48f2fdc5352c3 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 04:08:32 -0700 Subject: [PATCH 20/28] chore: complete daemon todo --- crates/hearthspace-settingsd/src/main.rs | 62 ++++++++++++--- todos/DAEMONS.md | 97 ------------------------ 2 files changed, 52 insertions(+), 107 deletions(-) delete mode 100644 todos/DAEMONS.md diff --git a/crates/hearthspace-settingsd/src/main.rs b/crates/hearthspace-settingsd/src/main.rs index bf08cb8..37b1475 100644 --- a/crates/hearthspace-settingsd/src/main.rs +++ b/crates/hearthspace-settingsd/src/main.rs @@ -2,7 +2,8 @@ use std::{ fs, io, io::{BufRead, Write}, os::unix::net::{UnixListener, UnixStream}, - path::Path, + path::{Path, PathBuf}, + process, }; use hearthspace_ipc::{ @@ -14,7 +15,13 @@ use tracing::{error, info, warn}; fn main() -> Result<(), Box> { initialize_tracing(); - let listener = bind_settings_socket()?; + let listener = match bind_settings_socket()? { + BindSettingsSocket::Listening(listener) => listener, + BindSettingsSocket::AlreadyRunning(path) => { + warn!(socket = %path.display(), "hearthspace settings daemon is already running"); + return Ok(()); + } + }; info!("hearthspace settings daemon is listening"); for stream in listener.incoming() { @@ -38,17 +45,23 @@ fn initialize_tracing() { } } -fn bind_settings_socket() -> io::Result { +enum BindSettingsSocket { + Listening(UnixListener), + AlreadyRunning(PathBuf), +} + +fn bind_settings_socket() -> io::Result { let path = settings_socket_path()?; + bind_settings_socket_at(path) +} + +fn bind_settings_socket_at(path: PathBuf) -> io::Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } if UnixStream::connect(&path).is_ok() { - return Err(io::Error::new( - io::ErrorKind::AddrInUse, - format!("settings daemon is already listening at {}", path.display()), - )); + return Ok(BindSettingsSocket::AlreadyRunning(path)); } match fs::remove_file(&path) { @@ -57,7 +70,7 @@ fn bind_settings_socket() -> io::Result { Err(error) => return Err(error), } - UnixListener::bind(path) + UnixListener::bind(path).map(BindSettingsSocket::Listening) } fn handle_connection(stream: UnixStream) -> io::Result<()> { @@ -107,7 +120,14 @@ fn save_settings_to_path(settings: Settings, path: impl AsRef) -> io::Resu if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - fs::write(path, format_settings(settings)) + let temp_path = path.with_extension(format!("tmp-{}", process::id())); + let mut file = fs::File::create(&temp_path)?; + file.write_all(format_settings(settings).as_bytes())?; + file.sync_all()?; + drop(file); + fs::rename(&temp_path, path).inspect_err(|_| { + let _ = fs::remove_file(&temp_path); + }) } fn parse_settings(contents: &str) -> io::Result { @@ -159,7 +179,7 @@ mod tests { path::Path, }; - use super::handle_connection_with_config_path; + use super::{BindSettingsSocket, bind_settings_socket_at, handle_connection_with_config_path}; #[test] fn get_returns_default_settings_when_config_is_missing() { @@ -206,6 +226,28 @@ mod tests { assert!(!config_path.exists()); } + #[test] + fn bind_exits_cleanly_when_listener_is_already_active() { + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("settings.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + + let result = bind_settings_socket_at(socket_path.clone()).unwrap(); + + assert!(matches!(result, BindSettingsSocket::AlreadyRunning(path) if path == socket_path)); + } + + #[test] + fn bind_removes_stale_socket_file() { + let dir = tempfile::tempdir().unwrap(); + let socket_path = dir.path().join("settings.sock"); + std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + + let result = bind_settings_socket_at(socket_path).unwrap(); + + assert!(matches!(result, BindSettingsSocket::Listening(_))); + } + fn round_trip(request: &str, config_path: &Path) -> String { let (server, mut client) = UnixStream::pair().unwrap(); client.write_all(request.as_bytes()).unwrap(); diff --git a/todos/DAEMONS.md b/todos/DAEMONS.md deleted file mode 100644 index 61b7db0..0000000 --- a/todos/DAEMONS.md +++ /dev/null @@ -1,97 +0,0 @@ -# Daemons - -Hearthspace now has standalone helper daemons, starting with -`hearthspace-settingsd`. These should remain real binaries rather than hidden -`hearthspace` modes so they can be launched, tested, packaged, and eventually -socket-activated independently. - -## Current State - -- `hearthspace-settingsd` is a standalone Cargo binary. -- `hearthspace-settings` is a standalone Xilem frontend binary. -- The settings frontend connects to `$XDG_RUNTIME_DIR/hearthspace-settingsd.sock`. -- The settings daemon writes `~/.config/hearthspace/config.toml`. -- Nothing currently autostarts `hearthspace-settingsd`; it must be launched - manually before `hearthspace-settings` can persist changes. - -## Near-Term Autostart - -The compositor should eventually start Hearthspace-owned daemons during session -startup, alongside the shell. - -For `settingsd`, this likely means: - -- Add daemon spawning to compositor startup, near shell spawning. -- Use `std::env::current_exe()` only for the main `hearthspace` binary; helper - daemons are separate executables, so production startup needs a reliable way to - locate sibling binaries such as `hearthspace-settingsd`. -- Preserve the user's runtime/config environment, especially `XDG_RUNTIME_DIR`, - `HOME`, and `XDG_CONFIG_HOME`. -- Log daemon spawn failures, but do not fail compositor startup. -- Treat an already-running daemon as non-fatal. -- Decide whether the compositor should keep child handles and terminate daemons - on shutdown, or leave process lifetime to the session/runtime manager. - -The minimal acceptable first version is fire-and-forget spawn with clear logging, -provided the daemon exits cleanly when another instance is already listening. - -## Daemon Robustness Requirements - -Before compositor autostart, each daemon should be safe to start repeatedly: - -- Check whether its socket already has a live listener. -- Exit cleanly or return a distinct non-error status when already running. -- Remove stale socket files only after verifying no listener is active. -- Keep protocol errors scoped to the client connection. -- Avoid panicking on malformed client input. -- Keep config writes atomic once settings become more important than the current - scaffold. - -## Frontend Behavior - -Frontend apps should not own settings persistence directly once a daemon exists. - -Settings frontends should: - -- Connect to the daemon socket. -- Show a clear unavailable/backend-disconnected status if the daemon is missing. -- Retry or expose a refresh action once the UI grows beyond the current scaffold. -- Avoid writing `config.toml` directly except in dedicated migration/recovery - tooling. - -## Future Systemd User Integration - -Once Hearthspace has install packaging, daemon startup should move toward systemd -user services or socket activation. - -Likely units: - -- `hearthspace-settingsd.service` -- `hearthspace-settingsd.socket` - -Benefits: - -- Daemons start on demand when a client connects. -- Process lifecycle is managed outside the compositor. -- Crashes and restarts can use standard user-service policy. -- The compositor and frontend only need to know the socket path. - -Open packaging questions: - -- Where unit files are installed. -- Whether Hearthspace enables units during install or first run. -- How native sessions, nested sessions, and development runs should differ. -- Whether daemon sockets should always live under the user's real - `XDG_RUNTIME_DIR` or a Hearthspace-specific nested runtime directory. - -## Testing Expectations - -Daemon changes should have focused tests that do not depend on global user state: - -- Inject config paths rather than mutating `HOME` or `XDG_CONFIG_HOME` when - possible. -- Use temporary directories for config/socket paths. -- Use `UnixStream::pair()` for request/response tests where a real bound socket - is unnecessary. -- Add one smoke test with a real `UnixListener` only when socket binding behavior - itself is under test. -- 2.51.2 From ab8ab33c4a587f861ac919c5b7d0eeba2de96489 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 04:48:59 -0700 Subject: [PATCH 21/28] feat: add initial monitor rearranging --- .../src/compositor/handlers.rs | 2 +- .../src/compositor/input.rs | 47 +- .../src/compositor/mod.rs | 2 + .../src/compositor/windows.rs | 2 +- crates/hearthspace-ipc/src/lib.rs | 199 +++++- crates/hearthspace-settings/src/main.rs | 628 +++++++++++++++++- crates/hearthspace-settingsd/src/main.rs | 146 +++- 7 files changed, 992 insertions(+), 34 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/handlers.rs b/crates/hearthspace-compositor/src/compositor/handlers.rs index da3f965..736190e 100644 --- a/crates/hearthspace-compositor/src/compositor/handlers.rs +++ b/crates/hearthspace-compositor/src/compositor/handlers.rs @@ -121,7 +121,7 @@ impl XdgShellHandler for App { ); self.drag = Some(DragState { window_id: self.windows[window_index].id, - pointer_start: self.pointer_location, + pointer_start: self.raw_pointer_location, window_start: self.windows[window_index].position, }); self.request_redraw(); diff --git a/crates/hearthspace-compositor/src/compositor/input.rs b/crates/hearthspace-compositor/src/compositor/input.rs index f8a5e87..e3247b0 100644 --- a/crates/hearthspace-compositor/src/compositor/input.rs +++ b/crates/hearthspace-compositor/src/compositor/input.rs @@ -47,7 +47,10 @@ pub(in crate::compositor) fn handle_input_event( } InputEvent::PointerMotion { event } => { trace!(delta = ?event.delta(), time = event.time_msec(), "relative pointer motion input"); - state.apply_pointer_motion(state.pointer_location + event.delta(), event.time_msec()); + state.apply_pointer_motion( + relative_pointer_location(state.raw_pointer_location, event.delta()), + event.time_msec(), + ); } InputEvent::PointerButton { event } => { let time = event.time_msec(); @@ -97,7 +100,7 @@ pub(in crate::compositor) fn handle_input_event( state.set_keyboard_focus_to_window(window_index, surface); state.drag = Some(DragState { window_id: state.windows[window_index].id, - pointer_start: state.pointer_location, + pointer_start: state.raw_pointer_location, window_start: state.windows[window_index].position, }); debug!( @@ -340,7 +343,10 @@ impl App { pub(super) fn synthesize_pointer_motion_rel(&mut self, delta: Point) { trace!(?delta, "synthesizing relative pointer motion"); - self.apply_pointer_motion(self.pointer_location + delta, event_time_msec()); + self.apply_pointer_motion( + relative_pointer_location(self.raw_pointer_location, delta), + event_time_msec(), + ); } pub(super) fn synthesize_pointer_button(&mut self, button: u32, state: ButtonState) { @@ -386,7 +392,7 @@ impl App { self.set_keyboard_focus_to_window(window_index, surface); self.drag = Some(DragState { window_id: self.windows[window_index].id, - pointer_start: self.pointer_location, + pointer_start: self.raw_pointer_location, window_start: self.windows[window_index].position, }); debug!( @@ -489,13 +495,14 @@ impl App { fn apply_pointer_motion(&mut self, location: Point, time: u32) { let previous_location = self.pointer_location; + self.raw_pointer_location = location; self.pointer_location = clamp_point_to_output(location, self.output_logical_size()); if self.software_cursor_visible && self.pointer_location != previous_location { self.request_redraw(); } if let Some(drag) = self.drag.as_ref() { - let delta = self.pointer_location - drag.pointer_start; + let delta = self.raw_pointer_location - drag.pointer_start; let new_position = super::CanvasPoint { x: drag.window_start.x + (delta.x / self.viewport_scale).round() as i32, y: drag.window_start.y + (delta.y / self.viewport_scale).round() as i32, @@ -512,7 +519,7 @@ impl App { if let Some(resize) = self.resize.as_ref() { let edges = resize.edges; self.cursor_icon = resize_cursor_icon(edges); - self.update_resize(self.pointer_location); + self.update_resize(self.raw_pointer_location); return; } @@ -552,6 +559,7 @@ impl App { let output_size = self.output_logical_size(); let previous_location = self.pointer_location; self.pointer_location = clamp_point_to_output(self.pointer_location, output_size); + self.raw_pointer_location = self.pointer_location; if self.software_cursor_visible && self.pointer_location != previous_location { self.request_redraw(); } @@ -639,9 +647,36 @@ fn clamp_point_to_output( )) } +fn relative_pointer_location( + raw_location: Point, + delta: Point, +) -> Point { + raw_location + delta +} + fn event_time_msec() -> u32 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_millis() as u32) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use smithay::utils::Size; + + use super::*; + + #[test] + fn relative_motion_continues_from_unclamped_location_after_visible_pointer_hits_edge() { + let output_size: Size = (100, 100).into(); + let raw_location = Point::from((120.0, 50.0)); + let visible_location = clamp_point_to_output(raw_location, output_size); + + assert_eq!(visible_location, Point::from((99.0, 50.0))); + assert_eq!( + relative_pointer_location(raw_location, Point::from((20.0, 0.0))), + Point::from((140.0, 50.0)) + ); + } +} diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 9214917..2889bb3 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -181,6 +181,7 @@ struct App { next_spawn_position: CanvasPoint, spawn_offset: i32, pointer_location: Point, + raw_pointer_location: Point, active_touch_slot: Option, scroll_zooms_without_super: bool, outputs: OutputSet, @@ -447,6 +448,7 @@ pub(in crate::compositor) fn initialize_app( next_spawn_position: CanvasPoint { x: 80, y: 96 }, spawn_offset: 0, pointer_location: (0.0, 0.0).into(), + raw_pointer_location: (0.0, 0.0).into(), active_touch_slot: None, scroll_zooms_without_super: options.scroll_zooms_without_super, outputs: OutputSet::new(primary_output), diff --git a/crates/hearthspace-compositor/src/compositor/windows.rs b/crates/hearthspace-compositor/src/compositor/windows.rs index b621a89..e520f1b 100644 --- a/crates/hearthspace-compositor/src/compositor/windows.rs +++ b/crates/hearthspace-compositor/src/compositor/windows.rs @@ -466,7 +466,7 @@ impl App { self.resize = Some(ResizeState { window_id: window.id, edges, - pointer_start: self.pointer_location, + pointer_start: self.raw_pointer_location, initial_position: window.position, initial_content_size, }); diff --git a/crates/hearthspace-ipc/src/lib.rs b/crates/hearthspace-ipc/src/lib.rs index e3af913..7bd167f 100644 --- a/crates/hearthspace-ipc/src/lib.rs +++ b/crates/hearthspace-ipc/src/lib.rs @@ -6,15 +6,53 @@ use std::{ path::PathBuf, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Settings { pub test: bool, + pub monitors: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MonitorConfig { + pub id: String, + pub name: String, + pub width: i32, + pub height: i32, + pub x: i32, + pub y: i32, +} + +impl Default for Settings { + fn default() -> Self { + Self { + test: false, + monitors: vec![ + MonitorConfig { + id: "primary".to_string(), + name: "Primary".to_string(), + width: 1920, + height: 1080, + x: 0, + y: 0, + }, + MonitorConfig { + id: "secondary".to_string(), + name: "Secondary".to_string(), + width: 1920, + height: 1080, + x: 1920, + y: 0, + }, + ], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsRequest { Get, SetTest(bool), + SetMonitorPosition { id: String, x: i32, y: i32 }, } pub fn settings_socket_path() -> io::Result { @@ -36,18 +74,46 @@ pub fn client_set_test(test: bool) -> io::Result { send_settings_request(&format!("set test {value}\n")) } +pub fn client_set_monitor_position(id: &str, x: i32, y: i32) -> io::Result { + if id.is_empty() || id.chars().any(char::is_whitespace) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("monitor id cannot contain whitespace: {id:?}"), + )); + } + send_settings_request(&format!("set monitor {id} {x} {y}\n")) +} + pub fn parse_settings_request(line: &str) -> Result { let parts = line.split_whitespace().collect::>(); match parts.as_slice() { ["get"] => Ok(SettingsRequest::Get), ["set", "test", value] => Ok(SettingsRequest::SetTest(parse_bool(value)?)), + ["set", "monitor", id, x, y] => Ok(SettingsRequest::SetMonitorPosition { + id: (*id).to_string(), + x: parse_i32(x, "monitor x")?, + y: parse_i32(y, "monitor y")?, + }), [] => Err("empty request".into()), _ => Err(format!("unknown request: {line:?}")), } } pub fn settings_response(settings: Settings) -> String { - format!("ok test {}\n", settings.test) + let mut response = format!("ok test {}", settings.test); + for monitor in settings.monitors { + response.push_str(&format!( + " monitor {} {} {} {} {} {}", + encode_token(&monitor.id), + monitor.width, + monitor.height, + monitor.x, + monitor.y, + encode_token(&monitor.name) + )); + } + response.push('\n'); + response } pub fn error_response(error: impl std::fmt::Display) -> String { @@ -68,9 +134,7 @@ fn parse_settings_response(response: &str) -> io::Result { let line = response.trim(); let parts = line.split_whitespace().collect::>(); match parts.as_slice() { - ["ok", "test", value] => Ok(Settings { - test: parse_bool(value).map_err(io::Error::other)?, - }), + ["ok", "test", value, rest @ ..] => parse_settings_response_parts(value, rest), ["err", message @ ..] => Err(io::Error::other(message.join(" "))), _ => Err(io::Error::new( io::ErrorKind::InvalidData, @@ -79,6 +143,94 @@ fn parse_settings_response(response: &str) -> io::Result { } } +fn parse_settings_response_parts(test: &str, rest: &[&str]) -> io::Result { + let mut settings = Settings { + test: parse_bool(test).map_err(io::Error::other)?, + monitors: Vec::new(), + }; + let chunks = rest.chunks_exact(7); + if !chunks.remainder().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid settingsd response monitor field count", + )); + } + for monitor in chunks { + if monitor[0] != "monitor" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid settingsd response monitor marker: {:?}", + monitor[0] + ), + )); + } + settings.monitors.push(MonitorConfig { + id: decode_token(monitor[1])?, + width: parse_i32(monitor[2], "monitor width").map_err(io::Error::other)?, + height: parse_i32(monitor[3], "monitor height").map_err(io::Error::other)?, + x: parse_i32(monitor[4], "monitor x").map_err(io::Error::other)?, + y: parse_i32(monitor[5], "monitor y").map_err(io::Error::other)?, + name: decode_token(monitor[6])?, + }); + } + if settings.monitors.is_empty() { + settings.monitors = Settings::default().monitors; + } + Ok(settings) +} + +fn encode_token(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn decode_token(value: &str) -> io::Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid encoded token: {value:?}"), + )); + } + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid token: {error}"), + ) + })?; + decoded.push(u8::from_str_radix(hex, 16).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid token: {error}"), + ) + })?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn parse_i32(value: &str, label: &str) -> Result { + value + .parse::() + .map_err(|error| format!("invalid {label}: {error}")) +} + fn parse_bool(value: &str) -> Result { match value { "true" => Ok(true), @@ -98,13 +250,44 @@ mod tests { parse_settings_request("set test true\n"), Ok(SettingsRequest::SetTest(true)) ); + assert_eq!( + parse_settings_request("set monitor primary 120 -40\n"), + Ok(SettingsRequest::SetMonitorPosition { + id: "primary".to_string(), + x: 120, + y: -40, + }) + ); } #[test] fn parses_ipc_response() { assert_eq!( - parse_settings_response("ok test true\n").unwrap(), - Settings { test: true } + parse_settings_response( + "ok test true monitor primary 1920 1080 120 -40 Primary monitor secondary 1280 720 2040 0 Secondary%20Display\n" + ) + .unwrap(), + Settings { + test: true, + monitors: vec![ + MonitorConfig { + id: "primary".to_string(), + name: "Primary".to_string(), + width: 1920, + height: 1080, + x: 120, + y: -40, + }, + MonitorConfig { + id: "secondary".to_string(), + name: "Secondary Display".to_string(), + width: 1280, + height: 720, + x: 2040, + y: 0, + }, + ], + } ); } } diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 8c0fd2c..00faa67 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -1,4 +1,20 @@ -use hearthspace_ipc::{Settings, client_get_settings, client_set_test}; +use xilem::core::{MessageCtx, MessageResult, Mut, View, ViewMarker}; +use xilem::masonry::accesskit::{Node, Role}; +use xilem::masonry::core::{ + AccessCtx, AccessEvent, ChildrenIds, CursorIcon, EventCtx, LayoutCtx, MeasureCtx, PaintCtx, + PointerButton, PointerButtonEvent, PointerEvent, PointerUpdate, PropertiesMut, PropertiesRef, + QueryCtx, RegisterCtx, TextEvent, Widget, +}; +use xilem::masonry::imaging::Painter; +use xilem::masonry::kurbo::{Axis, Point, Rect, Size, Stroke}; +use xilem::masonry::layout::{LenReq, Length}; +use xilem::masonry::palette; +use xilem::peniko::Color; +use xilem::{Pod, ViewCtx}; + +use hearthspace_ipc::{ + MonitorConfig, Settings, client_get_settings, client_set_monitor_position, client_set_test, +}; use xilem::{ EventLoop, WidgetView, WindowOptions, Xilem, dpi::LogicalSize, @@ -7,6 +23,12 @@ use xilem::{ view::{checkbox, flex_col, label, sized_box}, }; +const EDITOR_PADDING: f64 = 24.0; +const MONITOR_SNAP: i32 = 10; +const MONITOR_LAYOUT_CANVAS_MULTIPLIER: f64 = 1.2; +const EMPTY_MONITOR_CANVAS_WIDTH: i32 = 1920; +const EMPTY_MONITOR_CANVAS_HEIGHT: i32 = 1080; + struct SettingsApp { settings: Settings, status: String, @@ -19,8 +41,8 @@ fn main() -> Result<(), Box> { state, app_logic, WindowOptions::new("Hearthspace Settings") - .with_initial_inner_size(LogicalSize::new(420.0, 220.0)) - .with_min_inner_size(LogicalSize::new(320.0, 180.0)), + .with_initial_inner_size(LogicalSize::new(760.0, 620.0)) + .with_min_inner_size(LogicalSize::new(560.0, 480.0)), ); app.run_in(EventLoop::with_user_event())?; @@ -50,21 +72,25 @@ fn initial_state() -> SettingsApp { fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { let test_checked = state.settings.test; + let monitor_summary = format_monitor_summary(&state.settings.monitors); sized_box( flex_col(( label("Hearthspace Settings") .font(GenericFamily::SystemUi) .text_size(22.0), - label("This is a minimal scaffold for future settings."), + label("Drag monitors to arrange their relative positions."), + sized_box(monitor_layout_editor()).fixed_height(320.0.px()), + label(monitor_summary), checkbox("test", test_checked, |state: &mut SettingsApp, checked| { let previous = state.settings.test; state.settings.test = checked; match client_set_test(checked) { Ok(settings) => { + let saved_test = settings.test; state.settings = settings; - state.status = format!("Saved test = {}", settings.test); + state.status = format!("Saved test = {saved_test}"); } Err(error) => { state.settings.test = previous; @@ -79,3 +105,595 @@ fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { ) .padding(20.0.px()) } + +fn format_monitor_summary(monitors: &[MonitorConfig]) -> String { + monitors + .iter() + .map(|monitor| { + format!( + "{}: {}x{} at {},{}", + monitor.name, monitor.width, monitor.height, monitor.x, monitor.y + ) + }) + .collect::>() + .join(" | ") +} + +fn monitor_layout_editor() -> MonitorLayoutEditor { + MonitorLayoutEditor +} + +struct MonitorLayoutEditor; + +impl ViewMarker for MonitorLayoutEditor {} + +impl View for MonitorLayoutEditor { + type Element = Pod; + type ViewState = (); + + fn build( + &self, + ctx: &mut ViewCtx, + state: &mut SettingsApp, + ) -> (Self::Element, Self::ViewState) { + ( + ctx.with_action_widget(|ctx| { + ctx.create_pod(MonitorLayoutWidget::new(state.settings.monitors.clone())) + }), + (), + ) + } + + fn rebuild( + &self, + _prev: &Self, + (): &mut Self::ViewState, + _ctx: &mut ViewCtx, + mut element: Mut<'_, Self::Element>, + state: &mut SettingsApp, + ) { + element + .widget + .set_monitors(state.settings.monitors.clone(), &mut element.ctx); + } + + fn teardown( + &self, + (): &mut Self::ViewState, + ctx: &mut ViewCtx, + element: Mut<'_, Self::Element>, + ) { + ctx.teardown_action_source(element); + } + + fn message( + &self, + (): &mut Self::ViewState, + message: &mut MessageCtx, + _element: Mut<'_, Self::Element>, + state: &mut SettingsApp, + ) -> MessageResult<()> { + let Some(action) = message.take_message::() else { + return MessageResult::Stale; + }; + apply_monitor_editor_action(state, *action); + MessageResult::RequestRebuild + } +} + +fn apply_monitor_editor_action(state: &mut SettingsApp, action: MonitorEditorAction) { + let Some(monitor) = state + .settings + .monitors + .iter_mut() + .find(|monitor| monitor.id == action.id) + else { + state.status = format!("Unknown monitor: {}", action.id); + return; + }; + monitor.x = action.x; + monitor.y = action.y; + + if action.commit { + match client_set_monitor_position(&action.id, action.x, action.y) { + Ok(settings) => { + state.settings = settings; + state.status = format!("Saved monitor layout for {}", action.id); + } + Err(error) => state.status = format!("Failed to save monitor layout: {error}"), + } + } else { + state.status = format!("Moving {} to {},{}", monitor.name, action.x, action.y); + } +} + +#[derive(Debug)] +struct MonitorEditorAction { + id: String, + x: i32, + y: i32, + commit: bool, +} + +#[derive(Debug)] +struct MonitorLayoutWidget { + monitors: Vec, + drag: Option, +} + +#[derive(Debug)] +struct MonitorDrag { + id: String, + start_pointer: Point, + start_x: i32, + start_y: i32, + scale: f64, +} + +impl MonitorLayoutWidget { + fn new(monitors: Vec) -> Self { + Self { + monitors, + drag: None, + } + } + + fn set_monitors( + &mut self, + monitors: Vec, + ctx: &mut xilem::masonry::core::MutateCtx<'_>, + ) { + if self.monitors != monitors { + self.monitors = monitors; + ctx.request_render(); + } + } + + fn layout_metrics(&self, size: Size) -> LayoutMetrics { + let canvas_size = monitor_canvas_size(&self.monitors); + let scale = layout_scale_for_canvas(&self.monitors, size); + let (combined_width, combined_height) = monitor_canvas_span(&self.monitors); + + LayoutMetrics { + origin_x: (f64::from(combined_width) - canvas_size.width) / 2.0, + origin_y: (f64::from(combined_height) - canvas_size.height) / 2.0, + scale, + } + } + + fn monitor_rect(&self, monitor: &MonitorConfig, metrics: LayoutMetrics) -> Rect { + Rect::new( + EDITOR_PADDING + (f64::from(monitor.x) - metrics.origin_x) * metrics.scale, + EDITOR_PADDING + (f64::from(monitor.y) - metrics.origin_y) * metrics.scale, + EDITOR_PADDING + + (f64::from(monitor.x + monitor.width) - metrics.origin_x) * metrics.scale, + EDITOR_PADDING + + (f64::from(monitor.y + monitor.height) - metrics.origin_y) * metrics.scale, + ) + } + + fn hit_monitor(&self, point: Point, metrics: LayoutMetrics) -> Option<&MonitorConfig> { + self.monitors + .iter() + .rev() + .find(|monitor| self.monitor_rect(monitor, metrics).contains(point)) + } + + fn apply_drag_position(&mut self, id: &str, x: i32, y: i32) { + if let Some(monitor) = self.monitors.iter_mut().find(|monitor| monitor.id == id) { + monitor.x = x; + monitor.y = y; + } + } + + fn drag_position(&self, drag: &MonitorDrag, point: Point) -> (i32, i32) { + let x = drag.start_x + ((point.x - drag.start_pointer.x) / drag.scale).round() as i32; + let y = drag.start_y + ((point.y - drag.start_pointer.y) / drag.scale).round() as i32; + snap_monitor_to_neighbor(&self.monitors, &drag.id, snap_position(x), snap_position(y)) + } +} + +impl Widget for MonitorLayoutWidget { + type Action = MonitorEditorAction; + + fn on_pointer_event( + &mut self, + ctx: &mut EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + event: &PointerEvent, + ) { + match event { + PointerEvent::Down(PointerButtonEvent { + button: None | Some(PointerButton::Primary), + state, + .. + }) => { + let point = ctx.local_position(state.position); + let metrics = self.layout_metrics(ctx.content_box().size()); + if let Some(monitor) = self.hit_monitor(point, metrics) { + ctx.capture_pointer(); + ctx.request_focus(); + ctx.set_handled(); + self.drag = Some(MonitorDrag { + id: monitor.id.clone(), + start_pointer: point, + start_x: monitor.x, + start_y: monitor.y, + scale: metrics.scale, + }); + } + } + PointerEvent::Move(PointerUpdate { current, .. }) if ctx.is_active() => { + if let Some(drag) = &self.drag { + let point = ctx.local_position(current.position); + let (x, y) = self.drag_position(drag, point); + let id = drag.id.clone(); + self.apply_drag_position(&id, x, y); + ctx.submit_action::(MonitorEditorAction { + id, + x, + y, + commit: false, + }); + ctx.request_render(); + ctx.set_handled(); + } + } + PointerEvent::Up(PointerButtonEvent { state, .. }) if ctx.is_active() => { + if let Some(drag) = self.drag.take() { + let point = ctx.local_position(state.position); + let (x, y) = self.drag_position(&drag, point); + self.apply_drag_position(&drag.id, x, y); + ctx.submit_action::(MonitorEditorAction { + id: drag.id, + x, + y, + commit: true, + }); + ctx.request_render(); + ctx.set_handled(); + } + } + PointerEvent::Cancel(..) => self.drag = None, + _ => {} + } + } + + fn on_text_event( + &mut self, + _ctx: &mut EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &AccessEvent, + ) { + } + + fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> Length { + match axis { + Axis::Horizontal => 520.0.px(), + Axis::Vertical => 300.0.px(), + } + } + + fn layout(&mut self, _ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) {} + + fn paint( + &mut self, + ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + painter: &mut Painter<'_>, + ) { + let content = ctx.content_box(); + painter + .fill(content, Color::from_rgb8(0x13, 0x17, 0x22)) + .draw(); + + let metrics = self.layout_metrics(content.size()); + for monitor in &self.monitors { + let rect = self.monitor_rect(monitor, metrics); + let active = self.drag.as_ref().is_some_and(|drag| drag.id == monitor.id); + let fill = if active { + Color::from_rgb8(0x74, 0x9C, 0xFF) + } else { + Color::from_rgb8(0x38, 0x55, 0xA3) + }; + painter.fill(rect, fill).draw(); + painter + .stroke(rect, &Stroke::new(2.0), palette::css::WHITE) + .draw(); + } + } + + fn accessibility_role(&self) -> Role { + Role::Canvas + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + node: &mut Node, + ) { + node.set_label("Drag monitor rectangles to configure their arrangement"); + } + + fn children_ids(&self) -> ChildrenIds { + ChildrenIds::new() + } + + fn get_cursor(&self, ctx: &QueryCtx<'_>, pos: Point) -> CursorIcon { + let metrics = self.layout_metrics(ctx.content_box().size()); + if self.hit_monitor(ctx.to_local(pos), metrics).is_some() { + CursorIcon::Grab + } else { + CursorIcon::Default + } + } +} + +#[derive(Clone, Copy)] +struct LayoutMetrics { + origin_x: f64, + origin_y: f64, + scale: f64, +} + +fn monitor_canvas_size(monitors: &[MonitorConfig]) -> Size { + let (width, height) = monitor_canvas_span(monitors); + Size::new( + f64::from(width) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + f64::from(height) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) +} + +fn monitor_canvas_span(monitors: &[MonitorConfig]) -> (i32, i32) { + if monitors.is_empty() { + return (EMPTY_MONITOR_CANVAS_WIDTH, EMPTY_MONITOR_CANVAS_HEIGHT); + } + + ( + combined_monitor_width(monitors), + combined_monitor_height(monitors), + ) +} + +fn combined_monitor_width(monitors: &[MonitorConfig]) -> i32 { + monitors.iter().map(|monitor| monitor.width.max(1)).sum() +} + +fn combined_monitor_height(monitors: &[MonitorConfig]) -> i32 { + monitors.iter().map(|monitor| monitor.height.max(1)).sum() +} + +fn layout_scale_for_canvas(monitors: &[MonitorConfig], size: Size) -> f64 { + let canvas_size = monitor_canvas_size(monitors); + let available_width = (size.width - EDITOR_PADDING * 2.0).max(1.0); + let available_height = (size.height - EDITOR_PADDING * 2.0).max(1.0); + (available_width / canvas_size.width) + .min(available_height / canvas_size.height) + .clamp(0.03, 1.0) +} + +#[cfg(test)] +fn offset_monitor(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { + if let Some(monitor) = monitors.iter_mut().find(|monitor| monitor.id == id) { + monitor.x = x; + monitor.y = y; + } +} + +fn snap_position(value: i32) -> i32 { + let half = MONITOR_SNAP / 2; + if value >= 0 { + ((value + half) / MONITOR_SNAP) * MONITOR_SNAP + } else { + ((value - half) / MONITOR_SNAP) * MONITOR_SNAP + } +} + +fn snap_monitor_to_neighbor(monitors: &[MonitorConfig], id: &str, x: i32, y: i32) -> (i32, i32) { + let Some(monitor) = monitors.iter().find(|monitor| monitor.id == id) else { + return (x, y); + }; + let candidates = monitors + .iter() + .filter(|other| other.id != id) + .flat_map(|other| bordered_positions_for(monitor, other, x, y)); + + candidates + .min_by_key(|(candidate_x, candidate_y)| { + let dx = i64::from(*candidate_x - x); + let dy = i64::from(*candidate_y - y); + dx * dx + dy * dy + }) + .unwrap_or((x, y)) +} + +fn bordered_positions_for( + monitor: &MonitorConfig, + other: &MonitorConfig, + x: i32, + y: i32, +) -> [(i32, i32); 4] { + let left_of_other = other.x - monitor.width; + let right_of_other = other.x + other.width; + let above_other = other.y - monitor.height; + let below_other = other.y + other.height; + + [ + ( + left_of_other, + clamp_overlap(y, monitor.height, other.y, other.height), + ), + ( + right_of_other, + clamp_overlap(y, monitor.height, other.y, other.height), + ), + ( + clamp_overlap(x, monitor.width, other.x, other.width), + above_other, + ), + ( + clamp_overlap(x, monitor.width, other.x, other.width), + below_other, + ), + ] +} + +fn clamp_overlap(position: i32, size: i32, other_position: i32, other_size: i32) -> i32 { + let size = size.max(1); + let other_size = other_size.max(1); + position.clamp(other_position - size + 1, other_position + other_size - 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_canvas_size_uses_combined_dimensions_with_multiplier() { + let monitors = vec![ + MonitorConfig { + id: "left".to_string(), + name: "Left".to_string(), + width: 1280, + height: 720, + x: -1280, + y: 120, + }, + MonitorConfig { + id: "main".to_string(), + name: "Main".to_string(), + width: 1920, + height: 1080, + x: 0, + y: 0, + }, + ]; + + assert_eq!(combined_monitor_width(&monitors), 3200); + assert_eq!(combined_monitor_height(&monitors), 1800); + assert_eq!( + monitor_canvas_size(&monitors), + Size::new( + 3200.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + 1800.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) + ); + } + + #[test] + fn monitor_canvas_size_does_not_cap_large_monitors() { + let monitors = vec![ + MonitorConfig { + id: "ultrawide".to_string(), + name: "Ultrawide".to_string(), + width: 5120, + height: 1440, + x: 0, + y: 0, + }, + MonitorConfig { + id: "hidpi".to_string(), + name: "HiDPI".to_string(), + width: 3840, + height: 2160, + x: 5120, + y: 0, + }, + ]; + + assert_eq!(combined_monitor_width(&monitors), 8960); + assert_eq!(combined_monitor_height(&monitors), 3600); + assert_eq!( + monitor_canvas_size(&monitors), + Size::new( + 8960.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + 3600.0 * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) + ); + } + + #[test] + fn monitor_canvas_size_uses_default_only_when_no_monitors_exist() { + assert_eq!( + monitor_canvas_size(&[]), + Size::new( + f64::from(EMPTY_MONITOR_CANVAS_WIDTH) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + f64::from(EMPTY_MONITOR_CANVAS_HEIGHT) * MONITOR_LAYOUT_CANVAS_MULTIPLIER, + ) + ); + } + + #[test] + fn layout_scale_does_not_change_when_monitors_are_dragged() { + let mut monitors = Settings::default().monitors; + let size = Size::new(760.0, 320.0); + let scale = layout_scale_for_canvas(&monitors, size); + + offset_monitor(&mut monitors, "secondary", -3200, 900); + + assert_eq!(layout_scale_for_canvas(&monitors, size), scale); + } + + #[test] + fn monitor_drag_snaps_gap_to_shared_border() { + let monitors = Settings::default().monitors; + + let snapped = snap_monitor_to_neighbor(&monitors, "secondary", 2600, 120); + + assert_eq!(snapped, (1920, 120)); + assert!(monitor_borders_another(&monitors, "secondary", snapped)); + } + + #[test] + fn monitor_drag_snaps_to_border_and_preserves_overlap() { + let monitors = Settings::default().monitors; + + let snapped = snap_monitor_to_neighbor(&monitors, "secondary", 600, -1500); + + assert_eq!(snapped, (600, -1080)); + assert!(monitor_borders_another(&monitors, "secondary", snapped)); + } + + #[test] + fn snap_position_rounds_to_nearest_grid_line() { + assert_eq!(snap_position(14), 10); + assert_eq!(snap_position(15), 20); + assert_eq!(snap_position(-14), -10); + assert_eq!(snap_position(-15), -20); + } + + fn monitor_borders_another(monitors: &[MonitorConfig], id: &str, position: (i32, i32)) -> bool { + let monitor = monitors.iter().find(|monitor| monitor.id == id).unwrap(); + monitors.iter().any(|other| { + other.id != id + && shares_border(position.0, position.1, monitor.width, monitor.height, other) + }) + } + + fn shares_border(x: i32, y: i32, width: i32, height: i32, other: &MonitorConfig) -> bool { + let horizontal_overlap = y < other.y + other.height && y + height > other.y; + let vertical_overlap = x < other.x + other.width && x + width > other.x; + let touches_left_or_right = x + width == other.x || x == other.x + other.width; + let touches_top_or_bottom = y + height == other.y || y == other.y + other.height; + + (touches_left_or_right && horizontal_overlap) || (touches_top_or_bottom && vertical_overlap) + } +} diff --git a/crates/hearthspace-settingsd/src/main.rs b/crates/hearthspace-settingsd/src/main.rs index 37b1475..c744d38 100644 --- a/crates/hearthspace-settingsd/src/main.rs +++ b/crates/hearthspace-settingsd/src/main.rs @@ -7,8 +7,8 @@ use std::{ }; use hearthspace_ipc::{ - Settings, SettingsRequest, error_response, parse_settings_request, settings_response, - settings_socket_path, + MonitorConfig, Settings, SettingsRequest, error_response, parse_settings_request, + settings_response, settings_socket_path, }; use tracing::{error, info, warn}; @@ -101,7 +101,21 @@ fn handle_request(request: SettingsRequest, config_path: &Path) -> io::Result { let mut settings = load_settings_from_path(config_path)?; settings.test = test; - save_settings_to_path(settings, config_path)?; + save_settings_to_path(settings.clone(), config_path)?; + Ok(settings_response(settings)) + } + SettingsRequest::SetMonitorPosition { id, x, y } => { + let mut settings = load_settings_from_path(config_path)?; + let Some(monitor) = settings + .monitors + .iter_mut() + .find(|monitor| monitor.id == id) + else { + return Ok(error_response(format!("unknown monitor id: {id}"))); + }; + monitor.x = x; + monitor.y = y; + save_settings_to_path(settings.clone(), config_path)?; Ok(settings_response(settings)) } } @@ -132,9 +146,30 @@ fn save_settings_to_path(settings: Settings, path: impl AsRef) -> io::Resu fn parse_settings(contents: &str) -> io::Result { let mut settings = Settings::default(); + settings.monitors.clear(); + let mut current_monitor: Option = None; for raw_line in contents.lines() { let line = raw_line.split('#').next().unwrap_or_default().trim(); - if line.is_empty() || line.starts_with('[') { + if line.is_empty() { + continue; + } + + if line == "[[monitor]]" { + if let Some(monitor) = current_monitor.take() { + settings.monitors.push(monitor); + } + current_monitor = Some(MonitorConfig { + id: String::new(), + name: String::new(), + width: 1920, + height: 1080, + x: 0, + y: 0, + }); + continue; + } + + if line.starts_with('[') { continue; } @@ -144,9 +179,21 @@ fn parse_settings(contents: &str) -> io::Result { format!("invalid settings line: {line:?}"), )); }; - - if key.trim() == "test" { - settings.test = parse_bool(value.trim()).map_err(|error| { + let key = key.trim(); + let value = value.trim(); + + if let Some(monitor) = &mut current_monitor { + match key { + "id" => monitor.id = value.to_string(), + "name" => monitor.name = value.to_string(), + "width" => monitor.width = parse_i32(value, "monitor width")?, + "height" => monitor.height = parse_i32(value, "monitor height")?, + "x" => monitor.x = parse_i32(value, "monitor x")?, + "y" => monitor.y = parse_i32(value, "monitor y")?, + _ => {} + } + } else if key == "test" { + settings.test = parse_bool(value).map_err(|error| { io::Error::new( io::ErrorKind::InvalidData, format!("invalid test setting: {error}"), @@ -154,11 +201,32 @@ fn parse_settings(contents: &str) -> io::Result { })?; } } + + if let Some(monitor) = current_monitor.take() { + settings.monitors.push(monitor); + } + settings.monitors.retain(|monitor| !monitor.id.is_empty()); + if settings.monitors.is_empty() { + settings.monitors = Settings::default().monitors; + } Ok(settings) } fn format_settings(settings: Settings) -> String { - format!("test = {}\n", settings.test) + let mut contents = format!("test = {}\n", settings.test); + for monitor in settings.monitors { + contents.push_str(&format!( + "\n[[monitor]]\n\ + id = {}\n\ + name = {}\n\ + width = {}\n\ + height = {}\n\ + x = {}\n\ + y = {}\n", + monitor.id, monitor.name, monitor.width, monitor.height, monitor.x, monitor.y, + )); + } + contents } fn parse_bool(value: &str) -> Result { @@ -169,6 +237,15 @@ fn parse_bool(value: &str) -> Result { } } +fn parse_i32(value: &str, label: &str) -> io::Result { + value.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid {label}: {error}"), + ) + }) +} + #[cfg(test)] mod tests { use std::{ @@ -188,7 +265,8 @@ mod tests { let response = round_trip("get\n", &config_path); - assert_eq!(response, "ok test false\n"); + assert!(response.starts_with("ok test false monitor primary 1920 1080 0 0 Primary")); + assert!(response.contains("monitor secondary 1920 1080 1920 0 Secondary")); assert!(!config_path.exists()); } @@ -197,11 +275,26 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("hearthspace/config.toml"); fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - fs::write(&config_path, "test = true\n").unwrap(); + fs::write( + &config_path, + "test = true\n\ + \n\ + [[monitor]]\n\ + id = dock\n\ + name = Dock Display\n\ + width = 2560\n\ + height = 1440\n\ + x = -2560\n\ + y = 120\n", + ) + .unwrap(); let response = round_trip("get\n", &config_path); - assert_eq!(response, "ok test true\n"); + assert_eq!( + response, + "ok test true monitor dock 2560 1440 -2560 120 Dock%20Display\n" + ); } #[test] @@ -211,8 +304,35 @@ mod tests { let response = round_trip("set test true\n", &config_path); - assert_eq!(response, "ok test true\n"); - assert_eq!(fs::read_to_string(config_path).unwrap(), "test = true\n"); + assert!(response.starts_with("ok test true monitor primary 1920 1080 0 0 Primary")); + let contents = fs::read_to_string(config_path).unwrap(); + assert!(contents.starts_with("test = true\n")); + assert!(contents.contains("[[monitor]]\nid = primary\n")); + } + + #[test] + fn set_monitor_position_writes_config_and_returns_layout() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("hearthspace/config.toml"); + + let response = round_trip("set monitor secondary -1600 90\n", &config_path); + + assert!(response.contains("monitor secondary 1920 1080 -1600 90 Secondary")); + let contents = fs::read_to_string(config_path).unwrap(); + assert!(contents.contains("id = secondary\n")); + assert!(contents.contains("x = -1600\n")); + assert!(contents.contains("y = 90\n")); + } + + #[test] + fn set_monitor_position_rejects_unknown_monitor() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("hearthspace/config.toml"); + + let response = round_trip("set monitor missing 10 20\n", &config_path); + + assert_eq!(response, "err unknown monitor id: missing\n"); + assert!(!config_path.exists()); } #[test] -- 2.51.2 From 81aa3817be2874ae07896009b6433ebfa5fcd718 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 05:12:50 -0700 Subject: [PATCH 22/28] fix: recenter monitors after dragging --- crates/hearthspace-settings/src/main.rs | 235 +++++++++++++++++++----- 1 file changed, 193 insertions(+), 42 deletions(-) diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 00faa67..92bb4bb 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -182,36 +182,45 @@ impl View for MonitorLayoutEditor { } fn apply_monitor_editor_action(state: &mut SettingsApp, action: MonitorEditorAction) { - let Some(monitor) = state - .settings + let moved_monitor = action .monitors - .iter_mut() - .find(|monitor| monitor.id == action.id) - else { - state.status = format!("Unknown monitor: {}", action.id); - return; - }; - monitor.x = action.x; - monitor.y = action.y; + .iter() + .find(|monitor| monitor.id == action.moved_id) + .cloned(); + state.settings.monitors = action.monitors; if action.commit { - match client_set_monitor_position(&action.id, action.x, action.y) { - Ok(settings) => { + match save_monitor_positions(&state.settings.monitors) { + Ok(Some(settings)) => { state.settings = settings; - state.status = format!("Saved monitor layout for {}", action.id); + state.status = format!("Saved monitor layout for {}", action.moved_id); } + Ok(None) => state.status = "Saved empty monitor layout".to_string(), Err(error) => state.status = format!("Failed to save monitor layout: {error}"), } + } else if let Some(monitor) = moved_monitor { + state.status = format!("Moving {} to {},{}", monitor.name, monitor.x, monitor.y); } else { - state.status = format!("Moving {} to {},{}", monitor.name, action.x, action.y); + state.status = format!("Unknown monitor: {}", action.moved_id); } } +fn save_monitor_positions(monitors: &[MonitorConfig]) -> std::io::Result> { + let mut saved = None; + for monitor in monitors { + saved = Some(client_set_monitor_position( + &monitor.id, + monitor.x, + monitor.y, + )?); + } + Ok(saved) +} + #[derive(Debug)] struct MonitorEditorAction { - id: String, - x: i32, - y: i32, + moved_id: String, + monitors: Vec, commit: bool, } @@ -227,6 +236,8 @@ struct MonitorDrag { start_pointer: Point, start_x: i32, start_y: i32, + origin_x: f64, + origin_y: f64, scale: f64, } @@ -250,13 +261,21 @@ impl MonitorLayoutWidget { } fn layout_metrics(&self, size: Size) -> LayoutMetrics { + if let Some(drag) = &self.drag { + return LayoutMetrics { + origin_x: drag.origin_x, + origin_y: drag.origin_y, + scale: drag.scale, + }; + } + let canvas_size = monitor_canvas_size(&self.monitors); let scale = layout_scale_for_canvas(&self.monitors, size); - let (combined_width, combined_height) = monitor_canvas_span(&self.monitors); + let (origin_x, origin_y) = monitor_canvas_origin(&self.monitors, canvas_size); LayoutMetrics { - origin_x: (f64::from(combined_width) - canvas_size.width) / 2.0, - origin_y: (f64::from(combined_height) - canvas_size.height) / 2.0, + origin_x, + origin_y, scale, } } @@ -279,17 +298,22 @@ impl MonitorLayoutWidget { .find(|monitor| self.monitor_rect(monitor, metrics).contains(point)) } - fn apply_drag_position(&mut self, id: &str, x: i32, y: i32) { - if let Some(monitor) = self.monitors.iter_mut().find(|monitor| monitor.id == id) { - monitor.x = x; - monitor.y = y; - } - } - - fn drag_position(&self, drag: &MonitorDrag, point: Point) -> (i32, i32) { + fn monitors_for_drag( + &self, + drag: &MonitorDrag, + point: Point, + normalize: bool, + ) -> Vec { let x = drag.start_x + ((point.x - drag.start_pointer.x) / drag.scale).round() as i32; let y = drag.start_y + ((point.y - drag.start_pointer.y) / drag.scale).round() as i32; - snap_monitor_to_neighbor(&self.monitors, &drag.id, snap_position(x), snap_position(y)) + let (x, y) = + snap_monitor_to_neighbor(&self.monitors, &drag.id, snap_position(x), snap_position(y)); + let mut monitors = self.monitors.clone(); + set_monitor_position(&mut monitors, &drag.id, x, y); + if normalize { + normalize_monitors_near_origin(&mut monitors); + } + monitors } } @@ -319,6 +343,8 @@ impl Widget for MonitorLayoutWidget { start_pointer: point, start_x: monitor.x, start_y: monitor.y, + origin_x: metrics.origin_x, + origin_y: metrics.origin_y, scale: metrics.scale, }); } @@ -326,13 +352,12 @@ impl Widget for MonitorLayoutWidget { PointerEvent::Move(PointerUpdate { current, .. }) if ctx.is_active() => { if let Some(drag) = &self.drag { let point = ctx.local_position(current.position); - let (x, y) = self.drag_position(drag, point); - let id = drag.id.clone(); - self.apply_drag_position(&id, x, y); + let moved_id = drag.id.clone(); + let monitors = self.monitors_for_drag(drag, point, false); + self.monitors = monitors.clone(); ctx.submit_action::(MonitorEditorAction { - id, - x, - y, + moved_id, + monitors, commit: false, }); ctx.request_render(); @@ -342,12 +367,11 @@ impl Widget for MonitorLayoutWidget { PointerEvent::Up(PointerButtonEvent { state, .. }) if ctx.is_active() => { if let Some(drag) = self.drag.take() { let point = ctx.local_position(state.position); - let (x, y) = self.drag_position(&drag, point); - self.apply_drag_position(&drag.id, x, y); + let monitors = self.monitors_for_drag(&drag, point, true); + self.monitors = monitors.clone(); ctx.submit_action::(MonitorEditorAction { - id: drag.id, - x, - y, + moved_id: drag.id, + monitors, commit: true, }); ctx.request_render(); @@ -462,6 +486,18 @@ fn monitor_canvas_size(monitors: &[MonitorConfig]) -> Size { ) } +fn monitor_canvas_origin(monitors: &[MonitorConfig], canvas_size: Size) -> (f64, f64) { + let Some((min_x, min_y, max_x, max_y)) = monitor_arrangement_bounds(monitors) else { + return (canvas_size.width / -2.0, canvas_size.height / -2.0); + }; + let center_x = (f64::from(min_x) + f64::from(max_x)) / 2.0; + let center_y = (f64::from(min_y) + f64::from(max_y)) / 2.0; + ( + center_x - canvas_size.width / 2.0, + center_y - canvas_size.height / 2.0, + ) +} + fn monitor_canvas_span(monitors: &[MonitorConfig]) -> (i32, i32) { if monitors.is_empty() { return (EMPTY_MONITOR_CANVAS_WIDTH, EMPTY_MONITOR_CANVAS_HEIGHT); @@ -490,14 +526,49 @@ fn layout_scale_for_canvas(monitors: &[MonitorConfig], size: Size) -> f64 { .clamp(0.03, 1.0) } -#[cfg(test)] -fn offset_monitor(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { +fn set_monitor_position(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { if let Some(monitor) = monitors.iter_mut().find(|monitor| monitor.id == id) { monitor.x = x; monitor.y = y; } } +fn normalize_monitors_near_origin(monitors: &mut [MonitorConfig]) { + let Some((min_x, min_y, max_x, max_y)) = monitor_arrangement_bounds(monitors) else { + return; + }; + let center_x = midpoint_i32(min_x, max_x); + let center_y = midpoint_i32(min_y, max_y); + for monitor in monitors { + monitor.x -= center_x; + monitor.y -= center_y; + } +} + +fn monitor_arrangement_bounds(monitors: &[MonitorConfig]) -> Option<(i32, i32, i32, i32)> { + let first = monitors.first()?; + let mut min_x = first.x; + let mut min_y = first.y; + let mut max_x = first.x + first.width.max(1); + let mut max_y = first.y + first.height.max(1); + for monitor in monitors.iter().skip(1) { + min_x = min_x.min(monitor.x); + min_y = min_y.min(monitor.y); + max_x = max_x.max(monitor.x + monitor.width.max(1)); + max_y = max_y.max(monitor.y + monitor.height.max(1)); + } + Some((min_x, min_y, max_x, max_y)) +} + +fn midpoint_i32(start: i32, end: i32) -> i32 { + ((i64::from(start) + i64::from(end)) / 2) as i32 +} + +#[cfg(test)] +fn offset_monitor(monitors: &mut [MonitorConfig], id: &str, x: i32, y: i32) { + set_monitor_position(monitors, id, x, y); +} + fn snap_position(value: i32) -> i32 { let half = MONITOR_SNAP / 2; if value >= 0 { @@ -652,6 +723,86 @@ mod tests { assert_eq!(layout_scale_for_canvas(&monitors, size), scale); } + #[test] + fn layout_metrics_do_not_recenter_during_live_drag() { + let mut widget = MonitorLayoutWidget::new(Settings::default().monitors); + let size = Size::new(760.0, 320.0); + let metrics = widget.layout_metrics(size); + widget.drag = Some(MonitorDrag { + id: "secondary".to_string(), + start_pointer: Point::from((0.0, 0.0)), + start_x: 1920, + start_y: 0, + origin_x: metrics.origin_x, + origin_y: metrics.origin_y, + scale: metrics.scale, + }); + + offset_monitor(&mut widget.monitors, "secondary", 0, 1200); + + let live_metrics = widget.layout_metrics(size); + assert_eq!(live_metrics.origin_x, metrics.origin_x); + assert_eq!(live_metrics.origin_y, metrics.origin_y); + assert_eq!(live_metrics.scale, metrics.scale); + } + + #[test] + fn canvas_origin_centers_far_away_arrangements() { + let mut monitors = Settings::default().monitors; + offset_monitor(&mut monitors, "primary", 10_000, -8_000); + offset_monitor(&mut monitors, "secondary", 11_920, -8_000); + let canvas_size = monitor_canvas_size(&monitors); + + let origin = monitor_canvas_origin(&monitors, canvas_size); + + assert_eq!(origin, (9616.0, -8756.0)); + } + + #[test] + fn normalize_monitors_keeps_layout_shape_centered_near_origin() { + let mut monitors = Settings::default().monitors; + offset_monitor(&mut monitors, "primary", 10_000, -8_000); + offset_monitor(&mut monitors, "secondary", 11_920, -8_000); + + normalize_monitors_near_origin(&mut monitors); + + assert_eq!( + monitor_arrangement_bounds(&monitors), + Some((-1920, -540, 1920, 540)) + ); + assert!(monitor_borders_another(&monitors, "secondary", (0, -540))); + } + + #[test] + fn live_drag_moves_only_selected_monitor_until_release() { + let widget = MonitorLayoutWidget::new(Settings::default().monitors); + let drag = MonitorDrag { + id: "secondary".to_string(), + start_pointer: Point::from((0.0, 0.0)), + start_x: 1920, + start_y: 0, + origin_x: 0.0, + origin_y: 0.0, + scale: 1.0, + }; + + let live = widget.monitors_for_drag(&drag, Point::from((0.0, 1200.0)), false); + let released = widget.monitors_for_drag(&drag, Point::from((0.0, 1200.0)), true); + + let live_primary = live.iter().find(|monitor| monitor.id == "primary").unwrap(); + let live_secondary = live + .iter() + .find(|monitor| monitor.id == "secondary") + .unwrap(); + assert_eq!((live_primary.x, live_primary.y), (0, 0)); + assert_eq!((live_secondary.x, live_secondary.y), (1919, 1080)); + + assert_eq!( + monitor_arrangement_bounds(&released), + Some((-1919, -1080, 1920, 1080)) + ); + } + #[test] fn monitor_drag_snaps_gap_to_shared_border() { let monitors = Settings::default().monitors; -- 2.51.2 From 5d779fe1d7ffbb0dd4a9dbc4c2c25f9225f14bb5 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 05:35:11 -0700 Subject: [PATCH 23/28] chore: initial work on udev support for monitors --- Cargo.lock | 1 + crates/hearthspace-compositor/Cargo.toml | 1 + .../src/compositor/mod.rs | 6 +- .../src/compositor/output.rs | 278 +++++++++++++++++- .../src/compositor/udev.rs | 24 ++ .../src/compositor/udev/device.rs | 106 ++++--- 6 files changed, 352 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb16755..3582fc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2272,6 +2272,7 @@ dependencies = [ "fontique", "gtk4", "hearthspace-common", + "hearthspace-ipc", "hearthspace-shell", "image", "masonry", diff --git a/crates/hearthspace-compositor/Cargo.toml b/crates/hearthspace-compositor/Cargo.toml index 925ea10..4ddb23d 100644 --- a/crates/hearthspace-compositor/Cargo.toml +++ b/crates/hearthspace-compositor/Cargo.toml @@ -32,6 +32,7 @@ atspi = { version = "0.30.0", features = ["zbus"] } calloop = { version = "0.14.4", features = ["signals"] } gtk = { package = "gtk4", version = "0.11", optional = true } hearthspace-common = { path = "../hearthspace-common" } +hearthspace-ipc = { path = "../hearthspace-ipc" } hearthspace-shell = { path = "../hearthspace-shell" } # Xilem (app shell) and Masonry (compositor-side imaging) are pulled from our # linebender/xilem fork branch backing PR linebender/xilem#1830, which adds diff --git a/crates/hearthspace-compositor/src/compositor/mod.rs b/crates/hearthspace-compositor/src/compositor/mod.rs index 2889bb3..cd939f7 100644 --- a/crates/hearthspace-compositor/src/compositor/mod.rs +++ b/crates/hearthspace-compositor/src/compositor/mod.rs @@ -27,7 +27,9 @@ use idle::{IdleTransition, WindowIdleDaemon}; pub(in crate::compositor) use input::handle_input_event; #[cfg(feature = "udev")] pub(in crate::compositor) use output::{OutputDescriptor, create_output_with_properties}; -pub(in crate::compositor) use output::{OutputRecord, OutputSet, create_output}; +pub(in crate::compositor) use output::{ + OutputRecord, OutputSet, create_output, load_output_layout, +}; #[cfg(feature = "udev")] pub(in crate::compositor) use runtime::create_calloop_data; pub(in crate::compositor) use runtime::{Backend, CalloopData, HeadlessBackend, run_event_loop}; @@ -451,7 +453,7 @@ pub(in crate::compositor) fn initialize_app( raw_pointer_location: (0.0, 0.0).into(), active_touch_slot: None, scroll_zooms_without_super: options.scroll_zooms_without_super, - outputs: OutputSet::new(primary_output), + outputs: OutputSet::new(primary_output, load_output_layout()), app_catalog, needs_redraw: true, dmabuf_state: dmabuf.state, diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index a525533..cd7d365 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -7,9 +7,15 @@ use wayland_server::{DisplayHandle, backend::GlobalId, protocol::wl_surface::WlS use super::App; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(in crate::compositor) struct OutputLayout { + monitors: Vec, +} + pub(in crate::compositor) struct OutputSet { primary: OutputRecord, secondary: Vec, + layout: OutputLayout, } pub(in crate::compositor) struct OutputRecord { @@ -40,22 +46,41 @@ pub(in crate::compositor) struct OutputDescriptor { } impl OutputSet { - pub(in crate::compositor) fn new(primary: OutputRecord) -> Self { - Self { + pub(in crate::compositor) fn new(primary: OutputRecord, layout: OutputLayout) -> Self { + let mut outputs = Self { primary, secondary: Vec::new(), + layout, + }; + outputs.apply_layout_to_existing_outputs(); + outputs + } + + #[cfg(feature = "udev")] + pub(in crate::compositor) fn set_layout(&mut self, layout: OutputLayout) -> bool { + if self.layout == layout { + return false; } + + self.layout = layout; + self.apply_layout_to_existing_outputs(); + true } #[cfg(feature = "udev")] fn sync_secondary_outputs(&mut self, dh: &DisplayHandle, descriptors: Vec) { - let mut next_x = self.primary.size.to_logical(self.primary.scale).w; let mut existing = std::mem::take(&mut self.secondary); let descriptors = secondary_descriptors_for_layout(descriptors, &self.primary.name); + let plan = output_locations_for_layout( + OutputGeometry::from(&self.primary), + descriptors.iter().map(OutputGeometry::from).collect(), + &self.layout, + ); + self.primary.set_location(plan.primary); let mut next_secondary = Vec::with_capacity(descriptors.len()); for descriptor in descriptors { - let location = (next_x, 0).into(); + let location = plan.secondary_location(&descriptor.name); let output = if let Some(index) = existing .iter() .position(|output| output.name == descriptor.name) @@ -68,7 +93,6 @@ impl OutputSet { tracing::info!(name = %output.name, ?location, "advertising newly connected Wayland output"); output }; - next_x += output.size.to_logical(output.scale).w; next_secondary.push(output); } @@ -80,6 +104,18 @@ impl OutputSet { self.secondary = next_secondary; } + fn apply_layout_to_existing_outputs(&mut self) { + let plan = output_locations_for_layout( + OutputGeometry::from(&self.primary), + self.secondary.iter().map(OutputGeometry::from).collect(), + &self.layout, + ); + self.primary.set_location(plan.primary); + for output in &mut self.secondary { + output.set_location(plan.secondary_location(&output.name)); + } + } + fn logical_size(&self) -> Size { let primary_size = self.primary.logical_size(); let mut width = primary_size.w; @@ -128,6 +164,169 @@ impl OutputRecord { self.location, ); } + + fn set_location(&mut self, location: Point) { + if self.location == location { + return; + } + + self.location = location; + update_output_mode_with_refresh_at( + &self.output, + self.size, + self.scale, + self.refresh, + self.location, + ); + } +} + +impl OutputLayout { + fn from_monitors(monitors: Vec) -> Self { + Self { monitors } + } + + fn location_for(&self, output_name: &str, role_id: &str) -> Option> { + self.monitors + .iter() + .find(|monitor| monitor.id == output_name || monitor.name == output_name) + .or_else(|| self.monitors.iter().find(|monitor| monitor.id == role_id)) + .map(|monitor| Point::from((monitor.x, monitor.y))) + } +} + +pub(in crate::compositor) fn load_output_layout() -> OutputLayout { + match hearthspace_ipc::client_get_settings() { + Ok(settings) => OutputLayout::from_monitors(settings.monitors), + Err(error) => { + debug!(%error, "using fallback output layout because settingsd is unavailable"); + OutputLayout::default() + } + } +} + +#[derive(Clone)] +struct OutputGeometry { + name: String, + size: Size, + scale: i32, +} + +impl OutputGeometry { + fn logical_size(&self) -> Size { + self.size.to_logical(self.scale) + } +} + +impl From<&OutputRecord> for OutputGeometry { + fn from(output: &OutputRecord) -> Self { + Self { + name: output.name.clone(), + size: output.size, + scale: output.scale, + } + } +} + +impl From<&OutputDescriptor> for OutputGeometry { + fn from(descriptor: &OutputDescriptor) -> Self { + Self { + name: descriptor.name.clone(), + size: descriptor.size, + scale: descriptor.scale, + } + } +} + +struct OutputLayoutPlan { + primary: Point, + secondary: Vec<(String, Point)>, +} + +impl OutputLayoutPlan { + fn secondary_location(&self, name: &str) -> Point { + self.secondary + .iter() + .find(|(secondary_name, _)| secondary_name == name) + .map(|(_, location)| *location) + .unwrap_or((0, 0).into()) + } +} + +fn output_locations_for_layout( + primary: OutputGeometry, + mut secondary: Vec, + layout: &OutputLayout, +) -> OutputLayoutPlan { + secondary.sort_by(|a, b| a.name.cmp(&b.name)); + + let primary_location = layout + .location_for(&primary.name, "primary") + .unwrap_or((0, 0).into()); + let mut placed = vec![( + primary.name.clone(), + primary.logical_size(), + primary_location, + )]; + let mut unplaced = Vec::new(); + + for (index, output) in secondary.into_iter().enumerate() { + let role_id = secondary_role_id(index); + if let Some(location) = layout.location_for(&output.name, &role_id) { + let size = output.logical_size(); + placed.push((output.name, size, location)); + } else { + unplaced.push(output); + } + } + + let fallback_y = primary_location.y; + let mut next_x = placed + .iter() + .map(|(_, size, location)| location.x + size.w) + .max() + .unwrap_or(0); + for output in unplaced { + let size = output.logical_size(); + let location = Point::from((next_x, fallback_y)); + next_x += size.w; + placed.push((output.name, size, location)); + } + + let min_x = placed + .iter() + .map(|(_, _, location)| location.x) + .min() + .unwrap_or(0); + let min_y = placed + .iter() + .map(|(_, _, location)| location.y) + .min() + .unwrap_or(0); + + let mut primary_plan = Point::from((0, 0)); + let mut secondary_plan = Vec::new(); + for (name, _, location) in placed { + let normalized = Point::from((location.x - min_x, location.y - min_y)); + if name == primary.name { + primary_plan = normalized; + } else { + secondary_plan.push((name, normalized)); + } + } + + OutputLayoutPlan { + primary: primary_plan, + secondary: secondary_plan, + } +} + +fn secondary_role_id(index: usize) -> String { + if index == 0 { + "secondary".to_string() + } else { + format!("secondary-{}", index + 1) + } } impl App { @@ -139,6 +338,11 @@ impl App { self.outputs.logical_size() } + #[cfg(feature = "udev")] + pub(in crate::compositor) fn set_output_layout(&mut self, layout: OutputLayout) -> bool { + self.outputs.set_layout(layout) + } + #[cfg(feature = "udev")] pub(in crate::compositor) fn output_render_view(&self, name: &str) -> Option { if self.outputs.primary.name == name { @@ -313,10 +517,13 @@ fn update_output_mode_with_refresh_at( mod tests { use smithay::{ output::{PhysicalProperties, Subpixel}, - utils::{Physical, Size}, + utils::{Physical, Point, Size}, }; - use super::{OutputDescriptor, secondary_descriptors_for_layout}; + use super::{ + OutputDescriptor, OutputGeometry, OutputLayout, output_locations_for_layout, + secondary_descriptors_for_layout, + }; fn descriptor(name: &str) -> OutputDescriptor { OutputDescriptor { @@ -333,6 +540,26 @@ mod tests { } } + fn geometry(name: &str) -> OutputGeometry { + OutputGeometry::from(&descriptor(name)) + } + + fn layout(monitors: &[(&str, i32, i32)]) -> OutputLayout { + OutputLayout::from_monitors( + monitors + .iter() + .map(|(id, x, y)| hearthspace_ipc::MonitorConfig { + id: (*id).into(), + name: (*id).into(), + width: 100, + height: 100, + x: *x, + y: *y, + }) + .collect(), + ) + } + #[test] fn secondary_layout_skips_selected_primary_and_sorts_by_connector_name() { let descriptors = vec![ @@ -365,4 +592,41 @@ mod tests { assert_eq!(names, ["HDMI-A-1"]); } + + #[test] + fn output_layout_uses_connector_positions_and_normalizes_to_zero_origin() { + let plan = output_locations_for_layout( + geometry("eDP-1"), + vec![geometry("HDMI-A-1")], + &layout(&[("eDP-1", 1920, 0), ("HDMI-A-1", 0, -200)]), + ); + + assert_eq!(plan.primary, Point::from((1920, 200))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 0))); + } + + #[test] + fn output_layout_maps_placeholder_roles_by_output_order() { + let plan = output_locations_for_layout( + geometry("eDP-1"), + vec![geometry("HDMI-A-1")], + &layout(&[("primary", 0, 0), ("secondary", -100, 100)]), + ); + + assert_eq!(plan.primary, Point::from((100, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 100))); + } + + #[test] + fn output_layout_places_unconfigured_outputs_to_the_right() { + let plan = output_locations_for_layout( + geometry("eDP-1"), + vec![geometry("HDMI-A-1"), geometry("DP-1")], + &layout(&[("primary", 0, 0)]), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.secondary_location("DP-1"), Point::from((100, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((200, 0))); + } } diff --git a/crates/hearthspace-compositor/src/compositor/udev.rs b/crates/hearthspace-compositor/src/compositor/udev.rs index ec28836..d6e47d1 100644 --- a/crates/hearthspace-compositor/src/compositor/udev.rs +++ b/crates/hearthspace-compositor/src/compositor/udev.rs @@ -9,6 +9,7 @@ use device::{ }; use input::log_input_event; +use calloop::timer::{TimeoutAction, Timer}; use smithay::{ backend::{ allocator::Buffer, @@ -32,6 +33,8 @@ use crate::{ config::{HEADLESS_OUTPUT_HEIGHT, HEADLESS_OUTPUT_WIDTH}, }; +const SETTINGS_LAYOUT_REFRESH_INTERVAL: Duration = Duration::from_millis(500); + pub(super) struct UdevBackendState { session: LibSeatSession, seat_name: String, @@ -150,6 +153,7 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { .unwrap_or_default(); app.sync_connector_outputs(&dh, output_descriptors); app.enable_software_cursor(); + insert_settings_layout_refresh(&handle)?; handle.insert_source(session_notifier, |event, _, data| match event { SessionEvent::PauseSession => { @@ -177,6 +181,7 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { }; let dh = data.display.handle(); data.state.sync_connector_outputs(&dh, output_descriptors); + data.state.configure_shell_bars(); data.state.reconcile_pointer_after_output_geometry_change(); data.full_redraw = data.full_redraw.max(1); data.state.request_redraw(); @@ -199,6 +204,7 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { }; let dh = data.display.handle(); data.state.sync_connector_outputs(&dh, output_descriptors); + data.state.configure_shell_bars(); data.state.reconcile_pointer_after_output_geometry_change(); data.full_redraw = data.full_redraw.max(1); data.state.request_redraw(); @@ -275,6 +281,23 @@ pub fn run_udev(options: RunOptions) -> Result<(), Box> { super::run_event_loop(event_loop, &mut data) } +fn insert_settings_layout_refresh( + handle: &LoopHandle<'static, super::CalloopData>, +) -> Result<(), Box> { + let timer = Timer::from_duration(SETTINGS_LAYOUT_REFRESH_INTERVAL); + handle.insert_source(timer, |_, _, data| { + if data.state.set_output_layout(super::load_output_layout()) { + data.state.configure_shell_bars(); + data.state.reconcile_pointer_after_output_geometry_change(); + data.full_redraw = data.full_redraw.max(1); + data.state.request_redraw(); + debug!("applied updated monitor layout settings"); + } + TimeoutAction::ToDuration(SETTINGS_LAYOUT_REFRESH_INTERVAL) + })?; + Ok(()) +} + fn udev_backend_mut(data: &mut super::CalloopData) -> Option<&mut UdevBackendState> { match &mut data.backend { super::Backend::Udev(backend) => Some(backend), @@ -296,6 +319,7 @@ fn apply_connector_sync(data: &mut super::CalloopData, connector_sync: Connector } data.state .sync_connector_outputs(&dh, connector_sync.descriptors); + data.state.configure_shell_bars(); data.state.reconcile_pointer_after_output_geometry_change(); data.full_redraw = data.full_redraw.max(1); } diff --git a/crates/hearthspace-compositor/src/compositor/udev/device.rs b/crates/hearthspace-compositor/src/compositor/udev/device.rs index 3419a5a..995562e 100644 --- a/crates/hearthspace-compositor/src/compositor/udev/device.rs +++ b/crates/hearthspace-compositor/src/compositor/udev/device.rs @@ -136,63 +136,59 @@ impl UdevDevice { mode_count = info.modes().len(), "DRM connector queried" ); - if connected { - if let Some(mode) = info.modes().first() { - debug!(connector = ?connector_handle, ?mode, "selected preferred/first DRM mode"); - let current_crtc = info - .current_encoder() - .and_then(|encoder| self.scanout_node.drm_device.get_encoder(encoder).ok()) - .and_then(|encoder| encoder.crtc()); - let crtc_candidates = info - .encoders() - .iter() - .filter_map(|encoder| { - self.scanout_node.drm_device.get_encoder(*encoder).ok().map( - |encoder_info| { - resources.filter_crtcs(encoder_info.possible_crtcs()) - }, + if connected && let Some(mode) = info.modes().first() { + debug!(connector = ?connector_handle, ?mode, "selected preferred/first DRM mode"); + let current_crtc = info + .current_encoder() + .and_then(|encoder| self.scanout_node.drm_device.get_encoder(encoder).ok()) + .and_then(|encoder| encoder.crtc()); + let crtc_candidates = info + .encoders() + .iter() + .filter_map(|encoder| { + self.scanout_node.drm_device.get_encoder(*encoder).ok().map( + |encoder_info| resources.filter_crtcs(encoder_info.possible_crtcs()), + ) + }) + .flatten() + .collect::>(); + let crtc = current_crtc + .filter(|crtc| !used_crtcs.contains(crtc)) + .or_else(|| { + crtc_candidates + .iter() + .copied() + .find(|crtc| !used_crtcs.contains(crtc)) + }); + debug!( + connector = ?connector_handle, + current_crtc = ?current_crtc, + crtcs = ?crtc_candidates, + selected_crtc = ?crtc, + "DRM CRTC candidates" + ); + if let Some(crtc) = crtc { + used_crtcs.push(crtc); + let physical_size_mm = info + .size() + .map(|(width, height)| { + ( + i32::try_from(width).unwrap_or(0), + i32::try_from(height).unwrap_or(0), ) }) - .flatten() - .collect::>(); - let crtc = current_crtc - .filter(|crtc| !used_crtcs.contains(crtc)) - .or_else(|| { - crtc_candidates - .iter() - .copied() - .find(|crtc| !used_crtcs.contains(crtc)) - }); - debug!( - connector = ?connector_handle, - current_crtc = ?current_crtc, - crtcs = ?crtc_candidates, - selected_crtc = ?crtc, - "DRM CRTC candidates" - ); - if let Some(crtc) = crtc { - used_crtcs.push(crtc); - let physical_size_mm = info - .size() - .map(|(width, height)| { - ( - i32::try_from(width).unwrap_or(0), - i32::try_from(height).unwrap_or(0), - ) - }) - .unwrap_or((0, 0)); - targets.push(KmsOutputTarget { - connector: *connector_handle, - crtc, - mode: *mode, - connector_name: format!( - "{}-{}", - info.interface().as_str(), - info.interface_id() - ), - physical_size_mm, - }); - } + .unwrap_or((0, 0)); + targets.push(KmsOutputTarget { + connector: *connector_handle, + crtc, + mode: *mode, + connector_name: format!( + "{}-{}", + info.interface().as_str(), + info.interface_id() + ), + physical_size_mm, + }); } } } -- 2.51.2 From 79da73b0b2d78b34d4829e2295b786844f72b62f Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 05:36:49 -0700 Subject: [PATCH 24/28] chore: pass udev flags on `--dev` --- CONTRIBUTING.md | 93 ++++++++++++++++++-------- crates/hearthspace-session/src/main.rs | 48 ++++++++++--- 2 files changed, 104 insertions(+), 37 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8168156..200af61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ As this project grows, you can find technical documentation in the [docs/](./doc ## Setup -This project targets modern Linux systems with Wayland only. The initial proof-of-concept is planned as a nested Wayland compositor built with Rust and Smithay. +This project targets modern Linux systems with Wayland only. Hearthspace is a Rust workspace with separate crates for the session supervisor, compositor, shell, settings daemon, settings app, and shared IPC/common code. Ubuntu 26.04 LTS is our development and runtime baseline, and it is expected to have the oldest supported version of most packages. @@ -70,26 +70,63 @@ sudo apt-get install -y libgtk-4-dev Build or run with the feature when you need the GTK test app or the shell's `A11yTest` spawn action: ```sh -cargo run --features test-apps -- --gtk-test-app -cargo test --features test-apps +cargo run -p hearthspace-compositor --bin hearthspace --features test-apps -- --gtk-test-app +cargo test -p hearthspace-compositor --features test-apps ``` Without `test-apps`, `--gtk-test-app` and the shell `A11yTest` action report that the binary must be rebuilt with `--features test-apps`. +### Workspace Packages + +The root `Cargo.toml` is a virtual workspace. Its default member is `hearthspace-session`, so `cargo run` starts the session supervisor rather than the compositor directly. + +Common packages: + +```text +crates/hearthspace-session session supervisor; root cargo run target +crates/hearthspace-compositor Smithay compositor; binary: hearthspace-compositor +crates/hearthspace-shell Xilem shell client; binary: hearthspace-shell +crates/hearthspace-settingsd settings daemon; binary: hearthspace-settingsd +crates/hearthspace-settings settings app; binary: hearthspace-settings +crates/hearthspace-ipc settings IPC protocol/types +crates/hearthspace-common shared environment/path constants +crates/waydriver-hearthspace WayDriver backend adapter +``` + ### Runtime And Test Flags +For a normal development session, run the session supervisor from the workspace root: + +```sh +cargo run -- --dev +cargo run -- --dev --no-watch-apps +cargo run -- --dev --dry-run --print-env +``` + +The supervisor starts `hearthspace-settingsd`, `hearthspace-compositor --no-shell`, `hearthspace-shell`, and optionally a settings-app build watcher. It creates per-session sockets under `$XDG_RUNTIME_DIR/hearthspace/session-/` and passes them to child processes through environment variables. + +From a real Linux TTY/VT, the same dev-session command launches the full native session: + +```sh +cargo run -- --dev --no-watch-apps +``` + +In dev mode, the supervisor runs its compositor child as `cargo run -p hearthspace-compositor --features udev -- --no-shell`. The compositor chooses `udev` automatically when neither `WAYLAND_DISPLAY` nor `DISPLAY` is present; inside GNOME or another graphical host session it still chooses the nested winit backend unless `--tty` is passed directly to the compositor. + +Use direct compositor commands when you want to test only the compositor backend: + Common compositor flags: ```sh -cargo run -- --scroll-zooms -cargo run -- --headless -cargo run -- --headless --headless-size 1280x720 -cargo run -- --headless --headless-scale 2 -cargo run -- --headless --no-shell -cargo run -- --winit -cargo run --features udev -- --tty -cargo run --no-default-features --features udev -- --tty --no-shell -cargo run --features udev -- --tty --no-shell --exit-after-ms 10000 +cargo run -p hearthspace-compositor -- --scroll-zooms +cargo run -p hearthspace-compositor -- --headless +cargo run -p hearthspace-compositor -- --headless --headless-size 1280x720 +cargo run -p hearthspace-compositor -- --headless --headless-scale 2 +cargo run -p hearthspace-compositor -- --headless --no-shell +cargo run -p hearthspace-compositor -- --winit +cargo run -p hearthspace-compositor --features udev -- --tty +cargo run -p hearthspace-compositor --no-default-features --features udev -- --tty --no-shell +cargo run -p hearthspace-compositor --features udev -- --tty --no-shell --exit-after-ms 10000 ``` `--scroll-zooms` makes vertical scroll events zoom the canvas without holding @@ -125,13 +162,12 @@ Native backend runs can be exited with `Ctrl+Alt+Backspace`, `Ctrl+Alt+Esc`, and log interpretation, see [docs/NATIVE_TESTING.md](./docs/NATIVE_TESTING.md). For native backend architecture, see [docs/DRM.md](./docs/DRM.md). -Settings runs use separate Cargo binaries rather than `hearthspace` runtime -flags. Start the backend daemon first, then launch the Xilem frontend: +Settings runs use separate packages rather than compositor runtime flags. Start the backend daemon first, then launch the Xilem frontend: ```sh -cargo build --bin hearthspace-settings -cargo run --bin hearthspace-settingsd -cargo run --bin hearthspace-settings +cargo build -p hearthspace-settings +cargo run -p hearthspace-settingsd +cargo run -p hearthspace-settings ``` The settings daemon writes `~/.config/hearthspace/config.toml` and listens on @@ -139,14 +175,13 @@ The settings daemon writes `~/.config/hearthspace/config.toml` and listens on to the Hearthspace shell through `data/applications/dev.hearthspace.Settings.desktop`; during source-tree runs, the app catalog includes `data/` as a low-priority XDG data directory. Build the -settings frontend once with `cargo build --bin hearthspace-settings` before -launching it from shell search so `target/debug/hearthspace-settings` exists next -to the compositor binary. +settings frontend once with `cargo build -p hearthspace-settings` before +launching it from shell search so `target/debug/hearthspace-settings` exists. The daemon's focused tests can be run with: ```sh -cargo test --bin hearthspace-settingsd +cargo test -p hearthspace-settingsd ``` ### Tracing Logs @@ -158,22 +193,22 @@ Common examples: ```sh RUST_LOG=info cargo run -RUST_LOG=hearthspace=debug cargo run -RUST_LOG=hearthspace=trace cargo run -RUST_LOG=hearthspace=debug,smithay=warn cargo run +RUST_LOG=hearthspace_compositor=debug cargo run -p hearthspace-compositor +RUST_LOG=hearthspace_compositor=trace cargo run -p hearthspace-compositor +RUST_LOG=hearthspace_compositor=debug,smithay=warn cargo run -p hearthspace-compositor ``` For noisy paths, target a narrower module: ```sh -RUST_LOG=hearthspace::compositor::input=trace,hearthspace=info cargo run +RUST_LOG=hearthspace_compositor::compositor::input=trace,hearthspace_compositor=info cargo run -p hearthspace-compositor ``` Backend-specific examples: ```sh -RUST_LOG=hearthspace=debug cargo run -- --headless -RUST_LOG=hearthspace=debug cargo run --features udev -- --tty +RUST_LOG=hearthspace_compositor=debug cargo run -p hearthspace-compositor -- --headless +RUST_LOG=hearthspace_compositor=debug cargo run -p hearthspace-compositor --features udev -- --tty ``` The shell/control socket is `hearthspace-shell.sock` in `XDG_RUNTIME_DIR`. Shell @@ -204,8 +239,8 @@ The WayDriver backend adapter lives in `crates/waydriver-hearthspace` and uses the published `waydriver` crate. Its E2E smoke tests can be run with: ```sh -cargo test --features e2e --test waydriver_hearthspace -cargo test --features e2e,test-apps --test waydriver_hearthspace +cargo test -p hearthspace-compositor --features e2e --test waydriver_hearthspace +cargo test -p hearthspace-compositor --features e2e,test-apps --test waydriver_hearthspace ``` The feature-gated WayDriver `Session` test launches the GTK test app under a diff --git a/crates/hearthspace-session/src/main.rs b/crates/hearthspace-session/src/main.rs index d1d9f44..6eb9308 100644 --- a/crates/hearthspace-session/src/main.rs +++ b/crates/hearthspace-session/src/main.rs @@ -504,14 +504,7 @@ impl Supervisor { let current_dir = (self.mode == RunMode::DevCargo).then(repo_root); match self.mode { RunMode::DevCargo => { - let mut args = vec!["run".to_string()]; - if cargo_package_exists(binary) { - args.extend(["-p".to_string(), binary.to_string()]); - } else { - args.extend(["--bin".to_string(), binary.to_string()]); - } - args.push("--".to_string()); - args.extend(binary_args.iter().map(|arg| (*arg).to_string())); + let args = dev_cargo_args(binary, binary_args); ProcessSpec { name: binary, program: "cargo".to_string(), @@ -586,6 +579,21 @@ impl Supervisor { } } +fn dev_cargo_args(binary: &str, binary_args: &[&str]) -> Vec { + let mut args = vec!["run".to_string()]; + if cargo_package_exists(binary) { + args.extend(["-p".to_string(), binary.to_string()]); + } else { + args.extend(["--bin".to_string(), binary.to_string()]); + } + if binary == "hearthspace-compositor" { + args.extend(["--features".to_string(), "udev".to_string()]); + } + args.push("--".to_string()); + args.extend(binary_args.iter().map(|arg| (*arg).to_string())); + args +} + fn cargo_package_exists(package: &str) -> bool { matches!( package, @@ -699,3 +707,27 @@ fn wait_then_kill_process(process: &mut Option) { } let _ = process.child.wait(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dev_compositor_child_enables_udev_feature() { + assert_eq!( + dev_cargo_args( + "hearthspace-compositor", + &[hearthspace_common::NO_SHELL_FLAG] + ), + vec![ + "run", + "-p", + "hearthspace-compositor", + "--features", + "udev", + "--", + hearthspace_common::NO_SHELL_FLAG, + ] + ); + } +} -- 2.51.2 From 88e39ed811d5077479495e91148d8cdb683da843 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 05:49:45 -0700 Subject: [PATCH 25/28] fix: only show active monitors --- Cargo.lock | 123 ++++++------ crates/hearthspace-settings/Cargo.toml | 1 + crates/hearthspace-settings/src/main.rs | 243 +++++++++++++++++++++++- 3 files changed, 306 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3582fc5..040a8cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -338,9 +338,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -1070,9 +1070,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -1115,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.0", - "core-foundation 0.10.0", + "core-foundation 0.10.1", "libc", ] @@ -1756,9 +1756,9 @@ dependencies = [ [[package]] name = "gdk4" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd42fdbbf48612c6e8f47c65fb92d2e8f39c25aecd6af047e83897c1a22d2a4e" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" dependencies = [ "cairo-rs", "gdk-pixbuf", @@ -1771,9 +1771,9 @@ dependencies = [ [[package]] name = "gdk4-sys" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d974ac4f15e67472c3a9728daf612590b4a5762a4b33f0edd298df0b80d043c" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -1841,9 +1841,9 @@ dependencies = [ [[package]] name = "gio" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3848bcba3a35cc0a71df8ba8ecfd799d6bfb862342a53a4a915fb62213aa4e6" +checksum = "8b3e1f669909c326b9413bde5a742097b8c90a7d78f45326db13668984769ded" dependencies = [ "futures-channel", "futures-core", @@ -1858,9 +1858,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +checksum = "353fdc7da7cd16da916104b1e0e4e7de380ec9c8aaa20d4d742d66310ab4b0d5" dependencies = [ "glib-sys", "gobject-sys", @@ -1882,9 +1882,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.22.7" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c207e04e51605dcf7b2924c41591b3a10e1438eaac5bcf448fb91f325381104a" +checksum = "ddbcf514bd1881fc1b960e4e52b4e82873f4da3bceddbd58d42827b508888100" dependencies = [ "bitflags 2.13.0", "futures-channel", @@ -1915,9 +1915,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7fbac234ed5bc2a28359b7bde8e1b9cdf1441cc2d7f068e4824672d7db9445" +checksum = "030967459f9f676851872c6304adea7825c6d462ec9b72554c733cf0c5952233" dependencies = [ "libc", "system-deps", @@ -1997,32 +1997,30 @@ dependencies = [ [[package]] name = "graphene-rs" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" dependencies = [ "glib", "graphene-sys", - "libc", ] [[package]] name = "graphene-sys" -version = "0.22.0" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +checksum = "5c7ffdfde88f3570d3705e0d8a2433e036d387a1f2930bbf47eafcb5f569fd04" dependencies = [ "glib-sys", "libc", - "pkg-config", "system-deps", ] [[package]] name = "gsk4" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" dependencies = [ "cairo-rs", "gdk4", @@ -2035,9 +2033,9 @@ dependencies = [ [[package]] name = "gsk4-sys" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" dependencies = [ "cairo-sys-rs", "gdk4-sys", @@ -2051,9 +2049,9 @@ dependencies = [ [[package]] name = "gstreamer" -version = "0.25.2" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28ca0c594cac4e86f5444aaa767c7bb810340c0710667a6467d3ead248e35e84" +checksum = "ab4527e1b9bae8d29ce137bde5b8eec8ae8f78f13ad00fc6e70cbe227d6ad027" dependencies = [ "cfg-if", "futures-channel", @@ -2061,7 +2059,7 @@ dependencies = [ "futures-util", "glib", "gstreamer-sys", - "itertools", + "itertools 0.15.0", "kstring", "libc", "muldiv", @@ -2104,9 +2102,9 @@ dependencies = [ [[package]] name = "gstreamer-base" -version = "0.25.2" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c279df2918be97fb9570e589a32ade33598f643b0c4f0c92c17f06be6940574e" +checksum = "c91c94a4d3047d05dd6e1f6d91c74f61f56384c7ea1c9d0c1051572eeeb0138d" dependencies = [ "atomic_refcell", "cfg-if", @@ -2118,9 +2116,9 @@ dependencies = [ [[package]] name = "gstreamer-base-sys" -version = "0.25.0" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6569606feeb89cfcf95a6476a64a0f0aec83fadcef0e91c24e576f7851ceac3a" +checksum = "709fbbc623dc066908ba10c43d629c21096508dea04796592a206c4edd864e37" dependencies = [ "glib-sys", "gobject-sys", @@ -2144,9 +2142,9 @@ dependencies = [ [[package]] name = "gtk4" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7181b837f04cbe93f79441475f7a00560a92cba7a72e38cc1a68b6f8b78eaae2" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" dependencies = [ "cairo-rs", "field-offset", @@ -2165,9 +2163,9 @@ dependencies = [ [[package]] name = "gtk4-macros" -version = "0.11.0" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2177,9 +2175,9 @@ dependencies = [ [[package]] name = "gtk4-sys" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20ba8e695e2640455561274e65e45f0a151619e450746007667f4b23ceae4e1b" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", @@ -2320,6 +2318,7 @@ version = "0.1.0" dependencies = [ "hearthspace-common", "hearthspace-ipc", + "wayland-client", "xilem", ] @@ -2660,6 +2659,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "jni" version = "0.22.4" @@ -2851,14 +2859,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.8.1", + "redox_syscall 0.9.0", ] [[package]] @@ -3268,9 +3276,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" dependencies = [ "num-integer", "num-traits", @@ -3665,18 +3673,17 @@ dependencies = [ "libdeflater", "log", "rgb", - "rustc-hash 2.1.2", + "rustc-hash 2.1.3", ] [[package]] name = "pango" -version = "0.22.6" +version = "0.22.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251bdc6e6487b811be0e406a21e301e07e45c0aa8fa39e00c0c8e12a91752438" +checksum = "5d800d8d0de2ad5d0fb046f5344dbaba14a003cf3dd27cc21d85893d35ea316c" dependencies = [ "gio", "glib", - "libc", "pango-sys", ] @@ -4165,7 +4172,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -4256,9 +4263,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ "bitflags 2.13.0", ] @@ -4382,9 +4389,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4996,9 +5003,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -5016,9 +5023,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", diff --git a/crates/hearthspace-settings/Cargo.toml b/crates/hearthspace-settings/Cargo.toml index 880f7c5..44bf360 100644 --- a/crates/hearthspace-settings/Cargo.toml +++ b/crates/hearthspace-settings/Cargo.toml @@ -8,6 +8,7 @@ license = "Apache-2.0" [dependencies] hearthspace-common = { path = "../hearthspace-common" } hearthspace-ipc = { path = "../hearthspace-ipc" } +wayland-client = "0.31.14" xilem = { git = "https://github.com/crutchcorn/xilem", branch = "wayland-app-id" } [lints.rust] diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 92bb4bb..c18a3ca 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -1,3 +1,8 @@ +use wayland_client::{ + Connection, Dispatch, QueueHandle, WEnum, + globals::{GlobalListContents, registry_queue_init}, + protocol::{wl_output, wl_registry}, +}; use xilem::core::{MessageCtx, MessageResult, Mut, View, ViewMarker}; use xilem::masonry::accesskit::{Node, Role}; use xilem::masonry::core::{ @@ -31,10 +36,19 @@ const EMPTY_MONITOR_CANVAS_HEIGHT: i32 = 1080; struct SettingsApp { settings: Settings, + live_outputs: Vec, status: String, config_label: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct LiveOutput { + id: String, + name: String, + width: i32, + height: i32, +} + fn main() -> Result<(), Box> { let state = initial_state(); let app = Xilem::new_simple( @@ -50,13 +64,21 @@ fn main() -> Result<(), Box> { } fn initial_state() -> SettingsApp { - let (settings, status) = match client_get_settings() { + let (mut settings, mut status) = match client_get_settings() { Ok(settings) => (settings, "Connected to hearthspace-settingsd".to_string()), Err(error) => ( Settings::default(), format!("Settings backend unavailable: {error}"), ), }; + let live_outputs = match live_wayland_outputs() { + Ok(outputs) => outputs, + Err(error) => { + status.push_str(&format!("; live monitors unavailable: {error}")); + Vec::new() + } + }; + settings = settings_with_live_outputs(settings, &live_outputs); let config_label = match hearthspace_common::config_path() { Ok(path) => format!("Config: {}", path.display()), @@ -65,6 +87,7 @@ fn initial_state() -> SettingsApp { SettingsApp { settings, + live_outputs, status, config_label, } @@ -89,7 +112,7 @@ fn app_logic(state: &mut SettingsApp) -> impl WidgetView + use<> { match client_set_test(checked) { Ok(settings) => { let saved_test = settings.test; - state.settings = settings; + state.settings = settings_with_live_outputs(settings, &state.live_outputs); state.status = format!("Saved test = {saved_test}"); } Err(error) => { @@ -192,7 +215,7 @@ fn apply_monitor_editor_action(state: &mut SettingsApp, action: MonitorEditorAct if action.commit { match save_monitor_positions(&state.settings.monitors) { Ok(Some(settings)) => { - state.settings = settings; + state.settings = settings_with_live_outputs(settings, &state.live_outputs); state.status = format!("Saved monitor layout for {}", action.moved_id); } Ok(None) => state.status = "Saved empty monitor layout".to_string(), @@ -205,6 +228,174 @@ fn apply_monitor_editor_action(state: &mut SettingsApp, action: MonitorEditorAct } } +fn settings_with_live_outputs(mut settings: Settings, live_outputs: &[LiveOutput]) -> Settings { + if live_outputs.is_empty() { + return settings; + } + + settings.monitors = monitors_for_live_outputs(&settings.monitors, live_outputs); + settings +} + +fn monitors_for_live_outputs( + saved_monitors: &[MonitorConfig], + live_outputs: &[LiveOutput], +) -> Vec { + let mut monitors = Vec::with_capacity(live_outputs.len()); + let mut next_x = 0; + for (index, output) in live_outputs.iter().enumerate() { + let role_id = monitor_role_id(index); + let saved = saved_monitors + .iter() + .find(|monitor| monitor.id == output.id || monitor.name == output.name) + .or_else(|| saved_monitors.iter().find(|monitor| monitor.id == role_id)); + let (id, x, y) = saved + .map(|monitor| (monitor.id.clone(), monitor.x, monitor.y)) + .unwrap_or_else(|| (output.id.clone(), next_x, 0)); + monitors.push(MonitorConfig { + id, + name: output.name.clone(), + width: output.width, + height: output.height, + x, + y, + }); + next_x += output.width.max(1); + } + monitors +} + +fn monitor_role_id(index: usize) -> String { + if index == 0 { + "primary".to_string() + } else if index == 1 { + "secondary".to_string() + } else { + format!("secondary-{index}") + } +} + +fn live_wayland_outputs() -> std::io::Result> { + let connection = Connection::connect_to_env().map_err(std::io::Error::other)?; + let (globals, mut event_queue) = + registry_queue_init::(&connection).map_err(std::io::Error::other)?; + let queue_handle = event_queue.handle(); + let mut probe = OutputProbe::default(); + for global in globals.contents().clone_list() { + if global.interface == "wl_output" { + globals.registry().bind::( + global.name, + global.version.min(4), + &queue_handle, + OutputGlobal { name: global.name }, + ); + } + } + event_queue + .roundtrip(&mut probe) + .map_err(std::io::Error::other)?; + Ok(probe.live_outputs()) +} + +#[derive(Default)] +struct OutputProbe { + outputs: Vec, +} + +struct OutputGlobal { + name: u32, +} + +#[derive(Debug)] +struct OutputState { + global_name: u32, + name: Option, + description: Option, + width: Option, + height: Option, +} + +impl OutputProbe { + fn output_mut(&mut self, global_name: u32) -> &mut OutputState { + if let Some(index) = self + .outputs + .iter() + .position(|output| output.global_name == global_name) + { + return &mut self.outputs[index]; + } + self.outputs.push(OutputState { + global_name, + name: None, + description: None, + width: None, + height: None, + }); + self.outputs.last_mut().unwrap() + } + + fn live_outputs(self) -> Vec { + self.outputs + .into_iter() + .map(|output| { + let fallback = format!("output-{}", output.global_name); + let id = output.name.clone().unwrap_or_else(|| fallback.clone()); + LiveOutput { + id: id.clone(), + name: output.description.or(output.name).unwrap_or(id), + width: output.width.unwrap_or(EMPTY_MONITOR_CANVAS_WIDTH), + height: output.height.unwrap_or(EMPTY_MONITOR_CANVAS_HEIGHT), + } + }) + .collect() + } +} + +impl Dispatch for OutputProbe { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _queue_handle: &QueueHandle, + ) { + } +} + +impl Dispatch for OutputProbe { + fn event( + state: &mut Self, + _proxy: &wl_output::WlOutput, + event: wl_output::Event, + data: &OutputGlobal, + _connection: &Connection, + _queue_handle: &QueueHandle, + ) { + let output = state.output_mut(data.name); + match event { + wl_output::Event::Name { name } => output.name = Some(name), + wl_output::Event::Description { description } => { + output.description = Some(description); + } + wl_output::Event::Mode { + flags, + width, + height, + .. + } if output_size_is_current(flags) => { + output.width = Some(width); + output.height = Some(height); + } + _ => {} + } + } +} + +fn output_size_is_current(flags: WEnum) -> bool { + matches!(flags, WEnum::Value(flags) if flags.contains(wl_output::Mode::Current)) +} + fn save_monitor_positions(monitors: &[MonitorConfig]) -> std::io::Result> { let mut saved = None; for monitor in monitors { @@ -712,6 +903,52 @@ mod tests { ); } + #[test] + fn live_outputs_hide_saved_disconnected_monitors() { + let monitors = monitors_for_live_outputs( + &Settings::default().monitors, + &[LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2256, + height: 1504, + }], + ); + + assert_eq!(monitors.len(), 1); + assert_eq!(monitors[0].id, "primary"); + assert_eq!(monitors[0].name, "Built-in Display"); + assert_eq!((monitors[0].width, monitors[0].height), (2256, 1504)); + } + + #[test] + fn live_outputs_preserve_saved_connector_positions() { + let saved = vec![MonitorConfig { + id: "HDMI-A-1".to_string(), + name: "HDMI-A-1".to_string(), + width: 1920, + height: 1080, + x: -1920, + y: 200, + }]; + + let monitors = monitors_for_live_outputs( + &saved, + &[LiveOutput { + id: "HDMI-A-1".to_string(), + name: "External Monitor".to_string(), + width: 2560, + height: 1440, + }], + ); + + assert_eq!(monitors.len(), 1); + assert_eq!(monitors[0].id, "HDMI-A-1"); + assert_eq!(monitors[0].name, "External Monitor"); + assert_eq!((monitors[0].x, monitors[0].y), (-1920, 200)); + assert_eq!((monitors[0].width, monitors[0].height), (2560, 1440)); + } + #[test] fn layout_scale_does_not_change_when_monitors_are_dragged() { let mut monitors = Settings::default().monitors; -- 2.51.2 From e4e628ee326c5b1e60f6a4b4dd5f99374c9522ab Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 05:54:03 -0700 Subject: [PATCH 26/28] chore: temporarily disable cargo deny advsory --- deny.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 deny.toml diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..c1f7270 --- /dev/null +++ b/deny.toml @@ -0,0 +1,10 @@ +[advisories] +ignore = [ + # Temporary: `quick-xml` is pulled in by `wayland-scanner` and `zbus_xml`, + # which currently require `quick-xml = "0.39"`. Remove once those crates + # publish releases that allow `quick-xml >= 0.41`. + "RUSTSEC-2026-0194", + # Same `quick-xml` constraint issue as above; this was reported recently, + # so prefer dropping the ignore when upstream dependency bounds catch up. + "RUSTSEC-2026-0195", +] -- 2.51.2 From c76586917030c5faaea693848147381e1b8be452 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 06:32:00 -0700 Subject: [PATCH 27/28] feat: rearranging windows snap properly and handle hot loading --- .../src/compositor/input.rs | 68 ++++-- .../src/compositor/output.rs | 216 +++++++++++++++--- .../src/compositor/rendering.rs | 22 +- .../src/compositor/windows.rs | 47 +++- crates/hearthspace-settings/src/main.rs | 125 +++++++++- 5 files changed, 411 insertions(+), 67 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/input.rs b/crates/hearthspace-compositor/src/compositor/input.rs index e3247b0..aeb8f58 100644 --- a/crates/hearthspace-compositor/src/compositor/input.rs +++ b/crates/hearthspace-compositor/src/compositor/input.rs @@ -9,7 +9,7 @@ use smithay::{ keyboard::{FilterResult, keysyms}, pointer::{AxisFrame, ButtonEvent, MotionEvent}, }, - utils::{Logical, Point, SERIAL_COUNTER}, + utils::{Logical, Point, Rectangle, SERIAL_COUNTER}, }; use tracing::{debug, trace}; @@ -496,7 +496,7 @@ impl App { fn apply_pointer_motion(&mut self, location: Point, time: u32) { let previous_location = self.pointer_location; self.raw_pointer_location = location; - self.pointer_location = clamp_point_to_output(location, self.output_logical_size()); + self.pointer_location = clamp_point_to_outputs(location, &self.output_logical_rects()); if self.software_cursor_visible && self.pointer_location != previous_location { self.request_redraw(); } @@ -556,9 +556,9 @@ impl App { } pub(super) fn reconcile_pointer_after_output_geometry_change(&mut self) { - let output_size = self.output_logical_size(); + let output_rects = self.output_logical_rects(); let previous_location = self.pointer_location; - self.pointer_location = clamp_point_to_output(self.pointer_location, output_size); + self.pointer_location = clamp_point_to_outputs(self.pointer_location, &output_rects); self.raw_pointer_location = self.pointer_location; if self.software_cursor_visible && self.pointer_location != previous_location { self.request_redraw(); @@ -591,7 +591,7 @@ impl App { debug!( ?previous_location, location = ?self.pointer_location, - ?output_size, + output_rects = ?output_rects, "reconciled pointer after output geometry change" ); } @@ -633,20 +633,43 @@ fn add_synthetic_axis_to_frame(mut frame: AxisFrame, axis: Axis, amount: f64) -> frame } -fn clamp_point_to_output( +fn clamp_point_to_outputs( location: Point, - output_size: smithay::utils::Size, + output_rects: &[Rectangle], +) -> Point { + output_rects + .iter() + .map(|rect| clamp_point_to_rect(location, *rect)) + .min_by(|a, b| { + squared_distance(location, *a) + .partial_cmp(&squared_distance(location, *b)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap_or(location) +} + +fn clamp_point_to_rect( + location: Point, + rect: Rectangle, ) -> Point { Point::from(( - location - .x - .clamp(0.0, f64::from(output_size.w.saturating_sub(1))), - location - .y - .clamp(0.0, f64::from(output_size.h.saturating_sub(1))), + location.x.clamp( + f64::from(rect.loc.x), + f64::from(rect.loc.x + rect.size.w.saturating_sub(1)), + ), + location.y.clamp( + f64::from(rect.loc.y), + f64::from(rect.loc.y + rect.size.h.saturating_sub(1)), + ), )) } +fn squared_distance(a: Point, b: Point) -> f64 { + let dx = a.x - b.x; + let dy = a.y - b.y; + dx * dx + dy * dy +} + fn relative_pointer_location( raw_location: Point, delta: Point, @@ -663,15 +686,13 @@ fn event_time_msec() -> u32 { #[cfg(test)] mod tests { - use smithay::utils::Size; - use super::*; #[test] fn relative_motion_continues_from_unclamped_location_after_visible_pointer_hits_edge() { - let output_size: Size = (100, 100).into(); + let output_rects = [Rectangle::from_size((100, 100).into())]; let raw_location = Point::from((120.0, 50.0)); - let visible_location = clamp_point_to_output(raw_location, output_size); + let visible_location = clamp_point_to_outputs(raw_location, &output_rects); assert_eq!(visible_location, Point::from((99.0, 50.0))); assert_eq!( @@ -679,4 +700,17 @@ mod tests { Point::from((140.0, 50.0)) ); } + + #[test] + fn pointer_clamps_to_real_outputs_not_dead_space_in_bounding_box() { + let output_rects = [ + Rectangle::new((0, 0).into(), (2560, 1440).into()), + Rectangle::new((2560, 0).into(), (1366, 768).into()), + ]; + + assert_eq!( + clamp_point_to_outputs(Point::from((3200.0, 1200.0)), &output_rects), + Point::from((3200.0, 767.0)) + ); + } } diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index cd7d365..066bd90 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -1,6 +1,6 @@ use smithay::{ output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, - utils::{Logical, Physical, Point, Size, Transform}, + utils::{Logical, Physical, Point, Rectangle, Size, Transform}, }; use tracing::debug; use wayland_server::{DisplayHandle, backend::GlobalId, protocol::wl_surface::WlSurface}; @@ -117,16 +117,25 @@ impl OutputSet { } fn logical_size(&self) -> Size { - let primary_size = self.primary.logical_size(); - let mut width = primary_size.w; - let mut height = primary_size.h; - for output in &self.secondary { - let size = output.logical_size(); - width = width.max(output.location.x + size.w); - height = height.max(output.location.y + size.h); + let mut width = 1; + let mut height = 1; + for rect in self.logical_rects() { + width = width.max(rect.loc.x + rect.size.w); + height = height.max(rect.loc.y + rect.size.h); } Size::from((width.max(1), height.max(1))) } + + fn logical_rects(&self) -> Vec> { + std::iter::once(&self.primary) + .chain(self.secondary.iter()) + .map(OutputRecord::logical_rect) + .collect() + } + + fn minimum_logical_width(&self) -> i32 { + minimum_output_width(self.logical_rects().iter().copied()).unwrap_or(1) + } } /// Native layout policy: selected primary at (0, 0), all other connectors placed @@ -150,6 +159,10 @@ impl OutputRecord { self.size.to_logical(self.scale) } + fn logical_rect(&self) -> Rectangle { + Rectangle::new(self.location, self.logical_size()) + } + #[cfg(feature = "udev")] fn update(&mut self, descriptor: OutputDescriptor, location: Point) { self.size = descriptor.size; @@ -187,14 +200,23 @@ impl OutputLayout { } fn location_for(&self, output_name: &str, role_id: &str) -> Option> { + let use_role_fallback = !default_placeholder_monitors(&self.monitors); self.monitors .iter() .find(|monitor| monitor.id == output_name || monitor.name == output_name) - .or_else(|| self.monitors.iter().find(|monitor| monitor.id == role_id)) + .or_else(|| { + use_role_fallback + .then(|| self.monitors.iter().find(|monitor| monitor.id == role_id)) + .flatten() + }) .map(|monitor| Point::from((monitor.x, monitor.y))) } } +fn default_placeholder_monitors(monitors: &[hearthspace_ipc::MonitorConfig]) -> bool { + monitors == hearthspace_ipc::Settings::default().monitors +} + pub(in crate::compositor) fn load_output_layout() -> OutputLayout { match hearthspace_ipc::client_get_settings() { Ok(settings) => OutputLayout::from_monitors(settings.monitors), @@ -243,6 +265,13 @@ struct OutputLayoutPlan { secondary: Vec<(String, Point)>, } +#[derive(Debug, Clone)] +struct OutputPlacement { + name: String, + size: Size, + location: Point, +} + impl OutputLayoutPlan { fn secondary_location(&self, name: &str) -> Point { self.secondary @@ -259,22 +288,28 @@ fn output_locations_for_layout( layout: &OutputLayout, ) -> OutputLayoutPlan { secondary.sort_by(|a, b| a.name.cmp(&b.name)); + let fallback_primary = primary.clone(); + let fallback_secondary = secondary.clone(); let primary_location = layout .location_for(&primary.name, "primary") .unwrap_or((0, 0).into()); - let mut placed = vec![( - primary.name.clone(), - primary.logical_size(), - primary_location, - )]; + let mut placed = vec![OutputPlacement { + name: primary.name.clone(), + size: primary.logical_size(), + location: primary_location, + }]; let mut unplaced = Vec::new(); for (index, output) in secondary.into_iter().enumerate() { let role_id = secondary_role_id(index); if let Some(location) = layout.location_for(&output.name, &role_id) { let size = output.logical_size(); - placed.push((output.name, size, location)); + placed.push(OutputPlacement { + name: output.name, + size, + location, + }); } else { unplaced.push(output); } @@ -283,35 +318,44 @@ fn output_locations_for_layout( let fallback_y = primary_location.y; let mut next_x = placed .iter() - .map(|(_, size, location)| location.x + size.w) + .map(|placement| placement.location.x + placement.size.w) .max() .unwrap_or(0); for output in unplaced { let size = output.logical_size(); let location = Point::from((next_x, fallback_y)); next_x += size.w; - placed.push((output.name, size, location)); + placed.push(OutputPlacement { + name: output.name, + size, + location, + }); + } + + if placements_overlap(&placed) { + debug!(placements = ?placement_debug(&placed), "repairing overlapping active monitor layout"); + placed = fallback_output_placements(fallback_primary, fallback_secondary); } let min_x = placed .iter() - .map(|(_, _, location)| location.x) + .map(|placement| placement.location.x) .min() .unwrap_or(0); let min_y = placed .iter() - .map(|(_, _, location)| location.y) + .map(|placement| placement.location.y) .min() .unwrap_or(0); let mut primary_plan = Point::from((0, 0)); let mut secondary_plan = Vec::new(); - for (name, _, location) in placed { - let normalized = Point::from((location.x - min_x, location.y - min_y)); - if name == primary.name { + for placement in placed { + let normalized = Point::from((placement.location.x - min_x, placement.location.y - min_y)); + if placement.name == primary.name { primary_plan = normalized; } else { - secondary_plan.push((name, normalized)); + secondary_plan.push((placement.name, normalized)); } } @@ -321,6 +365,58 @@ fn output_locations_for_layout( } } +fn fallback_output_placements( + primary: OutputGeometry, + secondary: Vec, +) -> Vec { + let mut next_x = 0; + std::iter::once(primary) + .chain(secondary) + .map(|output| { + let size = output.logical_size(); + let location = Point::from((next_x, 0)); + next_x += size.w; + OutputPlacement { + name: output.name, + size, + location, + } + }) + .collect() +} + +fn placements_overlap(placements: &[OutputPlacement]) -> bool { + placements.iter().enumerate().any(|(index, placement)| { + let rect = Rectangle::new(placement.location, placement.size); + placements + .iter() + .skip(index + 1) + .any(|other| rectangles_overlap(rect, Rectangle::new(other.location, other.size))) + }) +} + +fn rectangles_overlap(a: Rectangle, b: Rectangle) -> bool { + a.loc.x < b.loc.x + b.size.w + && a.loc.x + a.size.w > b.loc.x + && a.loc.y < b.loc.y + b.size.h + && a.loc.y + a.size.h > b.loc.y +} + +fn placement_debug(placements: &[OutputPlacement]) -> Vec<(String, i32, i32, i32, i32)> { + placements + .iter() + .map(|placement| { + ( + placement.name.clone(), + placement.location.x, + placement.location.y, + placement.size.w, + placement.size.h, + ) + }) + .collect() +} + fn secondary_role_id(index: usize) -> String { if index == 0 { "secondary".to_string() @@ -338,6 +434,14 @@ impl App { self.outputs.logical_size() } + pub(super) fn output_logical_rects(&self) -> Vec> { + self.outputs.logical_rects() + } + + pub(super) fn minimum_output_logical_width(&self) -> i32 { + self.outputs.minimum_logical_width() + } + #[cfg(feature = "udev")] pub(in crate::compositor) fn set_output_layout(&mut self, layout: OutputLayout) -> bool { self.outputs.set_layout(layout) @@ -513,16 +617,22 @@ fn update_output_mode_with_refresh_at( ); } +fn minimum_output_width( + rects: impl IntoIterator>, +) -> Option { + rects.into_iter().map(|rect| rect.size.w.max(1)).min() +} + #[cfg(all(test, feature = "udev"))] mod tests { use smithay::{ output::{PhysicalProperties, Subpixel}, - utils::{Physical, Point, Size}, + utils::{Physical, Point, Rectangle, Size}, }; use super::{ - OutputDescriptor, OutputGeometry, OutputLayout, output_locations_for_layout, - secondary_descriptors_for_layout, + OutputDescriptor, OutputGeometry, OutputLayout, minimum_output_width, + output_locations_for_layout, secondary_descriptors_for_layout, }; fn descriptor(name: &str) -> OutputDescriptor { @@ -544,6 +654,14 @@ mod tests { OutputGeometry::from(&descriptor(name)) } + fn geometry_with_size(name: &str, width: i32, height: i32) -> OutputGeometry { + OutputGeometry { + name: name.into(), + size: Size::::from((width, height)), + scale: 1, + } + } + fn layout(monitors: &[(&str, i32, i32)]) -> OutputLayout { OutputLayout::from_monitors( monitors @@ -617,6 +735,52 @@ mod tests { assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 100))); } + #[test] + fn output_layout_ignores_default_placeholder_positions_for_mixed_resolutions() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 2560, 1440), + vec![geometry_with_size("HDMI-A-1", 1366, 768)], + &OutputLayout::from_monitors(hearthspace_ipc::Settings::default().monitors), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); + } + + #[test] + fn output_layout_repairs_overlapping_saved_active_monitors() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 2560, 1440), + vec![geometry_with_size("HDMI-A-1", 1366, 768)], + &layout(&[("primary", -1280, -736), ("secondary", -1920, -540)]), + ); + + assert_eq!(plan.primary, Point::from((0, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((2560, 0))); + } + + #[test] + fn output_layout_size_includes_primary_location() { + let plan = output_locations_for_layout( + geometry_with_size("eDP-1", 100, 100), + vec![geometry_with_size("HDMI-A-1", 100, 100)], + &layout(&[("eDP-1", 100, 0), ("HDMI-A-1", 0, 0)]), + ); + + assert_eq!(plan.primary, Point::from((100, 0))); + assert_eq!(plan.secondary_location("HDMI-A-1"), Point::from((0, 0))); + } + + #[test] + fn minimum_output_width_uses_smallest_monitor() { + let width = minimum_output_width([ + Rectangle::new((0, 0).into(), (2560, 1440).into()), + Rectangle::new((2560, 0).into(), (1366, 768).into()), + ]); + + assert_eq!(width, Some(1366)); + } + #[test] fn output_layout_places_unconfigured_outputs_to_the_right() { let plan = output_locations_for_layout( diff --git a/crates/hearthspace-compositor/src/compositor/rendering.rs b/crates/hearthspace-compositor/src/compositor/rendering.rs index 1ef4faf..0563605 100644 --- a/crates/hearthspace-compositor/src/compositor/rendering.rs +++ b/crates/hearthspace-compositor/src/compositor/rendering.rs @@ -128,12 +128,19 @@ impl App { elements.push(titlebar); } - let origin = self.surface_screen_origin(index); let scale = self.window_render_scale(index); - for surface in self.window_render_elements(renderer, index) { - elements.push(HearthspaceRenderElement::from( - RescaleRenderElement::from_element(surface, origin, scale), - )); + let origins = match self.windows[index].kind { + ManagedWindowKind::ShellBar => self.shell_bar_screen_origins(), + ManagedWindowKind::Normal | ManagedWindowKind::Launcher => { + vec![self.surface_screen_origin(index)] + } + }; + for origin in origins { + for surface in self.window_render_elements_at(renderer, index, origin) { + elements.push(HearthspaceRenderElement::from( + RescaleRenderElement::from_element(surface, origin, scale), + )); + } } } @@ -280,16 +287,17 @@ impl App { .collect() } - pub(super) fn window_render_elements( + fn window_render_elements_at( &self, renderer: &mut GlesRenderer, window_index: usize, + origin: Point, ) -> Vec> { let window = &self.windows[window_index]; render_elements_from_surface_tree( renderer, window.surface.wl_surface(), - self.surface_screen_origin(window_index), + origin, // Built at native scale; the viewport zoom is applied by wrapping // these elements in a `RescaleRenderElement` in the caller. 1.0, diff --git a/crates/hearthspace-compositor/src/compositor/windows.rs b/crates/hearthspace-compositor/src/compositor/windows.rs index e520f1b..a284ee7 100644 --- a/crates/hearthspace-compositor/src/compositor/windows.rs +++ b/crates/hearthspace-compositor/src/compositor/windows.rs @@ -203,14 +203,26 @@ impl App { // space above the canvas, so it is hit-tested first using each surface's // own screen-space origin. for (window_index, window) in self.windows.iter().enumerate().rev() { - let origin = match window.kind { - ManagedWindowKind::ShellBar => Point::from((0, 0)), - ManagedWindowKind::Launcher => self.launcher_screen_logical_origin(), + match window.kind { + ManagedWindowKind::ShellBar => { + for origin in self.shell_bar_logical_origins() { + if let Some(target) = + self.hit_test_shell_surface(window_index, location, origin) + { + return Some(target); + } + } + } + ManagedWindowKind::Launcher => { + if let Some(target) = self.hit_test_shell_surface( + window_index, + location, + self.launcher_screen_logical_origin(), + ) { + return Some(target); + } + } ManagedWindowKind::Normal => continue, - }; - - if let Some(target) = self.hit_test_shell_surface(window_index, location, origin) { - return Some(target); } } @@ -331,9 +343,10 @@ impl App { .replace(std::iter::empty::()); match kind { ManagedWindowKind::ShellBar => { - let output_size = self.output_size(); - state.size = Some((output_size.w, CONTROL_BAR_HEIGHT).into()); - state.bounds = Some((output_size.w, CONTROL_BAR_HEIGHT).into()); + let width = self.minimum_output_logical_width(); + trace!(width, "configuring shell bar to smallest output width"); + state.size = Some((width, CONTROL_BAR_HEIGHT).into()); + state.bounds = Some((width, CONTROL_BAR_HEIGHT).into()); state.decoration_mode = Some(DecorationMode::ClientSide); } ManagedWindowKind::Launcher => { @@ -558,6 +571,20 @@ impl App { Point::::from((0, 0)).to_physical(1) } + pub(super) fn shell_bar_screen_origins(&self) -> Vec> { + self.shell_bar_logical_origins() + .into_iter() + .map(|origin| origin.to_physical(1)) + .collect() + } + + fn shell_bar_logical_origins(&self) -> Vec> { + self.output_logical_rects() + .into_iter() + .map(|rect| rect.loc) + .collect() + } + /// Logical screen-space origin of the launcher palette: pinned to the left /// edge, directly below the bar. fn launcher_screen_logical_origin(&self) -> Point { diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index c18a3ca..912fcbd 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -243,15 +243,24 @@ fn monitors_for_live_outputs( ) -> Vec { let mut monitors = Vec::with_capacity(live_outputs.len()); let mut next_x = 0; + let saved_monitors_are_placeholders = default_placeholder_monitors(saved_monitors); for (index, output) in live_outputs.iter().enumerate() { let role_id = monitor_role_id(index); - let saved = saved_monitors - .iter() - .find(|monitor| monitor.id == output.id || monitor.name == output.name) - .or_else(|| saved_monitors.iter().find(|monitor| monitor.id == role_id)); - let (id, x, y) = saved - .map(|monitor| (monitor.id.clone(), monitor.x, monitor.y)) - .unwrap_or_else(|| (output.id.clone(), next_x, 0)); + let saved = (!saved_monitors_are_placeholders) + .then(|| { + saved_monitors + .iter() + .find(|monitor| monitor.id == output.id || monitor.name == output.name) + .or_else(|| saved_monitors.iter().find(|monitor| monitor.id == role_id)) + }) + .flatten(); + let (id, x, y) = if let Some(saved) = saved { + (saved.id.clone(), saved.x, saved.y) + } else if saved_monitors_are_placeholders && index < 2 { + (role_id, next_x, 0) + } else { + (output.id.clone(), next_x, 0) + }; monitors.push(MonitorConfig { id, name: output.name.clone(), @@ -262,9 +271,41 @@ fn monitors_for_live_outputs( }); next_x += output.width.max(1); } + if monitors_overlap(&monitors) { + place_monitors_edge_to_edge(&mut monitors); + } monitors } +fn place_monitors_edge_to_edge(monitors: &mut [MonitorConfig]) { + let mut next_x = 0; + for monitor in monitors { + monitor.x = next_x; + monitor.y = 0; + next_x += monitor.width.max(1); + } +} + +fn monitors_overlap(monitors: &[MonitorConfig]) -> bool { + monitors.iter().enumerate().any(|(index, monitor)| { + monitors + .iter() + .skip(index + 1) + .any(|other| monitor_rects_overlap(monitor, other)) + }) +} + +fn monitor_rects_overlap(a: &MonitorConfig, b: &MonitorConfig) -> bool { + a.x < b.x + b.width + && a.x + a.width > b.x + && a.y < b.y + b.height + && a.y + a.height > b.y +} + +fn default_placeholder_monitors(monitors: &[MonitorConfig]) -> bool { + monitors == Settings::default().monitors +} + fn monitor_role_id(index: usize) -> String { if index == 0 { "primary".to_string() @@ -921,6 +962,76 @@ mod tests { assert_eq!((monitors[0].width, monitors[0].height), (2256, 1504)); } + #[test] + fn live_outputs_do_not_reuse_placeholder_positions_for_mixed_resolutions() { + let monitors = monitors_for_live_outputs( + &Settings::default().monitors, + &[ + LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2560, + height: 1440, + }, + LiveOutput { + id: "HDMI-A-1".to_string(), + name: "External Monitor".to_string(), + width: 1366, + height: 768, + }, + ], + ); + + assert_eq!(monitors[0].id, "primary"); + assert_eq!((monitors[0].x, monitors[0].y), (0, 0)); + assert_eq!(monitors[1].id, "secondary"); + assert_eq!((monitors[1].x, monitors[1].y), (2560, 0)); + } + + #[test] + fn live_outputs_repair_overlapping_saved_active_monitors() { + let saved = vec![ + MonitorConfig { + id: "primary".to_string(), + name: "Primary".to_string(), + width: 1920, + height: 1080, + x: -1280, + y: -736, + }, + MonitorConfig { + id: "secondary".to_string(), + name: "Secondary".to_string(), + width: 1920, + height: 1080, + x: -1920, + y: -540, + }, + ]; + + let monitors = monitors_for_live_outputs( + &saved, + &[ + LiveOutput { + id: "eDP-1".to_string(), + name: "Built-in Display".to_string(), + width: 2560, + height: 1440, + }, + LiveOutput { + id: "HDMI-A-1".to_string(), + name: "External Monitor".to_string(), + width: 1366, + height: 768, + }, + ], + ); + + assert_eq!((monitors[0].x, monitors[0].y), (0, 0)); + assert_eq!((monitors[1].x, monitors[1].y), (2560, 0)); + assert!(!monitors_overlap(&monitors)); + } + #[test] fn live_outputs_preserve_saved_connector_positions() { let saved = vec![MonitorConfig { -- 2.51.2 From c2ae5136e15631743a2350a989be5510598608ab Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 3 Jul 2026 06:51:50 -0700 Subject: [PATCH 28/28] chore: fix lint/format --- .../src/compositor/output.rs | 4 +- .../src/compositor/shell_integration.rs | 10 ++--- crates/hearthspace-compositor/src/main.rs | 35 ++++++++-------- crates/hearthspace-session/src/main.rs | 40 ++++++++++++++----- crates/hearthspace-settings/src/main.rs | 5 +-- crates/hearthspace-shell/src/app_catalog.rs | 17 ++++---- 6 files changed, 64 insertions(+), 47 deletions(-) diff --git a/crates/hearthspace-compositor/src/compositor/output.rs b/crates/hearthspace-compositor/src/compositor/output.rs index 066bd90..b719907 100644 --- a/crates/hearthspace-compositor/src/compositor/output.rs +++ b/crates/hearthspace-compositor/src/compositor/output.rs @@ -617,9 +617,7 @@ fn update_output_mode_with_refresh_at( ); } -fn minimum_output_width( - rects: impl IntoIterator>, -) -> Option { +fn minimum_output_width(rects: impl IntoIterator>) -> Option { rects.into_iter().map(|rect| rect.size.w.max(1)).min() } diff --git a/crates/hearthspace-compositor/src/compositor/shell_integration.rs b/crates/hearthspace-compositor/src/compositor/shell_integration.rs index 70f061b..b88d1d7 100644 --- a/crates/hearthspace-compositor/src/compositor/shell_integration.rs +++ b/crates/hearthspace-compositor/src/compositor/shell_integration.rs @@ -16,10 +16,7 @@ use smithay::{ }; use tracing::{debug, error, info, trace, warn}; -use crate::{ - config::*, - geometry::CanvasPoint, -}; +use crate::{config::*, geometry::CanvasPoint}; use hearthspace_shell::{ ShellCommand, SpawnTarget, app_catalog::{DesktopApp, spawn_argv_with_env}, @@ -70,7 +67,10 @@ fn ensure_snap_wayland_socket(instance_name: &str) -> std::io::Result { Err(error) => return Err(error), } - symlink(PathBuf::from("..").join(&wayland_display), &snap_socket_path)?; + symlink( + PathBuf::from("..").join(&wayland_display), + &snap_socket_path, + )?; Ok(wayland_display) } diff --git a/crates/hearthspace-compositor/src/main.rs b/crates/hearthspace-compositor/src/main.rs index e9de705..4245d43 100644 --- a/crates/hearthspace-compositor/src/main.rs +++ b/crates/hearthspace-compositor/src/main.rs @@ -61,7 +61,10 @@ fn parse_backend_selection( hearthspace_compositor::BackendSelection::Headless, )); } - if args.iter().any(|arg| arg == hearthspace_compositor::config::TTY_FLAG) { + if args + .iter() + .any(|arg| arg == hearthspace_compositor::config::TTY_FLAG) + { selected.push(( hearthspace_compositor::config::TTY_FLAG, hearthspace_compositor::BackendSelection::Udev, @@ -101,12 +104,10 @@ fn parse_exit_after(args: &[String]) -> Result, Stri return parse_positive_duration_ms(value).map(Some); } - if let Some(value) = - arg.strip_prefix(&format!( - "{}=", - hearthspace_compositor::config::EXIT_AFTER_MS_FLAG - )) - { + if let Some(value) = arg.strip_prefix(&format!( + "{}=", + hearthspace_compositor::config::EXIT_AFTER_MS_FLAG + )) { return parse_positive_duration_ms(value).map(Some); } } @@ -126,12 +127,10 @@ fn parse_headless_output_size(args: &[String]) -> Result, Str return parse_size(value).map(Some); } - if let Some(value) = - arg.strip_prefix(&format!( - "{}=", - hearthspace_compositor::config::HEADLESS_SIZE_FLAG - )) - { + if let Some(value) = arg.strip_prefix(&format!( + "{}=", + hearthspace_compositor::config::HEADLESS_SIZE_FLAG + )) { return parse_size(value).map(Some); } } @@ -151,12 +150,10 @@ fn parse_headless_output_scale(args: &[String]) -> Result, String> { return parse_positive_dimension(value, "scale").map(Some); } - if let Some(value) = - arg.strip_prefix(&format!( - "{}=", - hearthspace_compositor::config::HEADLESS_SCALE_FLAG - )) - { + if let Some(value) = arg.strip_prefix(&format!( + "{}=", + hearthspace_compositor::config::HEADLESS_SCALE_FLAG + )) { return parse_positive_dimension(value, "scale").map(Some); } } diff --git a/crates/hearthspace-session/src/main.rs b/crates/hearthspace-session/src/main.rs index 6eb9308..f753240 100644 --- a/crates/hearthspace-session/src/main.rs +++ b/crates/hearthspace-session/src/main.rs @@ -369,7 +369,9 @@ impl Supervisor { if args.watch_apps { warn!("app watcher requested, but neither cargo-watch nor watchexec was found"); } else { - warn!("cargo-watch/watchexec not found; Hearthspace Settings will build on first launch"); + warn!( + "cargo-watch/watchexec not found; Hearthspace Settings will build on first launch" + ); } return Ok(()); }; @@ -395,7 +397,10 @@ impl Supervisor { } fn wait_for_compositor_ready(&mut self) -> Result<(), Box> { - let socket = self.paths.runtime_dir.join(&self.session_env.wayland_display); + let socket = self + .paths + .runtime_dir + .join(&self.session_env.wayland_display); info!(socket = %socket.display(), "waiting for compositor readiness"); let deadline = Instant::now() + Duration::from_secs(120); while Instant::now() < deadline { @@ -460,9 +465,13 @@ impl Supervisor { wait_then_kill_process(&mut self.watcher); match fs::remove_dir_all(&self.paths.session_dir) { - Ok(()) => info!(path = %self.paths.session_dir.display(), "removed session runtime directory"), + Ok(()) => { + info!(path = %self.paths.session_dir.display(), "removed session runtime directory") + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => warn!(path = %self.paths.session_dir.display(), %error, "failed to remove session runtime directory"), + Err(error) => { + warn!(path = %self.paths.session_dir.display(), %error, "failed to remove session runtime directory") + } } } @@ -527,7 +536,9 @@ impl Supervisor { self.session_env .vars .iter() - .filter(|(key, _)| include_client_wayland || key != hearthspace_common::WAYLAND_DISPLAY_ENV) + .filter(|(key, _)| { + include_client_wayland || key != hearthspace_common::WAYLAND_DISPLAY_ENV + }) .cloned() .collect() } @@ -673,12 +684,18 @@ fn terminate_process(process: &mut Option) { return; } let Ok(raw_pid) = i32::try_from(process.child.id()) else { - warn!(name = process.spec.name, pid = process.child.id(), "cannot signal process with oversized pid"); + warn!( + name = process.spec.name, + pid = process.child.id(), + "cannot signal process with oversized pid" + ); return; }; match signal::kill(Pid::from_raw(raw_pid), signal::Signal::SIGTERM) { Ok(()) => info!(name = process.spec.name, pid = raw_pid, "sent SIGTERM"), - Err(error) => warn!(name = process.spec.name, pid = raw_pid, %error, "failed to send SIGTERM"), + Err(error) => { + warn!(name = process.spec.name, pid = raw_pid, %error, "failed to send SIGTERM") + } } } @@ -702,8 +719,13 @@ fn wait_then_kill_process(process: &mut Option) { } match process.child.kill() { - Ok(()) => info!(name = process.spec.name, "killed process after shutdown timeout"), - Err(error) => warn!(name = process.spec.name, %error, "failed to kill process after shutdown timeout"), + Ok(()) => info!( + name = process.spec.name, + "killed process after shutdown timeout" + ), + Err(error) => { + warn!(name = process.spec.name, %error, "failed to kill process after shutdown timeout") + } } let _ = process.child.wait(); } diff --git a/crates/hearthspace-settings/src/main.rs b/crates/hearthspace-settings/src/main.rs index 912fcbd..3bce458 100644 --- a/crates/hearthspace-settings/src/main.rs +++ b/crates/hearthspace-settings/src/main.rs @@ -296,10 +296,7 @@ fn monitors_overlap(monitors: &[MonitorConfig]) -> bool { } fn monitor_rects_overlap(a: &MonitorConfig, b: &MonitorConfig) -> bool { - a.x < b.x + b.width - && a.x + a.width > b.x - && a.y < b.y + b.height - && a.y + a.height > b.y + a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y } fn default_placeholder_monitors(monitors: &[MonitorConfig]) -> bool { diff --git a/crates/hearthspace-shell/src/app_catalog.rs b/crates/hearthspace-shell/src/app_catalog.rs index ab64078..904fb06 100644 --- a/crates/hearthspace-shell/src/app_catalog.rs +++ b/crates/hearthspace-shell/src/app_catalog.rs @@ -597,8 +597,8 @@ pub fn spawn_argv_with_env( fn session_environment_keys() -> &'static [&'static str] { &[ - "XDG_CURRENT_DESKTOP", - "DESKTOP_SESSION", + "XDG_CURRENT_DESKTOP", + "DESKTOP_SESSION", hearthspace_common::SETTINGS_SOCKET_ENV, hearthspace_common::SESSION_SOCKET_ENV, hearthspace_common::REPO_ROOT_ENV, @@ -832,9 +832,7 @@ mod tests { #[test] fn source_tree_settings_desktop_file_is_searchable_and_launchable() { - let catalog = AppCatalog::load_from_data_dirs(vec![ - source_tree_data_dir(), - ]); + let catalog = AppCatalog::load_from_data_dirs(vec![source_tree_data_dir()]); let app = catalog .app_by_id("dev.hearthspace.Settings.desktop") @@ -865,7 +863,9 @@ mod tests { let catalog = AppCatalog::load_from_data_dirs(vec![dir.path().join("share")]); - let app = catalog.app_by_id("dev.hearthspace.Settings.desktop").unwrap(); + let app = catalog + .app_by_id("dev.hearthspace.Settings.desktop") + .unwrap(); assert_eq!(app.name, "Hearthspace Settings"); assert_eq!(catalog.search("hearthspace", 1)[0].id, app.id); assert_eq!(catalog.search("preferences", 1)[0].id, app.id); @@ -894,7 +894,10 @@ mod tests { ) .unwrap(); }; - write(session.path(), "\"/repo/scripts/dev-run-app\" hearthspace-settings"); + write( + session.path(), + "\"/repo/scripts/dev-run-app\" hearthspace-settings", + ); write(source.path(), "hearthspace-settings"); let catalog = AppCatalog::load_from_data_dirs(vec![ -- 2.51.2