diff --git a/Cargo.lock b/Cargo.lock index 3cb118e9..4f91779a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -135,6 +141,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "cairo-rs" version = "0.21.2" @@ -184,6 +196,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -335,6 +353,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "femtovg" version = "0.19.3" @@ -578,6 +612,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gio" version = "0.21.2" @@ -982,9 +1028,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.176" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" @@ -1012,6 +1058,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.14" @@ -1058,6 +1110,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.7.6" @@ -1074,7 +1137,19 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom", + "getrandom 0.2.16", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] @@ -1195,6 +1270,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1222,7 +1303,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", ] [[package]] @@ -1317,6 +1398,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1361,6 +1455,7 @@ name = "satty" version = "0.20.0" dependencies = [ "anyhow", + "base64", "chrono", "clap", "clap_complete", @@ -1376,6 +1471,7 @@ dependencies = [ "hex_color", "keycode", "libloading 0.9.0", + "nix", "pango", "relm4", "relm4-icons", @@ -1384,6 +1480,8 @@ dependencies = [ "satty_cli", "serde", "serde_derive", + "serde_json", + "tempfile", "thiserror", "tokio", "toml 0.9.8", @@ -1486,6 +1584,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +dependencies = [ + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -1513,6 +1620,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "spin" version = "0.9.8" @@ -1558,6 +1675,19 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.17" @@ -1584,7 +1714,25 @@ version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1788,6 +1936,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -2017,6 +2174,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "xdg" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index b34963a9..42aa53b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,9 +19,14 @@ include = [ [dependencies] satty_cli.workspace = true relm4 = { version = "0.10.0", features = ["macros", "libadwaita", "gnome_42"] } -tokio = { version = "1.48.0", features = ["time"] } +tokio = { version = "1.48.0", features = ["time", "net", "io-util", "sync", "rt", "signal", "macros", "rt-multi-thread"] } gdk-pixbuf = "0.21.2" +# Daemon mode dependencies +serde_json = "1.0" +base64 = "0.22" +nix = { version = "0.29", features = ["user", "fs"] } + # error handling anyhow = "1.0" thiserror = "2.0" @@ -61,6 +66,9 @@ satty_cli.workspace = true clap_complete_fig = "4.5.2" relm4-icons-build = "0.10" +[dev-dependencies] +tempfile = "3" + [workspace] members = [ "cli" ] diff --git a/README.md b/README.md index a9e9855f..f461411a 100644 --- a/README.md +++ b/README.md @@ -191,13 +191,17 @@ custom = [ » satty --help Modern Screenshot Annotation. -Usage: satty [OPTIONS] --filename +Usage: satty [OPTIONS] Options: -c, --config Path to the config file. Otherwise will be read from XDG_CONFIG_DIR/satty/config.toml -f, --filename - Path to input image or '-' to read from stdin + Path to input image or '-' to read from stdin Not required when using --daemon mode + --daemon + (NEXTRELEASE) Run as daemon, keeping GTK initialized between calls for faster startup. Start with: satty --daemon Then use: satty --show -f /path/to/image.png + --show + (NEXTRELEASE) Send image to running daemon (falls back to normal start if no daemon). Requires a running daemon started with --daemon --fullscreen Start Satty in fullscreen mode -o, --output-filename @@ -304,6 +308,63 @@ mode $printscreen_mode { bindsym $mod+Shift+p mode $printscreen_mode ``` +### Daemon Mode + +For faster startup, Satty can run in daemon mode. This keeps GTK4 initialized in the background, reducing window creation time from ~250ms to ~25ms. + +#### Starting the daemon + +```sh +# Start daemon in background +satty --daemon & + +# Or use the provided systemd service +``` + +#### Sending images to the daemon + +```sh +# Basic usage +satty --show -f /tmp/screenshot.png + +# With additional options +satty --show -f /tmp/screenshot.png --fullscreen --output-filename ~/Pictures/annotated.png +``` + +#### Integration with screenshot tools + +```sh +# With grim and slurp on wlroots compositors +grim -g "$(slurp)" -t ppm - | satty --show -f - +``` + +#### Systemd user service + +Create `~/.config/systemd/user/satty.service`: + +```ini +[Unit] +Description=Satty Screenshot Annotation Daemon +After=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/satty --daemon +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=graphical-session.target +``` + +Then enable and start: + +```sh +systemctl --user daemon-reload +systemctl --user enable satty.service +systemctl --user start satty.service +``` + ## Build from source You first need to install the native dependencies of Satty (see below) and then run: diff --git a/cli/src/command_line.rs b/cli/src/command_line.rs index 6ced94af..02696cc8 100644 --- a/cli/src/command_line.rs +++ b/cli/src/command_line.rs @@ -8,8 +8,20 @@ pub struct CommandLine { pub config: Option, /// Path to input image or '-' to read from stdin - #[arg(short, long)] - pub filename: String, + /// Not required when using --daemon mode + #[arg(short, long, required_unless_present = "daemon")] + pub filename: Option, + + /// (NEXTRELEASE) Run as daemon, keeping GTK initialized between calls for faster startup. + /// Start with: satty --daemon + /// Then use: satty --show -f /path/to/image.png + #[arg(long, conflicts_with = "show")] + pub daemon: bool, + + /// (NEXTRELEASE) Send image to running daemon (falls back to normal start if no daemon). + /// Requires a running daemon started with --daemon + #[arg(long, conflicts_with = "daemon")] + pub show: bool, /// Start Satty in fullscreen mode #[arg(long)] diff --git a/src/configuration.rs b/src/configuration.rs index af344a08..e5f8b4e2 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -34,7 +34,9 @@ enum ConfigurationFileError { } pub struct Configuration { - input_filename: String, + input_filename: Option, + daemon_mode: bool, + show_mode: bool, output_filename: Option, fullscreen: bool, early_exit: bool, @@ -305,7 +307,11 @@ impl Configuration { // --- } fn merge(&mut self, file: Option, command_line: CommandLine) { - // input_filename is required and needs to be overwritten + // Set daemon/show mode flags + self.daemon_mode = command_line.daemon; + self.show_mode = command_line.show; + + // input_filename - now optional (not required in daemon mode) self.input_filename = command_line.filename; // overwrite with all specified values from config file @@ -435,7 +441,15 @@ impl Configuration { } pub fn input_filename(&self) -> &str { - self.input_filename.as_ref() + self.input_filename.as_deref().unwrap_or("") + } + + pub fn daemon_mode(&self) -> bool { + self.daemon_mode + } + + pub fn show_mode(&self) -> bool { + self.show_mode } pub fn annotation_size_factor(&self) -> f32 { @@ -514,7 +528,9 @@ impl Configuration { impl Default for Configuration { fn default() -> Self { Self { - input_filename: String::new(), + input_filename: None, + daemon_mode: false, + show_mode: false, output_filename: None, fullscreen: false, early_exit: false, diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 00000000..d1135f88 --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,69 @@ +//! Daemon mode for Satty +//! +//! This module implements a daemon mode that keeps GTK4 initialized between +//! screenshot annotation calls, significantly reducing startup time. +//! +//! ## Architecture +//! +//! - **Daemon process**: Runs with `--daemon` flag, initializes GTK without showing a window, +//! listens on a Unix socket for requests +//! - **Client process**: Runs with `--show` flag, connects to the daemon socket and sends +//! image path + configuration, then exits +//! - **Multi-window**: Each request creates a new independent window +//! +//! ## Usage +//! +//! ```bash +//! # Start daemon (e.g., on login) +//! satty --daemon & +//! +//! # Send images to daemon +//! satty --show -f /tmp/screenshot.png -o /tmp/output.png +//! ``` + +pub mod protocol; +pub mod request_config; +pub mod security; +pub mod socket; + +#[cfg(test)] +mod tests; + +pub use protocol::{DaemonRequest, DaemonResponse, ResponseStatus}; +pub use request_config::RequestConfig; +pub use security::validate_image_path; +pub use socket::{DaemonClient, DaemonServer}; + +use std::path::PathBuf; + +/// Get the socket path for the current user +pub fn get_socket_path() -> PathBuf { + let uid = nix::unistd::getuid(); + PathBuf::from(format!("/tmp/satty-{}.sock", uid)) +} + +/// Check if a daemon is already running +pub fn is_daemon_running() -> bool { + let socket_path = get_socket_path(); + if !socket_path.exists() { + return false; + } + + // Try to connect to verify the daemon is actually running + match std::os::unix::net::UnixStream::connect(&socket_path) { + Ok(_) => true, + Err(_) => { + // Socket exists but can't connect - stale socket + false + } + } +} + +/// Remove stale socket file if it exists +pub fn remove_stale_socket() -> std::io::Result<()> { + let socket_path = get_socket_path(); + if socket_path.exists() { + std::fs::remove_file(&socket_path)?; + } + Ok(()) +} diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs new file mode 100644 index 00000000..7af863ed --- /dev/null +++ b/src/daemon/protocol.rs @@ -0,0 +1,338 @@ +//! Protocol message types for daemon-client communication +//! +//! Messages are framed with a 4-byte little-endian length prefix followed by JSON payload. +//! Maximum message size is 16MB to support base64-encoded images via stdin. + +use serde_derive::{Deserialize, Serialize}; +use std::io::{self, Read, Write}; +use thiserror::Error; + +/// Maximum message size (16MB for base64-encoded images) +pub const MAX_MESSAGE_SIZE: usize = 16 * 1024 * 1024; + +/// Length prefix size in bytes +pub const LENGTH_PREFIX_SIZE: usize = 4; + +#[derive(Error, Debug)] +pub enum ProtocolError { + #[error("Message too large: {0} bytes (max {MAX_MESSAGE_SIZE})")] + MessageTooLarge(usize), + + #[error("Invalid JSON: {0}")] + InvalidJson(#[from] serde_json::Error), + + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("Missing required field: {0}")] + MissingField(&'static str), + + #[error("Connection closed")] + ConnectionClosed, +} + +/// Request from client to daemon +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonRequest { + /// Path to image file, or "-" for stdin data + pub filename: String, + + /// Output filename for saving + #[serde(skip_serializing_if = "Option::is_none")] + pub output_filename: Option, + + /// Command to use for copying to clipboard + #[serde(skip_serializing_if = "Option::is_none")] + pub copy_command: Option, + + /// Initial tool to select + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_tool: Option, + + /// Start in fullscreen mode + #[serde(skip_serializing_if = "Option::is_none")] + pub fullscreen: Option, + + /// Exit after first save/copy action + #[serde(skip_serializing_if = "Option::is_none")] + pub early_exit: Option, + + /// Corner roundness for shapes + #[serde(skip_serializing_if = "Option::is_none")] + pub corner_roundness: Option, + + /// Annotation size factor + #[serde(skip_serializing_if = "Option::is_none")] + pub annotation_size_factor: Option, + + /// Hide toolbars by default + #[serde(skip_serializing_if = "Option::is_none")] + pub default_hide_toolbars: Option, + + /// Disable window decoration + #[serde(skip_serializing_if = "Option::is_none")] + pub no_window_decoration: Option, + + /// Base64-encoded image data for stdin mode + /// Only used when filename is "-" + #[serde(skip_serializing_if = "Option::is_none")] + pub stdin_data: Option, +} + +impl DaemonRequest { + /// Create a new request with only the required filename + pub fn new(filename: impl Into) -> Self { + Self { + filename: filename.into(), + output_filename: None, + copy_command: None, + initial_tool: None, + fullscreen: None, + early_exit: None, + corner_roundness: None, + annotation_size_factor: None, + default_hide_toolbars: None, + no_window_decoration: None, + stdin_data: None, + } + } + + /// Validate the request + pub fn validate(&self) -> Result<(), ProtocolError> { + if self.filename.is_empty() { + return Err(ProtocolError::MissingField("filename")); + } + + // If filename is "-", stdin_data must be present + if self.filename == "-" && self.stdin_data.is_none() { + return Err(ProtocolError::MissingField( + "stdin_data (required when filename is '-')", + )); + } + + Ok(()) + } + + /// Serialize to JSON bytes + pub fn to_bytes(&self) -> Result, ProtocolError> { + let json = serde_json::to_vec(self)?; + if json.len() > MAX_MESSAGE_SIZE { + return Err(ProtocolError::MessageTooLarge(json.len())); + } + Ok(json) + } + + /// Deserialize from JSON bytes + pub fn from_bytes(bytes: &[u8]) -> Result { + let request: Self = serde_json::from_slice(bytes)?; + request.validate()?; + Ok(request) + } +} + +/// Response status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseStatus { + /// Request was successful + Ok, + /// An error occurred + Error, +} + +/// Response from daemon to client +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonResponse { + /// Status of the request + pub status: ResponseStatus, + + /// Window ID (for successful requests) + #[serde(skip_serializing_if = "Option::is_none")] + pub window_id: Option, + + /// Error or informational message + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl DaemonResponse { + /// Create a successful response + pub fn ok(window_id: u64) -> Self { + Self { + status: ResponseStatus::Ok, + window_id: Some(window_id), + message: None, + } + } + + /// Create an error response + pub fn error(message: impl Into) -> Self { + Self { + status: ResponseStatus::Error, + window_id: None, + message: Some(message.into()), + } + } + + /// Serialize to JSON bytes + pub fn to_bytes(&self) -> Result, ProtocolError> { + let json = serde_json::to_vec(self)?; + Ok(json) + } + + /// Deserialize from JSON bytes + pub fn from_bytes(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) + } +} + +/// Write a length-prefixed message to a writer +pub fn write_message(writer: &mut W, data: &[u8]) -> Result<(), ProtocolError> { + if data.len() > MAX_MESSAGE_SIZE { + return Err(ProtocolError::MessageTooLarge(data.len())); + } + + let len = data.len() as u32; + writer.write_all(&len.to_le_bytes())?; + writer.write_all(data)?; + writer.flush()?; + Ok(()) +} + +/// Read a length-prefixed message from a reader +pub fn read_message(reader: &mut R) -> Result, ProtocolError> { + let mut len_buf = [0u8; LENGTH_PREFIX_SIZE]; + match reader.read_exact(&mut len_buf) { + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => { + return Err(ProtocolError::ConnectionClosed); + } + Err(e) => return Err(e.into()), + } + + let len = u32::from_le_bytes(len_buf) as usize; + + if len > MAX_MESSAGE_SIZE { + return Err(ProtocolError::MessageTooLarge(len)); + } + + let mut data = vec![0u8; len]; + reader.read_exact(&mut data)?; + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_request_minimal() { + let req = DaemonRequest::new("/tmp/test.png"); + assert_eq!(req.filename, "/tmp/test.png"); + assert!(req.validate().is_ok()); + } + + #[test] + fn test_request_full() { + let req = DaemonRequest { + filename: "/tmp/test.png".into(), + output_filename: Some("/tmp/output.png".into()), + copy_command: Some("wl-copy".into()), + initial_tool: Some("arrow".into()), + fullscreen: Some(true), + early_exit: Some(false), + corner_roundness: Some(12.0), + annotation_size_factor: Some(1.5), + default_hide_toolbars: Some(false), + no_window_decoration: Some(false), + stdin_data: None, + }; + assert!(req.validate().is_ok()); + + let bytes = req.to_bytes().unwrap(); + let parsed = DaemonRequest::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.filename, req.filename); + assert_eq!(parsed.output_filename, req.output_filename); + } + + #[test] + fn test_request_empty_filename() { + let req = DaemonRequest::new(""); + assert!(matches!( + req.validate(), + Err(ProtocolError::MissingField("filename")) + )); + } + + #[test] + fn test_request_stdin_without_data() { + let req = DaemonRequest::new("-"); + assert!(req.validate().is_err()); + } + + #[test] + fn test_request_stdin_with_data() { + use base64::Engine; + let mut req = DaemonRequest::new("-"); + req.stdin_data = Some(base64::engine::general_purpose::STANDARD.encode(b"fake image data")); + assert!(req.validate().is_ok()); + } + + #[test] + fn test_response_ok() { + let resp = DaemonResponse::ok(42); + assert_eq!(resp.status, ResponseStatus::Ok); + assert_eq!(resp.window_id, Some(42)); + + let bytes = resp.to_bytes().unwrap(); + let parsed = DaemonResponse::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.status, ResponseStatus::Ok); + assert_eq!(parsed.window_id, Some(42)); + } + + #[test] + fn test_response_error() { + let resp = DaemonResponse::error("File not found"); + assert_eq!(resp.status, ResponseStatus::Error); + assert_eq!(resp.message, Some("File not found".into())); + } + + #[test] + fn test_message_framing() { + let data = b"hello world"; + let mut buffer = Vec::new(); + write_message(&mut buffer, data).unwrap(); + + let mut reader = std::io::Cursor::new(buffer); + let read_data = read_message(&mut reader).unwrap(); + assert_eq!(read_data, data); + } + + #[test] + fn test_message_too_large() { + let data = vec![0u8; MAX_MESSAGE_SIZE + 1]; + let mut buffer = Vec::new(); + assert!(matches!( + write_message(&mut buffer, &data), + Err(ProtocolError::MessageTooLarge(_)) + )); + } + + #[test] + fn test_json_with_unknown_fields() { + // Unknown fields should be ignored (forward compatibility) + let json = r#"{"filename": "/tmp/test.png", "unknown_field": "value"}"#; + let req: DaemonRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.filename, "/tmp/test.png"); + } + + #[test] + fn test_unicode_paths() { + let req = DaemonRequest::new("/tmp/скриншот.png"); + assert!(req.validate().is_ok()); + + let bytes = req.to_bytes().unwrap(); + let parsed = DaemonRequest::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.filename, "/tmp/скриншот.png"); + } +} diff --git a/src/daemon/request_config.rs b/src/daemon/request_config.rs new file mode 100644 index 00000000..2f687ca3 --- /dev/null +++ b/src/daemon/request_config.rs @@ -0,0 +1,157 @@ +//! Per-request configuration for daemon mode +//! +//! When running in daemon mode, each window can have its own configuration +//! derived from the DaemonRequest. This replaces the global APP_CONFIG for +//! per-request overrides while still using defaults from the config file. + +use crate::configuration::{Action, APP_CONFIG}; +use crate::tools::Tools; + +use super::protocol::DaemonRequest; + +/// Configuration for a single daemon request/window +/// +/// This contains all the configurable options that can be overridden per-request. +/// Options are merged with the global configuration: request values take precedence. +#[derive(Debug, Clone)] +pub struct RequestConfig { + pub input_filename: String, + pub output_filename: Option, + pub copy_command: Option, + pub initial_tool: Tools, + pub fullscreen: bool, + pub early_exit: bool, + pub corner_roundness: f32, + pub annotation_size_factor: f32, + pub default_hide_toolbars: bool, + pub no_window_decoration: bool, + pub focus_toggles_toolbars: bool, + pub actions_on_enter: Vec, + pub actions_on_escape: Vec, + pub actions_on_right_click: Vec, +} + +impl RequestConfig { + /// Create a RequestConfig from a DaemonRequest, merging with global config + pub fn from_request(request: &DaemonRequest) -> Self { + let global = APP_CONFIG.read(); + + Self { + input_filename: request.filename.clone(), + output_filename: request + .output_filename + .clone() + .or_else(|| global.output_filename().cloned()), + copy_command: request + .copy_command + .clone() + .or_else(|| global.copy_command().cloned()), + initial_tool: request + .initial_tool + .as_ref() + .and_then(|s| parse_tool(s)) + .unwrap_or_else(|| global.initial_tool()), + fullscreen: request.fullscreen.unwrap_or_else(|| global.fullscreen()), + early_exit: request.early_exit.unwrap_or_else(|| global.early_exit()), + corner_roundness: request + .corner_roundness + .unwrap_or_else(|| global.corner_roundness()), + annotation_size_factor: request + .annotation_size_factor + .unwrap_or_else(|| global.annotation_size_factor()), + default_hide_toolbars: request + .default_hide_toolbars + .unwrap_or_else(|| global.default_hide_toolbars()), + no_window_decoration: request + .no_window_decoration + .unwrap_or_else(|| global.no_window_decoration()), + focus_toggles_toolbars: global.focus_toggles_toolbars(), + actions_on_enter: global.actions_on_enter(), + actions_on_escape: global.actions_on_escape(), + actions_on_right_click: global.actions_on_right_click(), + } + } + + /// Create a RequestConfig from the global configuration + /// Used for non-daemon mode or testing + pub fn from_global() -> Self { + let global = APP_CONFIG.read(); + + Self { + input_filename: global.input_filename().to_string(), + output_filename: global.output_filename().cloned(), + copy_command: global.copy_command().cloned(), + initial_tool: global.initial_tool(), + fullscreen: global.fullscreen(), + early_exit: global.early_exit(), + corner_roundness: global.corner_roundness(), + annotation_size_factor: global.annotation_size_factor(), + default_hide_toolbars: global.default_hide_toolbars(), + no_window_decoration: global.no_window_decoration(), + focus_toggles_toolbars: global.focus_toggles_toolbars(), + actions_on_enter: global.actions_on_enter(), + actions_on_escape: global.actions_on_escape(), + actions_on_right_click: global.actions_on_right_click(), + } + } +} + +/// Parse a tool name from a string +fn parse_tool(s: &str) -> Option { + match s.to_lowercase().as_str() { + "pointer" => Some(Tools::Pointer), + "crop" => Some(Tools::Crop), + "line" => Some(Tools::Line), + "arrow" => Some(Tools::Arrow), + "rectangle" => Some(Tools::Rectangle), + "ellipse" => Some(Tools::Ellipse), + "text" => Some(Tools::Text), + "marker" => Some(Tools::Marker), + "blur" => Some(Tools::Blur), + "highlight" => Some(Tools::Highlight), + "brush" => Some(Tools::Brush), + _ => None, + } +} + +impl Default for RequestConfig { + fn default() -> Self { + Self { + input_filename: String::new(), + output_filename: None, + copy_command: None, + initial_tool: Tools::Pointer, + fullscreen: false, + early_exit: false, + corner_roundness: 12.0, + annotation_size_factor: 1.0, + default_hide_toolbars: false, + no_window_decoration: false, + focus_toggles_toolbars: false, + actions_on_enter: vec![], + actions_on_escape: vec![Action::Exit], + actions_on_right_click: vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tool() { + assert_eq!(parse_tool("pointer"), Some(Tools::Pointer)); + assert_eq!(parse_tool("ARROW"), Some(Tools::Arrow)); + assert_eq!(parse_tool("Rectangle"), Some(Tools::Rectangle)); + assert_eq!(parse_tool("invalid"), None); + } + + #[test] + fn test_default_config() { + let config = RequestConfig::default(); + assert!(config.input_filename.is_empty()); + assert_eq!(config.initial_tool, Tools::Pointer); + assert!(!config.fullscreen); + } +} diff --git a/src/daemon/security.rs b/src/daemon/security.rs new file mode 100644 index 00000000..78118af6 --- /dev/null +++ b/src/daemon/security.rs @@ -0,0 +1,211 @@ +//! Security utilities for daemon mode +//! +//! Provides path validation and socket permission checking to prevent +//! common security issues like path traversal attacks. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum SecurityError { + #[error("File not found: {0}")] + FileNotFound(PathBuf), + + #[error("Path traversal detected: {0}")] + PathTraversal(String), + + #[error("Invalid path: {0}")] + InvalidPath(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Path is not a file: {0}")] + NotAFile(PathBuf), +} + +/// Maximum path length to prevent DoS attacks +const MAX_PATH_LENGTH: usize = 4096; + +/// Validate an image path for security +/// +/// Performs the following checks (BASIC level): +/// - Path is not empty +/// - Path length is reasonable +/// - File exists +/// - Path does not contain path traversal sequences after canonicalization +/// - Resolved path is a regular file (not a directory) +/// +/// Symlinks are allowed in BASIC mode. +pub fn validate_image_path(path: &str) -> Result { + // Check for empty path + if path.is_empty() { + return Err(SecurityError::InvalidPath("empty path".into())); + } + + // Check for stdin indicator (not a real path) + if path == "-" { + return Ok(PathBuf::from("-")); + } + + // Check path length + if path.len() > MAX_PATH_LENGTH { + return Err(SecurityError::InvalidPath(format!( + "path too long: {} bytes (max {})", + path.len(), + MAX_PATH_LENGTH + ))); + } + + let path = Path::new(path); + + // Check for obvious path traversal before canonicalization + let path_str = path.to_string_lossy(); + if path_str.contains("..") { + // We'll check again after canonicalization, but catch obvious cases early + // This helps with error messages + } + + // Canonicalize the path (resolves symlinks and ..) + let canonical = path.canonicalize().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + SecurityError::FileNotFound(path.to_path_buf()) + } else { + SecurityError::Io(e) + } + })?; + + // After canonicalization, path should be absolute and have no .. components + let canonical_str = canonical.to_string_lossy(); + if canonical_str.contains("..") { + return Err(SecurityError::PathTraversal(path_str.into_owned())); + } + + // Check that it's a file, not a directory + let metadata = fs::metadata(&canonical)?; + if !metadata.is_file() { + return Err(SecurityError::NotAFile(canonical)); + } + + Ok(canonical) +} + +/// Set secure permissions on a socket file (mode 0600) +pub fn set_socket_permissions(socket_path: &Path) -> Result<(), SecurityError> { + let permissions = fs::Permissions::from_mode(0o600); + fs::set_permissions(socket_path, permissions)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use tempfile::TempDir; + + #[test] + fn test_validate_existing_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.png"); + File::create(&file_path).unwrap(); + + let result = validate_image_path(file_path.to_str().unwrap()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), file_path.canonicalize().unwrap()); + } + + #[test] + fn test_validate_nonexistent_file() { + let result = validate_image_path("/nonexistent/path/to/file.png"); + assert!(matches!(result, Err(SecurityError::FileNotFound(_)))); + } + + #[test] + fn test_validate_empty_path() { + let result = validate_image_path(""); + assert!(matches!(result, Err(SecurityError::InvalidPath(_)))); + } + + #[test] + fn test_validate_stdin_marker() { + let result = validate_image_path("-"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("-")); + } + + #[test] + fn test_validate_path_traversal() { + // Create a temp file to make the path "valid" except for traversal + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.png"); + File::create(&file_path).unwrap(); + + // Try to access via path traversal + let traversal_path = format!( + "{}/../{}/test.png", + dir.path().to_str().unwrap(), + dir.path().file_name().unwrap().to_str().unwrap() + ); + + // This should still work because canonicalize resolves .. + // The point is we verify the final path, not block all .. usage + let result = validate_image_path(&traversal_path); + // After canonicalization, the path should be valid + assert!(result.is_ok()); + } + + #[test] + fn test_validate_directory() { + let dir = TempDir::new().unwrap(); + let result = validate_image_path(dir.path().to_str().unwrap()); + assert!(matches!(result, Err(SecurityError::NotAFile(_)))); + } + + #[test] + fn test_validate_symlink_to_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("real.png"); + File::create(&file_path).unwrap(); + + let link_path = dir.path().join("link.png"); + std::os::unix::fs::symlink(&file_path, &link_path).unwrap(); + + // Symlinks are allowed in BASIC mode + let result = validate_image_path(link_path.to_str().unwrap()); + assert!(result.is_ok()); + // Should resolve to the real file + assert_eq!(result.unwrap(), file_path.canonicalize().unwrap()); + } + + #[test] + fn test_validate_long_path() { + let long_path = "/".to_string() + &"a".repeat(MAX_PATH_LENGTH + 1); + let result = validate_image_path(&long_path); + assert!(matches!(result, Err(SecurityError::InvalidPath(_)))); + } + + #[test] + fn test_validate_unicode_path() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("скриншот.png"); + File::create(&file_path).unwrap(); + + let result = validate_image_path(file_path.to_str().unwrap()); + assert!(result.is_ok()); + } + + #[test] + fn test_set_socket_permissions() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + File::create(&socket_path).unwrap(); + + set_socket_permissions(&socket_path).unwrap(); + + let metadata = fs::metadata(&socket_path).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/src/daemon/socket.rs b/src/daemon/socket.rs new file mode 100644 index 00000000..5dccfe9b --- /dev/null +++ b/src/daemon/socket.rs @@ -0,0 +1,269 @@ +//! Unix socket server and client for daemon mode +//! +//! Uses tokio for async I/O with length-prefixed message framing. + +use std::os::unix::net::UnixStream as StdUnixStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; + +use super::protocol::{ + read_message, write_message, DaemonRequest, DaemonResponse, ProtocolError, LENGTH_PREFIX_SIZE, + MAX_MESSAGE_SIZE, +}; +use super::security::set_socket_permissions; + +/// Connection timeout for client +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); + +/// Read timeout for client waiting for response +const READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// Daemon server that listens for requests +pub struct DaemonServer { + listener: UnixListener, + socket_path: PathBuf, +} + +impl DaemonServer { + /// Create a new daemon server listening on the given path + pub async fn new(socket_path: &Path) -> Result { + // Remove stale socket if it exists + if socket_path.exists() { + std::fs::remove_file(socket_path)?; + } + + let listener = UnixListener::bind(socket_path)?; + + // Set secure permissions on the socket + set_socket_permissions(socket_path).map_err(|e| std::io::Error::other(e.to_string()))?; + + Ok(Self { + listener, + socket_path: socket_path.to_path_buf(), + }) + } + + /// Accept a new connection and read the request + pub async fn accept(&self) -> Result<(DaemonRequest, DaemonConnection), ProtocolError> { + let (stream, _addr) = self.listener.accept().await?; + let mut connection = DaemonConnection { stream }; + + let request = connection.read_request().await?; + Ok((request, connection)) + } + + /// Get the socket path + pub fn socket_path(&self) -> &Path { + &self.socket_path + } +} + +impl Drop for DaemonServer { + fn drop(&mut self) { + // Clean up socket file + let _ = std::fs::remove_file(&self.socket_path); + } +} + +/// A connection to a client +pub struct DaemonConnection { + stream: UnixStream, +} + +impl DaemonConnection { + /// Read a request from the client + pub async fn read_request(&mut self) -> Result { + // Read length prefix + let mut len_buf = [0u8; LENGTH_PREFIX_SIZE]; + self.stream.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf) as usize; + + if len > MAX_MESSAGE_SIZE { + return Err(ProtocolError::MessageTooLarge(len)); + } + + // Read message body + let mut data = vec![0u8; len]; + self.stream.read_exact(&mut data).await?; + + DaemonRequest::from_bytes(&data) + } + + /// Send a response to the client + pub async fn send_response(&mut self, response: &DaemonResponse) -> Result<(), ProtocolError> { + let data = response.to_bytes()?; + + // Write length prefix + let len = data.len() as u32; + self.stream.write_all(&len.to_le_bytes()).await?; + + // Write message body + self.stream.write_all(&data).await?; + self.stream.flush().await?; + + Ok(()) + } +} + +/// Client for connecting to the daemon +pub struct DaemonClient { + socket_path: PathBuf, +} + +impl DaemonClient { + /// Create a new client targeting the given socket path + pub fn new(socket_path: &Path) -> Self { + Self { + socket_path: socket_path.to_path_buf(), + } + } + + /// Check if the daemon is running (socket exists and accepts connections) + pub fn is_daemon_running(&self) -> bool { + if !self.socket_path.exists() { + return false; + } + + // Try to connect with a short timeout + StdUnixStream::connect(&self.socket_path).is_ok() + } + + /// Send a request to the daemon and wait for response + /// + /// Uses synchronous I/O because the client is typically a short-lived process. + pub fn send_request(&self, request: &DaemonRequest) -> Result { + use std::io::Write; + + let mut stream = StdUnixStream::connect(&self.socket_path)?; + + // Set timeouts + stream.set_read_timeout(Some(READ_TIMEOUT))?; + stream.set_write_timeout(Some(CONNECTION_TIMEOUT))?; + + // Send request + let data = request.to_bytes()?; + write_message(&mut stream, &data)?; + stream.flush()?; + + // Read response + let response_data = read_message(&mut stream)?; + DaemonResponse::from_bytes(&response_data) + } + + /// Send a request asynchronously (for use in async contexts) + #[allow(dead_code)] // Used in tests + pub async fn send_request_async( + &self, + request: &DaemonRequest, + ) -> Result { + // Connect + let mut stream = + tokio::time::timeout(CONNECTION_TIMEOUT, UnixStream::connect(&self.socket_path)) + .await + .map_err(|_| { + std::io::Error::new(std::io::ErrorKind::TimedOut, "connection timeout") + })??; + + // Send request + let data = request.to_bytes()?; + let len = data.len() as u32; + stream.write_all(&len.to_le_bytes()).await?; + stream.write_all(&data).await?; + stream.flush().await?; + + // Read response with timeout + let response_data = tokio::time::timeout(READ_TIMEOUT, async { + let mut len_buf = [0u8; LENGTH_PREFIX_SIZE]; + stream.read_exact(&mut len_buf).await?; + let len = u32::from_le_bytes(len_buf) as usize; + + if len > MAX_MESSAGE_SIZE { + return Err(ProtocolError::MessageTooLarge(len)); + } + + let mut data = vec![0u8; len]; + stream.read_exact(&mut data).await?; + Ok::<_, ProtocolError>(data) + }) + .await + .map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "read timeout"))??; + + DaemonResponse::from_bytes(&response_data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn test_server_client_communication() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + // Start server + let server = DaemonServer::new(&socket_path).await.unwrap(); + let server_path = server.socket_path().to_path_buf(); + + // Spawn server handler + tokio::spawn(async move { + let (request, mut conn) = server.accept().await.unwrap(); + assert_eq!(request.filename, "/tmp/test.png"); + conn.send_response(&DaemonResponse::ok(1)).await.unwrap(); + }); + + // Give server time to start + tokio::time::sleep(Duration::from_millis(50)).await; + + // Send request + let client = DaemonClient::new(&server_path); + let request = DaemonRequest::new("/tmp/test.png"); + let response = client.send_request_async(&request).await.unwrap(); + + assert_eq!(response.status, super::super::protocol::ResponseStatus::Ok); + assert_eq!(response.window_id, Some(1)); + } + + #[tokio::test] + async fn test_server_creates_socket() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let server = DaemonServer::new(&socket_path).await.unwrap(); + assert!(socket_path.exists()); + + // Check permissions + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&socket_path).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + drop(server); + // Socket should be cleaned up + assert!(!socket_path.exists()); + } + + #[test] + fn test_client_daemon_not_running() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("nonexistent.sock"); + + let client = DaemonClient::new(&socket_path); + assert!(!client.is_daemon_running()); + } + + #[tokio::test] + async fn test_client_daemon_running() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let _server = DaemonServer::new(&socket_path).await.unwrap(); + + let client = DaemonClient::new(&socket_path); + assert!(client.is_daemon_running()); + } +} diff --git a/src/daemon/tests/integration_tests.rs b/src/daemon/tests/integration_tests.rs new file mode 100644 index 00000000..175c6b25 --- /dev/null +++ b/src/daemon/tests/integration_tests.rs @@ -0,0 +1,272 @@ +//! Integration tests for daemon socket communication +//! +//! These tests verify end-to-end communication between client and server. + +use crate::daemon::protocol::{DaemonRequest, DaemonResponse, ResponseStatus}; +use crate::daemon::socket::{DaemonClient, DaemonServer}; +use std::time::Duration; +use tempfile::TempDir; + +#[tokio::test] +async fn test_client_server_valid_request() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + // Create a test image file path (we just test the protocol, not actual image loading) + let image_path = dir.path().join("test.png"); + // Create a dummy file for the test + std::fs::write(&image_path, [0u8; 100]).unwrap(); + + // Start server + let server = DaemonServer::new(&socket_path).await.unwrap(); + + // Spawn server handler + let server_path = server.socket_path().to_path_buf(); + tokio::spawn(async move { + let (request, mut conn) = server.accept().await.unwrap(); + // Verify request fields + assert!(!request.filename.is_empty()); + conn.send_response(&DaemonResponse::ok(1)).await.unwrap(); + }); + + // Give server time to start + tokio::time::sleep(Duration::from_millis(50)).await; + + // Send request + let client = DaemonClient::new(&server_path); + let request = DaemonRequest::new(image_path.to_str().unwrap()); + let response = client.send_request_async(&request).await.unwrap(); + + assert_eq!(response.status, ResponseStatus::Ok); + assert_eq!(response.window_id, Some(1)); +} + +#[tokio::test] +async fn test_client_server_error_response() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + // Start server + let server = DaemonServer::new(&socket_path).await.unwrap(); + let server_path = server.socket_path().to_path_buf(); + + // Spawn server handler that returns error + tokio::spawn(async move { + let (_request, mut conn) = server.accept().await.unwrap(); + conn.send_response(&DaemonResponse::error("Test error message")) + .await + .unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Send request + let client = DaemonClient::new(&server_path); + let request = DaemonRequest::new("/tmp/test.png"); + let response = client.send_request_async(&request).await.unwrap(); + + assert_eq!(response.status, ResponseStatus::Error); + assert_eq!(response.message, Some("Test error message".into())); + assert!(response.window_id.is_none()); +} + +#[tokio::test] +async fn test_multiple_sequential_requests() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let server = DaemonServer::new(&socket_path).await.unwrap(); + let server_path = server.socket_path().to_path_buf(); + + // Spawn server that handles multiple requests + tokio::spawn(async move { + for i in 1..=3 { + let (_request, mut conn) = server.accept().await.unwrap(); + conn.send_response(&DaemonResponse::ok(i)).await.unwrap(); + } + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let client = DaemonClient::new(&server_path); + + // Send 3 sequential requests + for i in 1..=3 { + let request = DaemonRequest::new(format!("/tmp/test_{}.png", i)); + let response = client.send_request_async(&request).await.unwrap(); + assert_eq!(response.status, ResponseStatus::Ok); + assert_eq!(response.window_id, Some(i)); + } +} + +#[tokio::test] +async fn test_request_with_all_options() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let server = DaemonServer::new(&socket_path).await.unwrap(); + let server_path = server.socket_path().to_path_buf(); + + tokio::spawn(async move { + let (request, mut conn) = server.accept().await.unwrap(); + + // Verify all options were received + assert_eq!(request.filename, "/tmp/input.png"); + assert_eq!(request.output_filename, Some("/tmp/output.png".into())); + assert_eq!(request.copy_command, Some("wl-copy".into())); + assert_eq!(request.fullscreen, Some(true)); + assert_eq!(request.early_exit, Some(false)); + + conn.send_response(&DaemonResponse::ok(42)).await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let client = DaemonClient::new(&server_path); + + let mut request = DaemonRequest::new("/tmp/input.png"); + request.output_filename = Some("/tmp/output.png".into()); + request.copy_command = Some("wl-copy".into()); + request.fullscreen = Some(true); + request.early_exit = Some(false); + + let response = client.send_request_async(&request).await.unwrap(); + assert_eq!(response.status, ResponseStatus::Ok); + assert_eq!(response.window_id, Some(42)); +} + +#[tokio::test] +async fn test_request_with_stdin_data() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let server = DaemonServer::new(&socket_path).await.unwrap(); + let server_path = server.socket_path().to_path_buf(); + + let test_data = vec![1u8, 2, 3, 4, 5]; + let encoded = base64::engine::general_purpose::STANDARD.encode(&test_data); + let encoded_clone = encoded.clone(); + + tokio::spawn(async move { + let (request, mut conn) = server.accept().await.unwrap(); + + assert_eq!(request.filename, "-"); + assert_eq!(request.stdin_data, Some(encoded_clone)); + + conn.send_response(&DaemonResponse::ok(1)).await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let client = DaemonClient::new(&server_path); + + let mut request = DaemonRequest::new("-"); + request.stdin_data = Some(encoded); + + let response = client.send_request_async(&request).await.unwrap(); + assert_eq!(response.status, ResponseStatus::Ok); +} + +#[test] +fn test_client_is_daemon_running_no_socket() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("nonexistent.sock"); + + let client = DaemonClient::new(&socket_path); + assert!(!client.is_daemon_running()); +} + +#[tokio::test] +async fn test_client_is_daemon_running_with_server() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let _server = DaemonServer::new(&socket_path).await.unwrap(); + + let client = DaemonClient::new(&socket_path); + assert!(client.is_daemon_running()); +} + +#[tokio::test] +async fn test_server_cleanup_on_drop() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + { + let _server = DaemonServer::new(&socket_path).await.unwrap(); + assert!(socket_path.exists()); + } + + // Socket should be cleaned up after server is dropped + assert!(!socket_path.exists()); +} + +#[tokio::test] +async fn test_stale_socket_replacement() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + // Create a stale socket file + std::fs::write(&socket_path, "stale").unwrap(); + assert!(socket_path.exists()); + + // Server should replace the stale socket + let server = DaemonServer::new(&socket_path).await.unwrap(); + assert!(socket_path.exists()); + + // Verify it's a real socket now + let client = DaemonClient::new(&socket_path); + assert!(client.is_daemon_running()); + + drop(server); +} + +#[tokio::test] +async fn test_request_validation() { + // Test validation of DaemonRequest + let valid_request = DaemonRequest::new("/tmp/test.png"); + assert!(valid_request.validate().is_ok()); + + // Request with empty filename should fail + let empty_request = DaemonRequest::new(""); + assert!(empty_request.validate().is_err()); +} + +#[tokio::test] +async fn test_concurrent_connections() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + + let server = DaemonServer::new(&socket_path).await.unwrap(); + let server_path = server.socket_path().to_path_buf(); + + // Spawn server handler for multiple connections + tokio::spawn(async move { + for i in 1..=5 { + let (_request, mut conn) = server.accept().await.unwrap(); + conn.send_response(&DaemonResponse::ok(i)).await.unwrap(); + } + }); + + tokio::time::sleep(Duration::from_millis(50)).await; + + // Send multiple concurrent requests + let mut handles = vec![]; + for i in 1..=5 { + let path = server_path.clone(); + handles.push(tokio::spawn(async move { + let client = DaemonClient::new(&path); + let request = DaemonRequest::new(format!("/tmp/test_{}.png", i)); + client.send_request_async(&request).await + })); + } + + // Verify all requests succeeded + for handle in handles { + let response = handle.await.unwrap().unwrap(); + assert_eq!(response.status, ResponseStatus::Ok); + assert!(response.window_id.is_some()); + } +} diff --git a/src/daemon/tests/mod.rs b/src/daemon/tests/mod.rs new file mode 100644 index 00000000..e10b45ae --- /dev/null +++ b/src/daemon/tests/mod.rs @@ -0,0 +1,8 @@ +//! Tests for daemon module +//! +//! Note: Many tests are embedded in their respective modules. +//! This file contains additional integration-style tests. + +mod integration_tests; +mod protocol_tests; +mod security_tests; diff --git a/src/daemon/tests/protocol_tests.rs b/src/daemon/tests/protocol_tests.rs new file mode 100644 index 00000000..a85a0ff9 --- /dev/null +++ b/src/daemon/tests/protocol_tests.rs @@ -0,0 +1,168 @@ +//! Extended tests for protocol module + +use crate::daemon::protocol::*; + +#[test] +fn test_request_serialization_roundtrip() { + let request = DaemonRequest { + filename: "/tmp/image.png".into(), + output_filename: Some("/tmp/output.png".into()), + copy_command: Some("wl-copy".into()), + initial_tool: Some("arrow".into()), + fullscreen: Some(true), + early_exit: Some(false), + corner_roundness: Some(15.0), + annotation_size_factor: Some(2.0), + default_hide_toolbars: Some(true), + no_window_decoration: Some(false), + stdin_data: None, + }; + + let bytes = request.to_bytes().unwrap(); + let parsed = DaemonRequest::from_bytes(&bytes).unwrap(); + + assert_eq!(parsed.filename, request.filename); + assert_eq!(parsed.output_filename, request.output_filename); + assert_eq!(parsed.copy_command, request.copy_command); + assert_eq!(parsed.initial_tool, request.initial_tool); + assert_eq!(parsed.fullscreen, request.fullscreen); + assert_eq!(parsed.early_exit, request.early_exit); + assert_eq!(parsed.corner_roundness, request.corner_roundness); + assert_eq!( + parsed.annotation_size_factor, + request.annotation_size_factor + ); + assert_eq!(parsed.default_hide_toolbars, request.default_hide_toolbars); + assert_eq!(parsed.no_window_decoration, request.no_window_decoration); +} + +#[test] +fn test_response_serialization_roundtrip() { + // Test Ok response + let ok_response = DaemonResponse::ok(42); + let bytes = ok_response.to_bytes().unwrap(); + let parsed = DaemonResponse::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.status, ResponseStatus::Ok); + assert_eq!(parsed.window_id, Some(42)); + assert!(parsed.message.is_none()); + + // Test Error response + let err_response = DaemonResponse::error("Something went wrong"); + let bytes = err_response.to_bytes().unwrap(); + let parsed = DaemonResponse::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.status, ResponseStatus::Error); + assert!(parsed.window_id.is_none()); + assert_eq!(parsed.message, Some("Something went wrong".into())); +} + +#[test] +fn test_invalid_json() { + let invalid_json = b"not valid json at all"; + let result = DaemonRequest::from_bytes(invalid_json); + assert!(matches!(result, Err(ProtocolError::InvalidJson(_)))); +} + +#[test] +fn test_incomplete_json() { + let incomplete_json = b"{\"filename\": \"/tmp/test.png\""; + let result = DaemonRequest::from_bytes(incomplete_json); + assert!(matches!(result, Err(ProtocolError::InvalidJson(_)))); +} + +#[test] +fn test_json_missing_required_field() { + let json = b"{}"; + let result = DaemonRequest::from_bytes(json); + // serde will fail to deserialize without filename + assert!(result.is_err()); +} + +#[test] +fn test_json_with_null_optional_fields() { + let json = r#"{ + "filename": "/tmp/test.png", + "output_filename": null, + "copy_command": null + }"#; + let result = DaemonRequest::from_bytes(json.as_bytes()); + assert!(result.is_ok()); + let req = result.unwrap(); + assert_eq!(req.filename, "/tmp/test.png"); + assert!(req.output_filename.is_none()); +} + +#[test] +fn test_message_framing_multiple() { + // Test multiple messages in sequence + let mut buffer = Vec::new(); + + let data1 = b"first message"; + let data2 = b"second message with more data"; + let data3 = b"third"; + + write_message(&mut buffer, data1).unwrap(); + write_message(&mut buffer, data2).unwrap(); + write_message(&mut buffer, data3).unwrap(); + + let mut reader = std::io::Cursor::new(buffer); + + assert_eq!(read_message(&mut reader).unwrap(), data1); + assert_eq!(read_message(&mut reader).unwrap(), data2); + assert_eq!(read_message(&mut reader).unwrap(), data3); +} + +#[test] +fn test_message_framing_empty() { + let mut buffer = Vec::new(); + write_message(&mut buffer, b"").unwrap(); + + let mut reader = std::io::Cursor::new(buffer); + let result = read_message(&mut reader).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn test_special_characters_in_paths() { + let special_paths = [ + "/tmp/file with spaces.png", + "/tmp/file\twith\ttabs.png", + "/tmp/file'with'quotes.png", + "/tmp/file\"with\"doublequotes.png", + "/tmp/файл.png", // Russian + "/tmp/文件.png", // Chinese + "/tmp/ファイル.png", // Japanese + ]; + + for path in special_paths { + let req = DaemonRequest::new(path); + let bytes = req.to_bytes().unwrap(); + let parsed = DaemonRequest::from_bytes(&bytes).unwrap(); + assert_eq!(parsed.filename, path, "Failed for path: {}", path); + } +} + +#[test] +fn test_large_stdin_data() { + use base64::Engine; + + // Test with moderately large base64 data (1MB) + let image_data = vec![0u8; 1024 * 1024]; + let encoded = base64::engine::general_purpose::STANDARD.encode(&image_data); + + let mut req = DaemonRequest::new("-"); + req.stdin_data = Some(encoded); + + let bytes = req.to_bytes().unwrap(); + assert!(bytes.len() < MAX_MESSAGE_SIZE); + + let parsed = DaemonRequest::from_bytes(&bytes).unwrap(); + assert!(parsed.stdin_data.is_some()); +} + +#[test] +fn test_connection_closed_on_empty_read() { + let empty: &[u8] = &[]; + let mut reader = std::io::Cursor::new(empty); + let result = read_message(&mut reader); + assert!(matches!(result, Err(ProtocolError::ConnectionClosed))); +} diff --git a/src/daemon/tests/security_tests.rs b/src/daemon/tests/security_tests.rs new file mode 100644 index 00000000..83654b20 --- /dev/null +++ b/src/daemon/tests/security_tests.rs @@ -0,0 +1,147 @@ +//! Extended tests for security module + +use crate::daemon::security::*; +use std::fs::{self, File}; +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +#[test] +fn test_validate_regular_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.png"); + File::create(&file_path).unwrap(); + + let result = validate_image_path(file_path.to_str().unwrap()); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_file_in_subdirectory() { + let dir = TempDir::new().unwrap(); + let subdir = dir.path().join("subdir"); + fs::create_dir(&subdir).unwrap(); + let file_path = subdir.join("test.png"); + File::create(&file_path).unwrap(); + + let result = validate_image_path(file_path.to_str().unwrap()); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_symlink_chain() { + let dir = TempDir::new().unwrap(); + + // Create: real.png -> link1.png -> link2.png + let real_path = dir.path().join("real.png"); + File::create(&real_path).unwrap(); + + let link1_path = dir.path().join("link1.png"); + std::os::unix::fs::symlink(&real_path, &link1_path).unwrap(); + + let link2_path = dir.path().join("link2.png"); + std::os::unix::fs::symlink(&link1_path, &link2_path).unwrap(); + + // All should resolve to the real file + let result = validate_image_path(link2_path.to_str().unwrap()); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), real_path.canonicalize().unwrap()); +} + +#[test] +fn test_validate_broken_symlink() { + let dir = TempDir::new().unwrap(); + let link_path = dir.path().join("broken.png"); + std::os::unix::fs::symlink("/nonexistent/file.png", &link_path).unwrap(); + + let result = validate_image_path(link_path.to_str().unwrap()); + assert!(matches!(result, Err(SecurityError::FileNotFound(_)))); +} + +#[test] +fn test_validate_relative_path_with_dots() { + let dir = TempDir::new().unwrap(); + let subdir = dir.path().join("subdir"); + fs::create_dir(&subdir).unwrap(); + let file_path = subdir.join("test.png"); + File::create(&file_path).unwrap(); + + // Use a path with .. that still resolves to a valid file + let relative_path = format!("{}/subdir/../subdir/test.png", dir.path().display()); + let result = validate_image_path(&relative_path); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_hidden_file() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join(".hidden.png"); + File::create(&file_path).unwrap(); + + let result = validate_image_path(file_path.to_str().unwrap()); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_file_with_special_permissions() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.png"); + File::create(&file_path).unwrap(); + + // Make file read-only + fs::set_permissions(&file_path, fs::Permissions::from_mode(0o444)).unwrap(); + + // Should still be valid (we only need to read) + let result = validate_image_path(file_path.to_str().unwrap()); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_fifo() { + let dir = TempDir::new().unwrap(); + let fifo_path = dir.path().join("test.fifo"); + + // Create a FIFO + nix::unistd::mkfifo(&fifo_path, nix::sys::stat::Mode::S_IRWXU).unwrap(); + + // FIFO is not a regular file + let result = validate_image_path(fifo_path.to_str().unwrap()); + assert!(matches!(result, Err(SecurityError::NotAFile(_)))); +} + +#[test] +fn test_set_socket_permissions() { + let dir = TempDir::new().unwrap(); + let socket_path = dir.path().join("test.sock"); + File::create(&socket_path).unwrap(); + + // Start with insecure permissions + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o777)).unwrap(); + + // Set secure permissions + set_socket_permissions(&socket_path).unwrap(); + + // Verify actual mode + let metadata = fs::metadata(&socket_path).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); +} + +#[test] +fn test_validate_path_at_root() { + // This test checks /tmp which should always exist + let result = validate_image_path("/tmp"); + // /tmp is a directory, not a file + assert!(matches!(result, Err(SecurityError::NotAFile(_)))); +} + +#[test] +fn test_validate_path_max_length() { + // Create a path that's exactly at the limit + let long_name = "a".repeat(255); // Max filename length on most filesystems + let long_path = format!("/tmp/{}", long_name); + + // This should be under our MAX_PATH_LENGTH + let result = validate_image_path(&long_path); + // Will fail because file doesn't exist, but not because path is too long + assert!(matches!(result, Err(SecurityError::FileNotFound(_)))); +} diff --git a/src/main.rs b/src/main.rs index d18b520e..3a8f8ade 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ use std::io::Read; +use std::rc::Rc; use std::sync::LazyLock; use std::{fs, ptr}; use std::{io, time::Duration}; use configuration::{Configuration, APP_CONFIG}; +use daemon::RequestConfig; use gdk_pixbuf::gio::ApplicationFlags; use gdk_pixbuf::{Pixbuf, PixbufLoader}; use gtk::prelude::*; @@ -22,6 +24,7 @@ use ui::toolbars::{StyleToolbar, StyleToolbarInput, ToolsToolbar, ToolsToolbarIn use xdg::BaseDirectories; mod configuration; +mod daemon; mod femtovg_area; mod icons; mod ime; @@ -50,7 +53,14 @@ macro_rules! generate_profile_output { }; } +/// Initialization data for the App component +pub struct AppInit { + pub image: Pixbuf, + pub config: Rc, +} + struct App { + config: Rc, image_dimensions: (i32, i32), sketch_board: Controller, tools_toolbar: Controller, @@ -82,45 +92,42 @@ impl App { } fn resize_window_initial(&self, root: &Window, sender: ComponentSender) { - let monitor_size = match Self::get_monitor_size(root) { - Some(s) => s, - None => { + // Handle window sizing based on monitor size + if let Some(monitor_size) = Self::get_monitor_size(root) { + let reduced_monitor_width = monitor_size.width() as f64 * 0.8; + let reduced_monitor_height = monitor_size.height() as f64 * 0.8; + + let image_width = self.image_dimensions.0 as f64; + let image_height = self.image_dimensions.1 as f64; + + // create a window that uses 80% of the available space max + // if necessary, scale down image + if reduced_monitor_width > image_width && reduced_monitor_height > image_height { + // set window to exact size root.set_default_size(self.image_dimensions.0, self.image_dimensions.1); - return; - } - }; - - let reduced_monitor_width = monitor_size.width() as f64 * 0.8; - let reduced_monitor_height = monitor_size.height() as f64 * 0.8; - - let image_width = self.image_dimensions.0 as f64; - let image_height = self.image_dimensions.1 as f64; + } else { + // scale down and use windowed mode + let aspect_ratio = image_width / image_height; - // create a window that uses 80% of the available space max - // if necessary, scale down image - if reduced_monitor_width > image_width && reduced_monitor_height > image_height { - // set window to exact size - root.set_default_size(self.image_dimensions.0, self.image_dimensions.1); - } else { - // scale down and use windowed mode - let aspect_ratio = image_width / image_height; + // resize + let mut new_width = reduced_monitor_width; + let mut new_height = new_width / aspect_ratio; - // resize - let mut new_width = reduced_monitor_width; - let mut new_height = new_width / aspect_ratio; + // if new_height is still bigger than monitor height, then scale on monitor height + if new_height > reduced_monitor_height { + new_height = reduced_monitor_height; + new_width = new_height * aspect_ratio; + } - // if new_height is still bigger than monitor height, then scale on monitor height - if new_height > reduced_monitor_height { - new_height = reduced_monitor_height; - new_width = new_height * aspect_ratio; + root.set_default_size(new_width as i32, new_height as i32); } - - root.set_default_size(new_width as i32, new_height as i32); + } else { + root.set_default_size(self.image_dimensions.0, self.image_dimensions.1); } root.set_resizable(false); - if APP_CONFIG.read().fullscreen() { + if self.config.fullscreen { root.fullscreen(); } @@ -169,14 +176,14 @@ impl App { #[relm4::component] impl Component for App { - type Init = Pixbuf; + type Init = AppInit; type Input = AppInput; type Output = (); type CommandOutput = AppCommandOutput; view! { main_window = gtk::Window { - set_decorated: !APP_CONFIG.read().no_window_decoration(), + set_decorated: !model.config.no_window_decoration, set_default_size: (500, 500), add_css_class: "root", @@ -241,39 +248,38 @@ impl Component for App { } fn init( - image: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { Self::apply_style(); + let AppInit { image, config } = init; let image_dimensions = (image.width(), image.height()); - // SketchBoard - let sketch_board = - SketchBoard::builder() - .launch(image) - .forward(sender.input_sender(), |t| match t { - SketchBoardOutput::ToggleToolbarsDisplay => AppInput::ToggleToolbarsDisplay, - SketchBoardOutput::ToolSwitchShortcut(tool) => { - AppInput::ToolSwitchShortcut(tool) - } - SketchBoardOutput::ColorSwitchShortcut(index) => { - AppInput::ColorSwitchShortcut(index) - } - }); + // SketchBoard - pass config for per-window settings + let sketch_board = SketchBoard::builder() + .launch((image, config.clone())) + .forward(sender.input_sender(), |t| match t { + SketchBoardOutput::ToggleToolbarsDisplay => AppInput::ToggleToolbarsDisplay, + SketchBoardOutput::ToolSwitchShortcut(tool) => AppInput::ToolSwitchShortcut(tool), + SketchBoardOutput::ColorSwitchShortcut(index) => { + AppInput::ColorSwitchShortcut(index) + } + }); - // Toolbars + // Toolbars - pass config for per-window settings let tools_toolbar = ToolsToolbar::builder() - .launch(()) + .launch(config.clone()) .forward(sketch_board.sender(), SketchBoardInput::ToolbarEvent); let style_toolbar = StyleToolbar::builder() - .launch(()) + .launch(config.clone()) .forward(sketch_board.sender(), SketchBoardInput::ToolbarEvent); // Model let model = App { + config: config.clone(), sketch_board, tools_toolbar, style_toolbar, @@ -282,7 +288,7 @@ impl Component for App { let widgets = view_output!(); - if APP_CONFIG.read().focus_toggles_toolbars() { + if config.focus_toggles_toolbars { let motion_controller = gtk::EventControllerMotion::builder().build(); let sender_clone = sender.clone(); @@ -356,11 +362,11 @@ fn run_satty() -> Result<()> { generate_profile_output!("loaded gl"); // load app config - let config = APP_CONFIG.read(); + let global_config = APP_CONFIG.read(); generate_profile_output!("loading image"); // load input image - let image = if config.input_filename() == "-" { + let image = if global_config.input_filename() == "-" { let mut buf = Vec::::new(); io::stdin().lock().read_to_end(&mut buf)?; let pb_loader = PixbufLoader::new(); @@ -370,8 +376,12 @@ fn run_satty() -> Result<()> { .pixbuf() .ok_or(anyhow!("Conversion to Pixbuf failed"))? } else { - Pixbuf::from_file(config.input_filename()).context("couldn't load image")? + Pixbuf::from_file(global_config.input_filename()).context("couldn't load image")? }; + drop(global_config); // Release lock before creating RequestConfig + + // Create per-window configuration from global config + let config = Rc::new(RequestConfig::from_global()); generate_profile_output!("image loaded, starting gui"); // start GUI @@ -379,16 +389,321 @@ fn run_satty() -> Result<()> { app.set_application_id(Some("com.gabm.satty")); // set flag to allow to run multiple instances app.set_flags(ApplicationFlags::NON_UNIQUE); - // create relm app and run + // create relm app and run (with empty args to avoid GTK parsing our flags) let app = RelmApp::from_app(app).with_args(vec![]); relm4_icons::initialize_icons( icons::icon_names::GRESOURCE_BYTES, icons::icon_names::RESOURCE_PREFIX, ); - app.run::(image); + app.run::(AppInit { image, config }); + Ok(()) +} + +/// Run in client mode: send request to daemon, fallback to normal if daemon not running +fn run_client() -> Result<()> { + use base64::Engine; + use daemon::{get_socket_path, DaemonClient, DaemonRequest, ResponseStatus}; + + let socket_path = get_socket_path(); + let client = DaemonClient::new(&socket_path); + + // Check if daemon is running + if !client.is_daemon_running() { + eprintln!("Daemon not running, falling back to normal startup"); + return run_satty(); + } + + let config = APP_CONFIG.read(); + + // Build request from current configuration + let mut request = DaemonRequest::new(config.input_filename()); + request.output_filename = config.output_filename().cloned(); + request.copy_command = config.copy_command().cloned(); + request.fullscreen = Some(config.fullscreen()); + request.early_exit = Some(config.early_exit()); + request.corner_roundness = Some(config.corner_roundness()); + request.annotation_size_factor = Some(config.annotation_size_factor()); + request.default_hide_toolbars = Some(config.default_hide_toolbars()); + request.no_window_decoration = Some(config.no_window_decoration()); + + // Handle stdin mode: read and base64 encode + if config.input_filename() == "-" { + let mut buf = Vec::new(); + io::stdin().lock().read_to_end(&mut buf)?; + request.stdin_data = Some(base64::engine::general_purpose::STANDARD.encode(&buf)); + } + + // Send request to daemon + match client.send_request(&request) { + Ok(response) => match response.status { + ResponseStatus::Ok => { + if let Some(window_id) = response.window_id { + generate_profile_output!(format!("window {} opened via daemon", window_id)); + } + Ok(()) + } + ResponseStatus::Error => { + let msg = response.message.unwrap_or_else(|| "Unknown error".into()); + eprintln!("Daemon error: {}", msg); + Err(anyhow!("Daemon error: {}", msg)) + } + }, + Err(e) => { + eprintln!("Failed to communicate with daemon: {}", e); + eprintln!("Falling back to normal startup"); + run_satty() + } + } +} + +/// Run in daemon mode: initialize GTK, listen for requests, create windows on demand +fn run_daemon() -> Result<()> { + use daemon::{get_socket_path, is_daemon_running, remove_stale_socket, DaemonServer}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + // Check if daemon is already running + if is_daemon_running() { + eprintln!("Daemon is already running"); + std::process::exit(1); + } + + // Remove stale socket if any + remove_stale_socket()?; + + // Load OpenGL + load_gl()?; + generate_profile_output!("daemon: loaded gl"); + + // Initialize icons (before any GTK windows) + relm4_icons::initialize_icons( + icons::icon_names::GRESOURCE_BYTES, + icons::icon_names::RESOURCE_PREFIX, + ); + + // Initialize GTK application + let app = gtk::Application::new(Some("com.gabm.satty.daemon"), ApplicationFlags::NON_UNIQUE); + + // Channel for passing requests from socket thread to main thread + let (tx, rx) = std::sync::mpsc::channel::<( + daemon::DaemonRequest, + std::sync::mpsc::Sender, + )>(); + let rx = Arc::new(std::sync::Mutex::new(rx)); + + // Window counter + let window_counter = Arc::new(AtomicU64::new(0)); + + // On activate, set up the socket listener and request handler + let rx_clone = rx.clone(); + let window_counter_clone = window_counter.clone(); + app.connect_activate(move |app| { + // Hold the application so it doesn't quit when no windows are open + let guard = app.hold(); + // Store the guard - we need to keep it alive + // Use a static or leak it since we want the daemon to run forever + std::mem::forget(guard); + + // Pre-warm GTK by creating, briefly presenting, and closing a hidden window + // This initializes internal GTK structures that would otherwise slow down the first real window + let dummy_image = Pixbuf::new(gdk_pixbuf::Colorspace::Rgb, false, 8, 1, 1) + .expect("Failed to create prewarm image"); + let dummy_config = Rc::new(RequestConfig::default()); + let mut prewarm_app = App::builder().launch(AppInit { + image: dummy_image, + config: dummy_config, + }); + let prewarm_window = prewarm_app.widget(); + prewarm_window.set_application(Some(app)); + // Hide the window initially, present briefly to trigger GTK init, then close + prewarm_window.set_visible(false); + prewarm_window.present(); + // Process a few GTK events to complete initialization + while gtk::glib::MainContext::default().iteration(false) {} + prewarm_window.close(); + prewarm_app.detach_runtime(); + + eprintln!("Daemon activated, setting up request handler..."); + + // Start socket server in separate thread + let socket_path = get_socket_path(); + let tx = tx.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(async move { + let server = match DaemonServer::new(&socket_path).await { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to create daemon server: {}", e); + return; + } + }; + eprintln!("Daemon listening on {:?}", server.socket_path()); + + loop { + match server.accept().await { + Ok((request, mut connection)) => { + // Create sync channel for response + let (resp_tx, resp_rx) = std::sync::mpsc::channel(); + + if tx.send((request, resp_tx)).is_err() { + eprintln!("Main thread exited, stopping socket server"); + break; // Main thread exited + } + + // Wait for response and send back to client + tokio::spawn(async move { + if let Ok(response) = resp_rx.recv() { + let _ = connection.send_response(&response).await; + } + }); + } + Err(e) => { + // Ignore "early eof" errors from connection checks + let err_str = e.to_string(); + if !err_str.contains("early eof") { + eprintln!("Error accepting connection: {}", e); + } + } + } + } + }); + }); + + // Poll for incoming requests using GLib timeout (more reliable than idle for long-running) + let rx = rx_clone.clone(); + let window_counter = window_counter_clone.clone(); + let app_weak = app.downgrade(); + + glib::timeout_add_local(std::time::Duration::from_millis(10), move || { + // Check if app still exists + let Some(app) = app_weak.upgrade() else { + return glib::ControlFlow::Break; + }; + + // Try to receive a request (non-blocking) + let maybe_request = { + let rx = rx.lock().unwrap(); + rx.try_recv().ok() + }; + + if let Some((request, response_tx)) = maybe_request { + // Validate request + if let Err(e) = request.validate() { + eprintln!("Request validation failed: {}", e); + let _ = response_tx.send(daemon::DaemonResponse::error(e.to_string())); + return glib::ControlFlow::Continue; + } + + // Load image + let image = match load_image_from_request(&request) { + Ok(img) => img, + Err(e) => { + eprintln!("Failed to load image: {}", e); + let _ = response_tx.send(daemon::DaemonResponse::error(e.to_string())); + return glib::ControlFlow::Continue; + } + }; + + // Create per-window configuration from request + // Each window gets its own config, eliminating race conditions + let config = Rc::new(RequestConfig::from_request(&request)); + + // Create window + let window_id = window_counter.fetch_add(1, Ordering::SeqCst) + 1; + + // Send response BEFORE window.present() so client can exit faster + let _ = response_tx.send(daemon::DaemonResponse::ok(window_id)); + + // Create a new window with the App component + spawn_annotation_window(&app, image, config); + } + + glib::ControlFlow::Continue + }); + }); + + // Connect shutdown handler + app.connect_shutdown(|_| { + eprintln!("Daemon shutting down, cleaning up socket..."); + let _ = daemon::remove_stale_socket(); + eprintln!("Socket cleaned up"); + }); + + // Set up signal handling for graceful shutdown + let app_for_signal = app.clone(); + glib::spawn_future_local(async move { + use tokio::signal::unix::{signal, SignalKind}; + + let mut sigterm = + signal(SignalKind::terminate()).expect("Failed to create SIGTERM handler"); + let mut sigint = signal(SignalKind::interrupt()).expect("Failed to create SIGINT handler"); + + tokio::select! { + _ = sigterm.recv() => { + eprintln!("Received SIGTERM, initiating graceful shutdown..."); + } + _ = sigint.recv() => { + eprintln!("Received SIGINT, initiating graceful shutdown..."); + } + } + + app_for_signal.quit(); + }); + + generate_profile_output!("daemon: starting GTK main loop"); + + // Run the GTK application (this blocks until quit) + // Pass empty args to avoid GTK parsing our arguments + app.run_with_args::<&str>(&[]); + Ok(()) } +/// Load image from a daemon request +fn load_image_from_request(request: &daemon::DaemonRequest) -> Result { + use base64::Engine; + + if request.filename == "-" { + // Load from base64 stdin data + let data = request + .stdin_data + .as_ref() + .ok_or_else(|| anyhow!("No stdin data provided"))?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(data) + .context("Failed to decode base64 image data")?; + + let pb_loader = PixbufLoader::new(); + pb_loader.write(&decoded)?; + pb_loader.close()?; + pb_loader + .pixbuf() + .ok_or_else(|| anyhow!("Conversion to Pixbuf failed")) + } else { + // Validate and load from file + let validated_path = daemon::validate_image_path(&request.filename) + .map_err(|e| anyhow!("Invalid image path: {}", e))?; + + Pixbuf::from_file(&validated_path).context("Couldn't load image") + } +} + +/// Spawn a new annotation window with the given image and per-window configuration +fn spawn_annotation_window(gtk_app: >k::Application, image: Pixbuf, config: Rc) { + // Launch the App component with per-window configuration + let init = AppInit { image, config }; + let mut app_component = App::builder().launch(init); + + // Get the window widget and associate it with our GTK Application + let window = app_component.widget(); + window.set_application(Some(gtk_app)); + window.present(); + + // Detach the controller so it doesn't get dropped and close the window + app_component.detach_runtime(); +} + fn main() -> Result<()> { let _ = *START_TIME; // populate the APP_CONFIG from commandline and @@ -402,8 +717,21 @@ fn main() -> Result<()> { } generate_profile_output!("configuration loaded"); - // run the application - match run_satty() { + let config = APP_CONFIG.read(); + + // Dispatch based on mode + let result = if config.daemon_mode() { + drop(config); // Release the lock before running + run_daemon() + } else if config.show_mode() { + drop(config); + run_client() + } else { + drop(config); + run_satty() + }; + + match result { Err(e) => { eprintln!("Error: {e}"); Err(e) diff --git a/src/sketch_board.rs b/src/sketch_board.rs index e7d539ff..6f07fb80 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -18,6 +18,7 @@ use relm4::gtk::gdk::{DisplayManager, Key, ModifierType, Texture}; use relm4::{gtk, Component, ComponentParts, ComponentSender, RelmWidgetExt}; use crate::configuration::{Action, APP_CONFIG}; +use crate::daemon::RequestConfig; use crate::femtovg_area::FemtoVGArea; use crate::ime::pango_adapter::spans_from_pango_attrs; use crate::math::Vec2D; @@ -232,6 +233,7 @@ impl InputEvent { } pub struct SketchBoard { + config: Rc, renderer: FemtoVGArea, active_tool: Rc>, tools: ToolsManager, @@ -314,7 +316,7 @@ impl SketchBoard { _ => (), } - if APP_CONFIG.read().early_exit() || action == Action::Exit { + if self.config.early_exit || action == Action::Exit { self.handle_exit(); return; } @@ -322,11 +324,22 @@ impl SketchBoard { } fn handle_exit(&self) { + // daemon_mode is a process-level flag, read from global config + if APP_CONFIG.read().daemon_mode() { + // In daemon mode, just close this window, don't quit the entire app + if let Some(root) = self.renderer.root() { + if let Some(window) = root.downcast_ref::() { + window.close(); + return; + } + } + } + // Fallback or normal mode: quit the application relm4::main_application().quit(); } fn handle_save(&self, image: &Pixbuf) { - let mut output_filename = match APP_CONFIG.read().output_filename() { + let mut output_filename = match self.config.output_filename.as_ref() { None => { println!("No Output filename specified!"); return; @@ -491,7 +504,7 @@ impl SketchBoard { fn handle_copy_clipboard(&self, image: &Pixbuf) { let texture = Texture::for_pixbuf(image); - let result = if let Some(command) = APP_CONFIG.read().copy_command() { + let result = if let Some(ref command) = self.config.copy_command { self.save_to_external_process(&texture, command) } else { self.save_to_clipboard(&texture) @@ -739,7 +752,7 @@ impl Component for SketchBoard { type CommandOutput = (); type Input = SketchBoardInput; type Output = SketchBoardOutput; - type Init = Pixbuf; + type Init = (Pixbuf, Rc); view! { gtk::Box { @@ -950,9 +963,9 @@ impl Component for SketchBoard { // Relying on ToolUpdateResult::Unmodified is probably not a good idea, but it's the only way at the moment. See discussion in #144 if let ToolUpdateResult::Unmodified = active_tool_result { let actions = if ke.key == Key::Escape { - APP_CONFIG.read().actions_on_escape() + self.config.actions_on_escape.clone() } else { - APP_CONFIG.read().actions_on_enter() + self.config.actions_on_enter.clone() }; self.renderer.request_render(&actions); }; @@ -1012,18 +1025,19 @@ impl Component for SketchBoard { } fn init( - image: Self::Init, + init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { - let config = APP_CONFIG.read(); + let (image, config) = init; let tools = ToolsManager::new(); let im_context = gtk::IMMulticontext::new(); let mut model = Self { + config: config.clone(), renderer: FemtoVGArea::default(), - active_tool: tools.get(&config.initial_tool()), + active_tool: tools.get(&config.initial_tool), style: Style::default(), tools, im_context, diff --git a/src/ui/toolbars.rs b/src/ui/toolbars.rs index a6bb6504..647384e9 100644 --- a/src/ui/toolbars.rs +++ b/src/ui/toolbars.rs @@ -1,7 +1,8 @@ -use std::{borrow::Cow, collections::HashMap}; +use std::{borrow::Cow, collections::HashMap, rc::Rc}; use crate::{ configuration::APP_CONFIG, + daemon::RequestConfig, style::{Color, Size}, tools::Tools, }; @@ -20,6 +21,7 @@ use relm4::{ pub struct ToolsToolbar { visible: bool, + output_filename_provided: bool, active_button: Option, tool_buttons: HashMap, tool_action: SimpleAction, @@ -99,7 +101,7 @@ fn create_icon(color: Color) -> gtk::Image { #[relm4::component(pub)] impl SimpleComponent for ToolsToolbar { - type Init = (); + type Init = Rc; type Input = ToolsToolbarInput; type Output = ToolbarEvent; @@ -273,7 +275,7 @@ impl SimpleComponent for ToolsToolbar { set_tooltip: "Save (Ctrl+S)", connect_clicked[sender] => move |_| {sender.output_sender().emit(ToolbarEvent::SaveFile);}, - set_visible: APP_CONFIG.read().output_filename().is_some() + set_visible: model.output_filename_provided }, gtk::Button { set_focusable: false, @@ -304,13 +306,13 @@ impl SimpleComponent for ToolsToolbar { } fn init( - _: Self::Init, + config: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { let sender_tmp: ComponentSender = sender.clone(); let tool_action: RelmAction = RelmAction::new_stateful_with_target_value( - &APP_CONFIG.read().initial_tool(), + &config.initial_tool, move |_, state, value| { *state = value; // notify parent of change @@ -321,7 +323,8 @@ impl SimpleComponent for ToolsToolbar { ); let mut model = ToolsToolbar { - visible: !APP_CONFIG.read().default_hide_toolbars(), + visible: !config.default_hide_toolbars, + output_filename_provided: config.output_filename.is_some(), active_button: None, tool_buttons: HashMap::new(), tool_action: tool_action.clone().into(), @@ -342,9 +345,9 @@ impl SimpleComponent for ToolsToolbar { (Tools::Highlight, widgets.highlight_button.clone()), ]); - // reverse shortcuts mapping - let config = APP_CONFIG.read(); - let tool_to_key_map: HashMap<&Tools, &char> = config + // reverse shortcuts mapping (keybinds are global, not per-window) + let global_config = APP_CONFIG.read(); + let tool_to_key_map: HashMap<&Tools, &char> = global_config .keybinds() .shortcuts() .iter() @@ -367,8 +370,7 @@ impl SimpleComponent for ToolsToolbar { } // Set initial active button correctly - let initial_tool = APP_CONFIG.read().initial_tool(); - if let Some(button) = model.tool_buttons.get(&initial_tool) { + if let Some(button) = model.tool_buttons.get(&config.initial_tool) { model.active_button = Some(button.clone()); } @@ -475,7 +477,7 @@ impl StyleToolbar { #[relm4::component(pub)] impl Component for StyleToolbar { - type Init = (); + type Init = Rc; type Input = StyleToolbarInput; type Output = ToolbarEvent; type CommandOutput = (); @@ -629,12 +631,13 @@ impl Component for StyleToolbar { } fn init( - _: Self::Init, + config: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { - for (i, &color) in APP_CONFIG - .read() + // Color palette is global (shared across windows) + let global_config = APP_CONFIG.read(); + for (i, &color) in global_config .color_palette() .palette() .iter() @@ -671,8 +674,7 @@ impl Component for StyleToolbar { .emit(ToolbarEvent::SizeSelected(*state)); }); - let custom_color = APP_CONFIG - .read() + let custom_color = global_config .color_palette() .custom() .first() @@ -680,17 +682,14 @@ impl Component for StyleToolbar { .unwrap_or(Color::red()); let custom_color_pixbuf = create_icon_pixbuf(custom_color); - // create model + // create model - use per-window config for visibility and annotation size let model = StyleToolbar { custom_color, custom_color_pixbuf, color_action: SimpleAction::from(color_action.clone()), - visible: !APP_CONFIG.read().default_hide_toolbars(), - annotation_size: APP_CONFIG.read().annotation_size_factor(), - annotation_size_formatted: format!( - "{0:.2}", - APP_CONFIG.read().annotation_size_factor() - ), + visible: !config.default_hide_toolbars, + annotation_size: config.annotation_size_factor, + annotation_size_formatted: format!("{0:.2}", config.annotation_size_factor), annotation_dialog_controller: None, };