refactor: trim direct dependencies

This commit is contained in:
devmobasa
2026-05-21 19:17:50 +02:00
parent d672d80d4b
commit 070c6043fb
39 changed files with 1913 additions and 695 deletions
Generated
-180
View File
@@ -37,15 +37,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "android-activity"
version = "0.6.1"
@@ -71,12 +62,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -95,21 +80,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert_cmd"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
dependencies = [
"anstyle",
"bstr",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -156,12 +126,6 @@ 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"
@@ -183,17 +147,6 @@ dependencies = [
"objc2 0.5.2",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -482,12 +435,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -558,25 +505,6 @@ dependencies = [
"syn",
]
[[package]]
name = "env_filter"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
]
[[package]]
name = "env_logger"
version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"env_filter",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -651,15 +579,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "float-cmp"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
dependencies = [
"num-traits",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1453,12 +1372,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1951,36 +1864,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "predicates"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
dependencies = [
"anstyle",
"difflib",
"float-cmp",
"normalize-line-endings",
"predicates-core",
"regex",
]
[[package]]
name = "predicates-core"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
[[package]]
name = "predicates-tree"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -2119,35 +2002,6 @@ dependencies = [
"syn",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -2358,16 +2212,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -2623,12 +2467,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2899,15 +2737,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -3185,26 +3014,18 @@ name = "wayscriber"
version = "0.9.19"
dependencies = [
"anyhow",
"assert_cmd",
"async-trait",
"base64",
"cairo-rs",
"env_logger",
"flate2",
"futures-util",
"ksni",
"libc",
"log",
"pango",
"pangocairo",
"png",
"predicates",
"schemars",
"serde",
"serde_json",
"signal-hook",
"smithay-client-toolkit 0.20.0",
"tempfile",
"tokio",
"toml",
"wayland-client",
@@ -3219,7 +3040,6 @@ name = "wayscriber-configurator"
version = "0.9.19"
dependencies = [
"iced",
"tempfile",
"wayscriber",
]
+1 -11
View File
@@ -33,26 +33,21 @@ pango = "0.22"
pangocairo = "0.22"
# Utils
log = "0.4"
env_logger = { version = "0.11", default-features = false }
log = { version = "0.4", features = ["std"] }
anyhow = "1.0"
toml = "1.1"
serde = { version = "1.0", features = ["derive"] }
async-trait = "0.1"
schemars = { version = "1.2", features = ["derive"], optional = true }
libc = "0.2"
flate2 = "1.0"
base64 = "0.22"
zune-jpeg = "0.5"
# Daemon mode
signal-hook = "0.4"
ksni = { version = "0.3", optional = true }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "sync"] }
# Screenshot capture
zbus = { version = "5.0", optional = true, default-features = false, features = ["tokio"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
serde_json = "1.0"
png = "0.18"
@@ -72,8 +67,3 @@ default = ["tablet-input", "portal", "tray"]
name = "dump_config_schema"
path = "src/bin/dump_config_schema.rs"
required-features = ["config-schema"]
[dev-dependencies]
tempfile = "3.10"
assert_cmd = "2.0"
predicates = "3.1"
-3
View File
@@ -15,6 +15,3 @@ iced = { version = "0.14", default-features = false, features = ["tokio", "tiny-
[features]
default = ["tablet-input"]
tablet-input = ["wayscriber/tablet-input"]
[dev-dependencies]
tempfile = "3.10"
@@ -449,7 +449,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let home = tmp.path();
let prev_home = env::var_os("HOME");
unsafe {
@@ -474,7 +474,7 @@ mod tests {
#[test]
fn write_light_controls_writes_include_and_sources_existing_main() {
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let hypr_dir = tmp.path().join(HYPRLAND_DIR);
fs::create_dir_all(&hypr_dir).unwrap();
let main = hypr_dir.join(MAIN_CONFIG);
@@ -495,7 +495,7 @@ mod tests {
#[test]
fn write_light_controls_is_idempotent_for_existing_source() {
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let hypr_dir = tmp.path().join(HYPRLAND_DIR);
fs::create_dir_all(&hypr_dir).unwrap();
let main = hypr_dir.join(MAIN_CONFIG);
@@ -513,7 +513,7 @@ mod tests {
#[test]
fn write_light_controls_handles_missing_main_config() {
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let result = write_light_controls(tmp.path(), Path::new("/tmp/wayscriber")).unwrap();
+2
View File
@@ -1,6 +1,8 @@
mod app;
mod messages;
mod models;
#[cfg(test)]
mod test_temp;
fn main() -> iced::Result {
app::run()
+41
View File
@@ -0,0 +1,41 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{fs, io};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
pub(crate) struct TempDir {
path: PathBuf,
}
impl TempDir {
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
pub(crate) fn tempdir() -> io::Result<TempDir> {
let base = std::env::temp_dir();
let pid = std::process::id();
for _ in 0..100 {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = base.join(format!("wayscriber-configurator-test-{pid}-{id}"));
match fs::create_dir(&path) {
Ok(()) => return Ok(TempDir { path }),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"failed to create a unique temporary test directory",
))
}
+25 -32
View File
@@ -1,14 +1,9 @@
#[cfg(unix)]
use signal_hook::{
consts::signal::{SIGINT, SIGTERM, SIGUSR1, SIGUSR2},
iterator::Signals,
};
use libc::{SIGINT, SIGTERM, SIGUSR1, SIGUSR2};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
#[cfg(unix)]
use std::thread;
pub(super) fn setup_signal_handlers() -> (Option<Arc<AtomicBool>>, Option<Arc<AtomicBool>>) {
// Gracefully exit the overlay when external signals request termination
@@ -16,33 +11,31 @@ pub(super) fn setup_signal_handlers() -> (Option<Arc<AtomicBool>>, Option<Arc<At
{
let exit_flag = Arc::new(AtomicBool::new(false));
let tray_action_flag = Arc::new(AtomicBool::new(false));
match Signals::new([SIGTERM, SIGINT, SIGUSR1, SIGUSR2]) {
Ok(mut signals) => {
let exit_flag_clone = Arc::clone(&exit_flag);
let tray_action_flag_clone = Arc::clone(&tray_action_flag);
thread::spawn(move || {
for sig in signals.forever() {
match sig {
SIGUSR1 => {
// SIGUSR1 is reserved for daemon toggle; ignore in overlay.
log::debug!("Overlay received SIGUSR1; ignoring");
}
SIGUSR2 => {
log::debug!("Overlay received SIGUSR2 for tray action");
tray_action_flag_clone.store(true, Ordering::Release);
}
_ => {
log::debug!(
"Overlay received signal {}; scheduling graceful shutdown",
sig
);
exit_flag_clone.store(true, Ordering::Release);
}
}
let signal_exit_flag = Arc::clone(&exit_flag);
let signal_tray_action_flag = Arc::clone(&tray_action_flag);
match crate::unix_signals::spawn_listener(
&[SIGTERM, SIGINT, SIGUSR1, SIGUSR2],
move |sig| {
match sig {
SIGUSR1 => {
// SIGUSR1 is reserved for daemon toggle; ignore in overlay.
log::debug!("Overlay received SIGUSR1; ignoring");
}
});
(Some(exit_flag), Some(tray_action_flag))
}
SIGUSR2 => {
log::debug!("Overlay received SIGUSR2 for tray action");
signal_tray_action_flag.store(true, Ordering::Release);
}
_ => {
log::debug!(
"Overlay received signal {}; scheduling graceful shutdown",
sig
);
signal_exit_flag.store(true, Ordering::Release);
}
}
},
) {
Ok(_) => (Some(exit_flag), Some(tray_action_flag)),
Err(err) => {
log::warn!("Failed to register overlay signal handlers: {}", err);
(Some(exit_flag), Some(tray_action_flag))
+1 -1
View File
@@ -199,8 +199,8 @@ fn is_gnome_copied_files_mime(mime_type: &str) -> bool {
mod tests {
use super::*;
use crate::backend::wayland::clipboard::image::choose_supported_mime;
use crate::test_temp::TempDir;
use std::fs;
use tempfile::TempDir;
#[test]
fn choose_supported_mime_accepts_file_manager_uri_lists() {
+208
View File
@@ -0,0 +1,208 @@
use std::{error::Error, fmt};
const STANDARD_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum DecodeError {
InvalidByte { index: usize, byte: u8 },
InvalidLength,
InvalidPadding,
NonCanonicalTrailingBits,
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidByte { index, byte } => {
write!(f, "invalid base64 byte 0x{byte:02x} at offset {index}")
}
Self::InvalidLength => write!(f, "invalid base64 length"),
Self::InvalidPadding => write!(f, "invalid base64 padding"),
Self::NonCanonicalTrailingBits => write!(f, "non-canonical base64 trailing bits"),
}
}
}
impl Error for DecodeError {}
pub(crate) fn encode_standard(bytes: &[u8]) -> String {
let encoded_len = bytes.len().div_ceil(3) * 4;
let mut encoded = String::with_capacity(encoded_len);
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
encoded.push(STANDARD_ALPHABET[(b0 >> 2) as usize] as char);
encoded.push(STANDARD_ALPHABET[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char);
if chunk.len() >= 2 {
encoded
.push(STANDARD_ALPHABET[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char);
} else {
encoded.push('=');
}
if chunk.len() == 3 {
encoded.push(STANDARD_ALPHABET[(b2 & 0b0011_1111) as usize] as char);
} else {
encoded.push('=');
}
}
encoded
}
pub(crate) fn decode_standard(encoded: &str) -> Result<Vec<u8>, DecodeError> {
let mut values = Vec::with_capacity(encoded.len());
let mut padding = 0usize;
let mut saw_padding = false;
for (index, byte) in encoded.bytes().enumerate() {
if byte == b'=' {
saw_padding = true;
padding += 1;
if padding > 2 {
return Err(DecodeError::InvalidPadding);
}
continue;
}
if saw_padding {
return Err(DecodeError::InvalidPadding);
}
let value = decode_value(byte).ok_or(DecodeError::InvalidByte { index, byte })?;
values.push(value);
}
let remainder = values.len() % 4;
if remainder == 1 {
return Err(DecodeError::InvalidLength);
}
if !(values.len() + padding).is_multiple_of(4) {
return Err(DecodeError::InvalidPadding);
}
if padding > 0 {
match (padding, remainder) {
(1, 3) | (2, 2) => {}
_ => return Err(DecodeError::InvalidPadding),
}
}
let mut decoded = Vec::with_capacity(values.len() / 4 * 3 + 2);
let full_groups_len = values.len() / 4 * 4;
for chunk in values[..full_groups_len].chunks_exact(4) {
decoded.push((chunk[0] << 2) | (chunk[1] >> 4));
decoded.push((chunk[1] << 4) | (chunk[2] >> 2));
decoded.push((chunk[2] << 6) | chunk[3]);
}
match &values[full_groups_len..] {
[] => {}
[a, b] => {
if b & 0b0000_1111 != 0 {
return Err(DecodeError::NonCanonicalTrailingBits);
}
decoded.push((a << 2) | (b >> 4));
}
[a, b, c] => {
if c & 0b0000_0011 != 0 {
return Err(DecodeError::NonCanonicalTrailingBits);
}
decoded.push((a << 2) | (b >> 4));
decoded.push((b << 4) | (c >> 2));
}
_ => unreachable!("base64 remainder length was checked"),
}
Ok(decoded)
}
fn decode_value(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{DecodeError, decode_standard, encode_standard};
#[test]
fn encodes_standard_base64_with_canonical_padding() {
let vectors = [
(b"".as_slice(), ""),
(b"f".as_slice(), "Zg=="),
(b"fo".as_slice(), "Zm8="),
(b"foo".as_slice(), "Zm9v"),
(b"foob".as_slice(), "Zm9vYg=="),
(b"fooba".as_slice(), "Zm9vYmE="),
(b"foobar".as_slice(), "Zm9vYmFy"),
(b"hello world".as_slice(), "aGVsbG8gd29ybGQ="),
];
for (decoded, encoded) in vectors {
assert_eq!(encode_standard(decoded), encoded);
}
}
#[test]
fn decodes_canonically_padded_standard_base64() {
let vectors = [
("", b"".as_slice()),
("Zg==", b"f".as_slice()),
("Zm8=", b"fo".as_slice()),
("Zm9v", b"foo".as_slice()),
("Zm9vYg==", b"foob".as_slice()),
("Zm9vYmE=", b"fooba".as_slice()),
("Zm9vYmFy", b"foobar".as_slice()),
("aGVsbG8gd29ybGQ=", b"hello world".as_slice()),
];
for (encoded, decoded) in vectors {
assert_eq!(decode_standard(encoded).unwrap(), decoded);
}
}
#[test]
fn rejects_invalid_base64() {
assert_eq!(
decode_standard("A").unwrap_err(),
DecodeError::InvalidLength
);
assert_eq!(
decode_standard("Zg").unwrap_err(),
DecodeError::InvalidPadding
);
assert_eq!(
decode_standard("Zm8").unwrap_err(),
DecodeError::InvalidPadding
);
assert_eq!(
decode_standard("A===").unwrap_err(),
DecodeError::InvalidPadding
);
assert_eq!(
decode_standard("AA=A").unwrap_err(),
DecodeError::InvalidPadding
);
assert_eq!(
decode_standard("Zg=A").unwrap_err(),
DecodeError::InvalidPadding
);
assert_eq!(
decode_standard("AB==").unwrap_err(),
DecodeError::NonCanonicalTrailingBits
);
}
}
+7 -8
View File
@@ -1,6 +1,4 @@
use std::{path::PathBuf, sync::Arc};
use async_trait::async_trait;
use std::{future::Future, path::PathBuf, pin::Pin, sync::Arc};
use crate::capture::{
clipboard,
@@ -10,11 +8,13 @@ use crate::capture::{
};
/// Abstraction over how image data is captured for the different capture types.
#[async_trait]
pub trait CaptureSource: Send + Sync {
async fn capture(&self, capture_type: CaptureType) -> Result<Vec<u8>, CaptureError>;
fn capture(&self, capture_type: CaptureType) -> CaptureFuture<'_>;
}
pub type CaptureFuture<'a> =
Pin<Box<dyn Future<Output = Result<Vec<u8>, CaptureError>> + Send + 'a>>;
/// Abstraction over file saving for captured screenshots.
pub trait CaptureFileSaver: Send + Sync {
fn save(&self, image_data: &[u8], config: &FileSaveConfig) -> Result<PathBuf, CaptureError>;
@@ -47,10 +47,9 @@ struct DefaultCaptureSource;
struct DefaultFileSaver;
struct DefaultClipboard;
#[async_trait]
impl CaptureSource for DefaultCaptureSource {
async fn capture(&self, capture_type: CaptureType) -> Result<Vec<u8>, CaptureError> {
sources::capture_image(capture_type).await
fn capture(&self, capture_type: CaptureType) -> CaptureFuture<'_> {
Box::pin(async move { sources::capture_image(capture_type).await })
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ mod tests {
#[test]
fn ensure_directory_exists_creates_missing_path() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let target = temp.path().join("nested").join("shots");
let resolved = ensure_directory_exists(&target).expect("ensure_directory_exists");
+1 -3
View File
@@ -1,7 +1,6 @@
//! xdg-desktop-portal integration for screenshot capture.
use super::types::{CaptureError, CaptureType};
use futures_util::StreamExt;
use std::collections::HashMap;
use zbus::zvariant::OwnedValue;
use zbus::{Connection, proxy};
@@ -144,8 +143,7 @@ async fn capture_once(
log::debug!("Waiting for Response signal...");
// Get the first (and only) response.
let response_signal = response_stream
.next()
let response_signal = crate::zbus_stream::next(&mut response_stream)
.await
.ok_or_else(|| CaptureError::InvalidResponse("No Response signal received".to_string()))?;
+1 -1
View File
@@ -81,7 +81,7 @@ fn decode_file_uri(uri: &str) -> Result<PathBuf, CaptureError> {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use crate::test_temp::TempDir;
#[test]
fn reads_and_removes_file() {
+14 -10
View File
@@ -1,10 +1,10 @@
use std::{
future::Future,
path::PathBuf,
pin::Pin,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use crate::capture::{
dependencies::{CaptureClipboard, CaptureFileSaver, CaptureSource},
file::FileSaveConfig,
@@ -18,15 +18,19 @@ pub(super) struct MockSource {
pub(super) captured_types: Arc<Mutex<Vec<CaptureType>>>,
}
#[async_trait]
impl CaptureSource for MockSource {
async fn capture(&self, capture_type: CaptureType) -> Result<Vec<u8>, CaptureError> {
self.captured_types.lock().unwrap().push(capture_type);
if let Some(err) = self.error.lock().unwrap().take() {
Err(err)
} else {
Ok(self.data.clone())
}
fn capture(
&self,
capture_type: CaptureType,
) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, CaptureError>> + Send + '_>> {
Box::pin(async move {
self.captured_types.lock().unwrap().push(capture_type);
if let Some(err) = self.error.lock().unwrap().take() {
Err(err)
} else {
Ok(self.data.clone())
}
})
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
use std::path::Path;
use std::sync::Mutex;
use tempfile::TempDir;
use crate::test_temp::TempDir;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
+137 -21
View File
@@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow};
use log::warn;
use serde::{Deserialize, Serialize};
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -271,7 +272,7 @@ pub(crate) fn clear_daemon_pid_file() -> Result<()> {
clear_file(&daemon_pid_file())
}
fn daemon_lock_is_held() -> Result<bool> {
fn try_acquire_daemon_lock() -> Result<Option<File>> {
let path = daemon_lock_file();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
@@ -287,8 +288,8 @@ fn daemon_lock_is_held() -> Result<bool> {
.with_context(|| format!("failed to open daemon lock {}", path.display()))?;
match try_lock_exclusive(&lock_file) {
Ok(()) => Ok(false),
Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(true),
Ok(()) => Ok(Some(lock_file)),
Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None),
Err(err) => Err(err).context("failed to inspect daemon lock"),
}
}
@@ -302,17 +303,8 @@ fn clear_stale_daemon_state() {
}
}
fn read_daemon_runtime_info() -> Result<DaemonRuntimeInfo> {
if !daemon_lock_is_held()? {
clear_stale_daemon_state();
return Err(anyhow!("wayscriber daemon is not running"));
}
let path = daemon_pid_file();
let raw =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
if let Ok(info) = serde_json::from_str::<DaemonRuntimeInfo>(&raw) {
fn parse_daemon_runtime_info(raw: &str) -> Result<DaemonRuntimeInfo> {
if let Ok(info) = serde_json::from_str::<DaemonRuntimeInfo>(raw) {
return Ok(info);
}
@@ -323,6 +315,55 @@ fn read_daemon_runtime_info() -> Result<DaemonRuntimeInfo> {
Ok(DaemonRuntimeInfo { pid, token: None })
}
fn read_daemon_runtime_file() -> Result<DaemonRuntimeInfo> {
let path = daemon_pid_file();
let raw =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
parse_daemon_runtime_info(&raw)
}
fn read_daemon_runtime_file_if_exists() -> Result<Option<DaemonRuntimeInfo>> {
let path = daemon_pid_file();
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err).with_context(|| format!("failed to read {}", path.display()));
}
};
parse_daemon_runtime_info(&raw).map(Some)
}
fn clear_stale_daemon_state_if_matches(expected: &DaemonRuntimeInfo) {
let Some(_lock_file) = (match try_acquire_daemon_lock() {
Ok(lock_file) => lock_file,
Err(err) => {
warn!(
"Failed to inspect daemon lock before stale cleanup: {}",
err
);
return;
}
}) else {
return;
};
match read_daemon_runtime_file_if_exists() {
Ok(Some(current)) if &current == expected => clear_stale_daemon_state(),
Ok(_) => {}
Err(err) => warn!("Failed to inspect daemon pid before stale cleanup: {}", err),
}
}
fn read_daemon_runtime_info() -> Result<DaemonRuntimeInfo> {
if let Some(_lock_file) = try_acquire_daemon_lock()? {
clear_stale_daemon_state();
return Err(anyhow!("wayscriber daemon is not running"));
}
read_daemon_runtime_file()
}
fn signal_daemon_pid(pid: u32) -> Result<()> {
#[cfg(unix)]
{
@@ -361,7 +402,7 @@ pub(crate) fn send_daemon_toggle_request(request: &DaemonToggleRequest) -> Resul
}
if let Err(err) = signal_daemon_pid(runtime.pid) {
clear_stale_daemon_state();
clear_stale_daemon_state_if_matches(&runtime);
return Err(err);
}
Ok(())
@@ -416,7 +457,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
@@ -435,12 +476,87 @@ mod tests {
}
}
#[test]
fn stale_cleanup_removes_matching_runtime_while_lock_is_free() {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
}
let runtime = DaemonRuntimeInfo {
pid: 1234,
token: Some("old-token".into()),
};
write_daemon_pid_file(runtime.pid, runtime.token.as_deref().unwrap()).unwrap();
write_daemon_toggle_request(
&DaemonToggleRequest {
freeze: true,
..Default::default()
},
"old-token",
)
.unwrap();
clear_stale_daemon_state_if_matches(&runtime);
assert!(!daemon_pid_file().exists());
assert!(!daemon_command_dir().exists());
match prev {
Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) },
None => unsafe { env::remove_var("XDG_RUNTIME_DIR") },
}
}
#[test]
fn stale_cleanup_preserves_mismatched_runtime() {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
}
let current = DaemonRuntimeInfo {
pid: 5678,
token: Some("new-token".into()),
};
write_daemon_pid_file(current.pid, current.token.as_deref().unwrap()).unwrap();
write_daemon_toggle_request(
&DaemonToggleRequest {
freeze: true,
..Default::default()
},
"new-token",
)
.unwrap();
clear_stale_daemon_state_if_matches(&DaemonRuntimeInfo {
pid: 1234,
token: Some("old-token".into()),
});
assert_eq!(read_daemon_runtime_file().unwrap(), current);
assert!(daemon_command_dir().exists());
match prev {
Some(value) => unsafe { env::set_var("XDG_RUNTIME_DIR", value) },
None => unsafe { env::remove_var("XDG_RUNTIME_DIR") },
}
}
#[test]
fn take_daemon_toggle_request_round_trips_payload() {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
@@ -474,7 +590,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
@@ -517,7 +633,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
@@ -556,7 +672,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
@@ -588,7 +704,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
+42 -38
View File
@@ -1,7 +1,5 @@
use anyhow::{Context, Result};
use log::{info, warn};
use signal_hook::consts::signal::{SIGINT, SIGTERM, SIGUSR1};
use signal_hook::iterator::Signals;
use std::fs;
use std::fs::OpenOptions;
use std::io::ErrorKind;
@@ -299,6 +297,9 @@ impl Daemon {
info!("Legacy raw SIGUSR1 toggle still works, but cannot carry launch args");
self.acquire_daemon_lock()?;
if let Err(err) = crate::daemon::clear_daemon_pid_file() {
warn!("Failed to clear stale daemon pid file on startup: {}", err);
}
if let Err(err) = crate::daemon::clear_daemon_toggle_request_file() {
warn!(
"Failed to clear stale daemon toggle request on startup: {}",
@@ -306,50 +307,53 @@ impl Daemon {
);
}
// Set up signal handling
let mut signals = Signals::new([SIGUSR1, SIGTERM, SIGINT])
.context("Failed to register signal handler")?;
crate::daemon::write_daemon_pid_file(std::process::id(), &self.instance_token)?;
#[cfg(unix)]
const DAEMON_SIGNALS: [libc::c_int; 3] = [libc::SIGUSR1, libc::SIGTERM, libc::SIGINT];
let toggle_flag = self.toggle_requested.clone();
let signal_toggle_flag = self.signal_toggle_requested.clone();
let quit_flag = self.should_quit.clone();
// Spawn signal handler thread
// Note: This thread will run until process termination. The signal_hook iterator
// doesn't provide a clean shutdown mechanism with forever(), but this is acceptable
// for a daemon process as the thread has no resources requiring explicit cleanup.
// The thread will be terminated by the OS when the process exits.
thread::spawn(move || {
for sig in signals.forever() {
if quit_flag.load(Ordering::Acquire) {
info!("Signal handler thread exiting");
break;
// The signal listener thread runs until process termination. The daemon
// exits shortly after quit signals, and the OS cleans up the detached thread.
#[cfg(unix)]
crate::unix_signals::spawn_listener(&DAEMON_SIGNALS, move |sig| {
if quit_flag.load(Ordering::Acquire) {
info!("Signal handler thread exiting");
return;
}
match sig {
libc::SIGUSR1 => {
info!("Received SIGUSR1 - toggling overlay");
// Use Release ordering to ensure all prior memory operations
// are visible to the thread that reads this flag
signal_toggle_flag.store(true, Ordering::Release);
toggle_flag.store(true, Ordering::Release);
}
match sig {
SIGUSR1 => {
info!("Received SIGUSR1 - toggling overlay");
// Use Release ordering to ensure all prior memory operations
// are visible to the thread that reads this flag
signal_toggle_flag.store(true, Ordering::Release);
toggle_flag.store(true, Ordering::Release);
}
SIGTERM | SIGINT => {
info!(
"Received {} - initiating graceful shutdown",
if sig == SIGTERM { "SIGTERM" } else { "SIGINT" }
);
// Use Release ordering to ensure all prior memory operations
// are visible to the thread that reads this flag
quit_flag.store(true, Ordering::Release);
}
_ => {
warn!("Received unexpected signal: {}", sig);
}
libc::SIGTERM | libc::SIGINT => {
info!(
"Received {} - initiating graceful shutdown",
if sig == libc::SIGTERM {
"SIGTERM"
} else {
"SIGINT"
}
);
// Use Release ordering to ensure all prior memory operations
// are visible to the thread that reads this flag
quit_flag.store(true, Ordering::Release);
}
_ => {
warn!("Received unexpected signal: {}", sig);
}
}
});
})
.context("Failed to register signal handler")?;
// Only publish the pid after SIGUSR1 is handled. A racing
// `--daemon-toggle` sends SIGUSR1 to this pid, and the default action
// before handler installation would terminate the daemon.
crate::daemon::write_daemon_pid_file(std::process::id(), &self.instance_token)?;
// Start system tray (optional)
if self.tray_enabled {
+2 -4
View File
@@ -10,8 +10,6 @@ use wayscriber::shortcut_hint::{PORTAL_APP_ID_ENV, PORTAL_SHORTCUT_ENV};
#[cfg(feature = "portal")]
use anyhow::{Context, Result, anyhow};
#[cfg(feature = "portal")]
use futures_util::StreamExt;
#[cfg(feature = "portal")]
use log::{debug, info, warn};
#[cfg(feature = "portal")]
use std::collections::HashMap;
@@ -215,7 +213,7 @@ async fn run_listener(
loop {
tokio::select! {
maybe_signal = activated_stream.next() => {
maybe_signal = crate::zbus_stream::next(&mut activated_stream) => {
let Some(signal) = maybe_signal else {
return Err(anyhow!("GlobalShortcuts.Activated stream ended unexpectedly"));
};
@@ -375,7 +373,7 @@ async fn wait_for_request_response(
.context("failed to subscribe to Request.Response")?;
loop {
tokio::select! {
maybe_signal = response_stream.next() => {
maybe_signal = crate::zbus_stream::next(&mut response_stream) => {
let response_signal = maybe_signal
.ok_or_else(|| anyhow!("portal request completed without Response signal"))?;
let args = response_signal
+1 -2
View File
@@ -1,6 +1,5 @@
use crate::draw::frame::{Frame, ImageBoundsSnapshot, UndoAction};
use crate::draw::{EmbeddedImage, Shape, color::BLACK};
use base64::{Engine as _, engine::general_purpose};
#[test]
fn frame_serializes_history() {
@@ -126,7 +125,7 @@ fn try_add_shape_respects_limit() {
#[test]
fn image_bounds_history_serializes_without_duplicate_image_payloads() {
let bytes = vec![42u8; 256];
let encoded = general_purpose::STANDARD.encode(&bytes);
let encoded = crate::base64::encode_standard(&bytes);
let mut frame = Frame::new();
let id = frame.add_shape(Shape::Image {
x: 0,
+2 -7
View File
@@ -7,7 +7,6 @@ use super::text::{bounding_box_for_sticky_note, bounding_box_for_text};
use crate::draw::color::Color;
use crate::draw::font::FontDescriptor;
use crate::util::Rect;
use base64::{Engine as _, engine::general_purpose};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Encoded image payload stored directly on an image shape.
@@ -404,9 +403,7 @@ mod base64_bytes {
where
S: Serializer,
{
general_purpose::STANDARD
.encode(bytes)
.serialize(serializer)
crate::base64::encode_standard(bytes).serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
@@ -414,8 +411,6 @@ mod base64_bytes {
D: Deserializer<'de>,
{
let encoded = String::deserialize(deserializer)?;
general_purpose::STANDARD
.decode(encoded)
.map_err(serde::de::Error::custom)
crate::base64::decode_standard(&encoded).map_err(serde::de::Error::custom)
}
}
+1 -2
View File
@@ -195,11 +195,10 @@ fn pixel_count(width: u32, height: u32) -> Result<usize, String> {
#[cfg(test)]
mod tests {
use super::{EncodedImageFormat, decode_rgba};
use base64::{Engine as _, engine::general_purpose::STANDARD};
#[test]
fn decode_jpeg_rgba_preserves_cmyk_jpeg_colors() {
let bytes = STANDARD.decode(CMYK_RED_JPEG).unwrap();
let bytes = crate::base64::decode_standard(CMYK_RED_JPEG).unwrap();
let image = decode_rgba(EncodedImageFormat::Jpeg, &bytes).unwrap();
+5
View File
@@ -4,6 +4,7 @@
//! rely on so that external tools (e.g. GUI configurators) can share validation
//! logic and serialization code with the main binary.
pub(crate) mod base64;
pub mod build_info;
pub mod capture;
pub mod config;
@@ -18,10 +19,14 @@ pub mod runtime_capabilities;
pub mod session;
pub mod shortcut_hint;
pub mod systemd_user_service;
#[cfg(test)]
pub(crate) mod test_temp;
pub mod time_utils;
pub mod toolbar_icons;
pub mod ui;
pub(crate) mod ui_text;
pub mod util;
#[cfg(feature = "portal")]
pub(crate) mod zbus_stream;
pub use config::Config;
+107
View File
@@ -0,0 +1,107 @@
mod file;
mod filter;
use std::io::{self, Write};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use log::{Log, Metadata, Record};
use self::file::{DailyFileWriter, resolve_log_target};
use self::filter::LogFilter;
pub(crate) fn init(log_to_file: bool) {
let filter = LogFilter::from_env();
let max_level = filter.max_level();
let writer: Box<dyn Write + Send> = if log_to_file {
let target = resolve_log_target();
let file_writer = DailyFileWriter::new(target);
Box::new(TeeWriter::new(
Box::new(io::stderr()),
Box::new(file_writer),
))
} else {
Box::new(io::stderr())
};
let logger = SimpleLogger {
filter,
writer: Mutex::new(writer),
include_timestamp: log_to_file,
};
if log::set_boxed_logger(Box::new(logger)).is_ok() {
log::set_max_level(max_level);
}
}
struct SimpleLogger {
filter: LogFilter,
writer: Mutex<Box<dyn Write + Send>>,
include_timestamp: bool,
}
impl Log for SimpleLogger {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
self.filter.enabled(metadata.target(), metadata.level())
}
fn log(&self, record: &Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let mut writer = match self.writer.lock() {
Ok(writer) => writer,
Err(poisoned) => poisoned.into_inner(),
};
if self.include_timestamp {
let _ = write!(writer, "{} ", timestamp_millis());
}
let _ = writeln!(
writer,
"{} {}: {}",
record.level(),
record.target(),
record.args()
);
}
fn flush(&self) {
if let Ok(mut writer) = self.writer.lock() {
let _ = writer.flush();
}
}
}
fn timestamp_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}
struct TeeWriter {
left: Box<dyn Write + Send>,
right: Box<dyn Write + Send>,
}
impl TeeWriter {
fn new(left: Box<dyn Write + Send>, right: Box<dyn Write + Send>) -> Self {
Self { left, right }
}
}
impl Write for TeeWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.left.write_all(buf)?;
self.right.write_all(buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.left.flush()?;
self.right.flush()
}
}
+276
View File
@@ -0,0 +1,276 @@
use std::env;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::{paths, time_utils};
const BYTES_PER_MB: u64 = 1024 * 1024;
const DEFAULT_LOG_MAX_BYTES: u64 = 10 * BYTES_PER_MB;
const LOG_MAX_SIZE_ENV: &str = "WAYSCRIBER_LOG_MAX_SIZE_MB";
pub(super) fn resolve_log_target() -> LogFileTarget {
if let Ok(path) = env::var("WAYSCRIBER_LOG_FILE")
&& !path.trim().is_empty()
{
let trimmed = path.trim();
let expanded = paths::expand_tilde(trimmed);
let mut treat_as_dir = trimmed.ends_with('/') || trimmed.ends_with('\\');
if !treat_as_dir && let Ok(metadata) = fs::metadata(&expanded) {
treat_as_dir = metadata.is_dir();
}
let append_date = if treat_as_dir {
true
} else {
expanded.extension().is_none()
};
return LogFileTarget {
base: expanded,
treat_as_dir,
append_date,
};
}
LogFileTarget {
base: paths::log_dir(),
treat_as_dir: true,
append_date: true,
}
}
fn resolve_log_max_bytes() -> u64 {
env::var(LOG_MAX_SIZE_ENV)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
.map(|value| value.saturating_mul(BYTES_PER_MB))
.unwrap_or(DEFAULT_LOG_MAX_BYTES)
}
fn current_log_date() -> String {
time_utils::format_with_template(time_utils::now_local(), "%Y-%m-%d")
}
pub(super) struct LogFileTarget {
base: PathBuf,
treat_as_dir: bool,
append_date: bool,
}
impl LogFileTarget {
fn path_for_date(&self, date: &str) -> PathBuf {
if self.treat_as_dir {
return self.base.join(format!("wayscriber-{}.log", date));
}
if !self.append_date {
return self.base.clone();
}
let file_name = dated_log_file_name(&self.base, date);
self.base.with_file_name(file_name)
}
fn path_for_date_and_index(&self, date: &str, index: u32) -> PathBuf {
let base = self.path_for_date(date);
if index == 0 {
base
} else {
append_index_to_path(&base, index)
}
}
}
fn dated_log_file_name(path: &Path, date: &str) -> OsString {
let mut name = OsString::new();
if let Some(stem) = path.file_stem() {
name.push(stem);
} else if let Some(file_name) = path.file_name() {
name.push(file_name);
} else {
name.push("wayscriber");
}
name.push("-");
name.push(date);
if let Some(ext) = path.extension() {
name.push(".");
name.push(ext);
} else {
name.push(".log");
}
name
}
fn append_index_to_path(path: &Path, index: u32) -> PathBuf {
let mut name = OsString::new();
if let Some(stem) = path.file_stem() {
name.push(stem);
} else if let Some(file_name) = path.file_name() {
name.push(file_name);
} else {
name.push("wayscriber");
}
name.push("-");
name.push(index.to_string());
if let Some(ext) = path.extension() {
name.push(".");
name.push(ext);
}
path.with_file_name(name)
}
pub(super) struct DailyFileWriter {
target: LogFileTarget,
max_bytes: u64,
current_date: Option<String>,
current_size: u64,
file: Option<fs::File>,
error_date: Option<String>,
}
impl DailyFileWriter {
pub(super) fn new(target: LogFileTarget) -> Self {
Self {
target,
max_bytes: resolve_log_max_bytes(),
current_date: None,
current_size: 0,
file: None,
error_date: None,
}
}
fn ensure_file(&mut self) {
let date = current_log_date();
if self.error_date.as_deref() == Some(date.as_str()) {
return;
}
if self.current_date.as_deref() == Some(date.as_str())
&& self.file.is_some()
&& self.current_size < self.max_bytes
{
return;
}
let (path, size) = match self.select_log_path(&date) {
Ok(result) => result,
Err(err) => {
eprintln!("Failed to resolve log file for {}: {}", date, err);
self.error_date = Some(date);
return;
}
};
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
eprintln!(
"Failed to create log directory {}: {}",
parent.display(),
err
);
self.error_date = Some(date);
return;
}
match fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => {
self.file = Some(file);
self.current_date = Some(date);
self.current_size = size;
self.error_date = None;
}
Err(err) => {
eprintln!("Failed to open log file {}: {}", path.display(), err);
self.error_date = Some(date);
}
}
}
fn select_log_path(&self, date: &str) -> io::Result<(PathBuf, u64)> {
let mut index = 0u32;
loop {
let path = self.target.path_for_date_and_index(date, index);
match fs::metadata(&path) {
Ok(metadata) => {
let size = metadata.len();
if size < self.max_bytes {
return Ok((path, size));
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok((path, 0));
}
Err(err) => return Err(err),
}
index = index
.checked_add(1)
.ok_or_else(|| io::Error::other("log index overflow"))?;
}
}
}
impl Write for DailyFileWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.ensure_file();
if let Some(file) = self.file.as_mut() {
file.write_all(buf)?;
self.current_size = self.current_size.saturating_add(buf.len() as u64);
if self.current_size >= self.max_bytes {
self.file = None;
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.ensure_file();
if let Some(file) = self.file.as_mut() {
file.flush()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::LogFileTarget;
use std::path::PathBuf;
#[test]
fn dated_log_file_name_preserves_or_adds_extension() {
let target = LogFileTarget {
base: PathBuf::from("/tmp/wayscriber.log"),
treat_as_dir: false,
append_date: true,
};
assert_eq!(
target.path_for_date("2026-05-21"),
PathBuf::from("/tmp/wayscriber-2026-05-21.log")
);
let target = LogFileTarget {
base: PathBuf::from("/tmp/wayscriber"),
treat_as_dir: false,
append_date: true,
};
assert_eq!(
target.path_for_date("2026-05-21"),
PathBuf::from("/tmp/wayscriber-2026-05-21.log")
);
}
#[test]
fn indexed_log_path_preserves_extension() {
let target = LogFileTarget {
base: PathBuf::from("/tmp/wayscriber.log"),
treat_as_dir: false,
append_date: true,
};
assert_eq!(
target.path_for_date_and_index("2026-05-21", 2),
PathBuf::from("/tmp/wayscriber-2026-05-21-2.log")
);
}
}
+218
View File
@@ -0,0 +1,218 @@
use std::env;
use log::{Level, LevelFilter};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct LogFilter {
default: LevelFilter,
directives: Vec<LogDirective>,
max_level: LevelFilter,
}
impl LogFilter {
pub(super) fn from_env() -> Self {
match env::var("RUST_LOG") {
Ok(value) => Self::parse(&value, LevelFilter::Off),
Err(_) => Self::parse("info", LevelFilter::Info),
}
}
fn parse(value: &str, fallback: LevelFilter) -> Self {
let mut default = fallback;
let mut directives = Vec::new();
let mut accepted_filter = false;
for raw_directive in value.split(',') {
let directive = raw_directive.trim();
if directive.is_empty() {
continue;
}
if let Some((target, level)) = directive.split_once('=') {
let target = target.trim();
if target.is_empty() {
continue;
}
if let Some(level) = parse_level(level.trim()) {
directives.push(LogDirective {
target: target.to_string(),
level,
});
accepted_filter = true;
}
} else if let Some(level) = parse_level(directive) {
default = level;
accepted_filter = true;
} else {
directives.push(LogDirective {
target: directive.to_string(),
level: LevelFilter::Trace,
});
accepted_filter = true;
}
}
if !accepted_filter && fallback == LevelFilter::Off {
default = LevelFilter::Error;
}
let max_level = directives
.iter()
.map(|directive| directive.level)
.fold(default, max_level_filter);
Self {
default,
directives,
max_level,
}
}
pub(super) fn max_level(&self) -> LevelFilter {
self.max_level
}
pub(super) fn enabled(&self, target: &str, level: Level) -> bool {
level.to_level_filter() <= self.level_for(target)
}
fn level_for(&self, target: &str) -> LevelFilter {
let mut matched = None;
for directive in &self.directives {
if target_matches(&directive.target, target) {
let target_len = directive.target.len();
if matched.is_none_or(|(matched_len, _)| target_len >= matched_len) {
matched = Some((target_len, directive.level));
}
}
}
matched.map_or(self.default, |(_, level)| level)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct LogDirective {
target: String,
level: LevelFilter,
}
fn parse_level(value: &str) -> Option<LevelFilter> {
match value.to_ascii_lowercase().as_str() {
"off" => Some(LevelFilter::Off),
"error" => Some(LevelFilter::Error),
"warn" | "warning" => Some(LevelFilter::Warn),
"info" => Some(LevelFilter::Info),
"debug" => Some(LevelFilter::Debug),
"trace" => Some(LevelFilter::Trace),
_ => None,
}
}
fn target_matches(directive: &str, target: &str) -> bool {
target == directive
|| target
.strip_prefix(directive)
.is_some_and(|remaining| remaining.starts_with("::"))
}
fn max_level_filter(left: LevelFilter, right: LevelFilter) -> LevelFilter {
if left >= right { left } else { right }
}
#[cfg(test)]
mod tests {
use super::{LogFilter, max_level_filter};
use log::{Level, LevelFilter};
#[test]
fn missing_rust_log_defaults_to_info() {
let filter = LogFilter::parse("info", LevelFilter::Info);
assert!(filter.enabled("wayscriber", Level::Info));
assert!(!filter.enabled("wayscriber", Level::Debug));
assert_eq!(filter.max_level(), LevelFilter::Info);
}
#[test]
fn global_rust_log_level_applies_to_all_targets() {
let filter = LogFilter::parse("debug", LevelFilter::Off);
assert!(filter.enabled("wayscriber", Level::Debug));
assert!(filter.enabled("zbus", Level::Debug));
assert!(!filter.enabled("wayscriber", Level::Trace));
assert_eq!(filter.max_level(), LevelFilter::Debug);
}
#[test]
fn empty_rust_log_falls_back_to_error() {
let filter = LogFilter::parse(" , ", LevelFilter::Off);
assert!(filter.enabled("wayscriber", Level::Error));
assert!(!filter.enabled("wayscriber", Level::Warn));
assert_eq!(filter.max_level(), LevelFilter::Error);
}
#[test]
fn unusable_rust_log_directives_fall_back_to_error() {
let filter = LogFilter::parse("wayscriber=verbose,zbus=", LevelFilter::Off);
assert!(filter.enabled("wayscriber", Level::Error));
assert!(!filter.enabled("wayscriber", Level::Warn));
assert_eq!(filter.max_level(), LevelFilter::Error);
}
#[test]
fn explicit_off_rust_log_stays_silent() {
let filter = LogFilter::parse("off", LevelFilter::Off);
assert!(!filter.enabled("wayscriber", Level::Error));
assert_eq!(filter.max_level(), LevelFilter::Off);
}
#[test]
fn target_directives_match_module_boundaries() {
let filter = LogFilter::parse("warn,wayscriber=debug,zbus=off", LevelFilter::Off);
assert!(filter.enabled("wayscriber::daemon", Level::Debug));
assert!(filter.enabled("wayscriber_extra", Level::Warn));
assert!(!filter.enabled("wayscriber_extra", Level::Info));
assert!(!filter.enabled("zbus", Level::Error));
assert_eq!(filter.max_level(), LevelFilter::Debug);
}
#[test]
fn later_target_directives_override_earlier_ones() {
let filter = LogFilter::parse("wayscriber=trace,wayscriber=error", LevelFilter::Off);
assert!(filter.enabled("wayscriber", Level::Error));
assert!(!filter.enabled("wayscriber", Level::Warn));
assert_eq!(filter.max_level(), LevelFilter::Trace);
}
#[test]
fn more_specific_target_directives_override_broader_later_matches() {
let filter = LogFilter::parse("wayscriber::daemon=debug,wayscriber=info", LevelFilter::Off);
assert!(filter.enabled("wayscriber::daemon", Level::Debug));
assert!(!filter.enabled("wayscriber::daemon", Level::Trace));
assert!(filter.enabled("wayscriber::ui", Level::Info));
assert!(!filter.enabled("wayscriber::ui", Level::Debug));
assert_eq!(filter.max_level(), LevelFilter::Debug);
}
#[test]
fn bare_target_directive_enables_trace_for_that_target() {
let filter = LogFilter::parse("wayscriber", LevelFilter::Off);
assert!(filter.enabled("wayscriber::backend", Level::Trace));
assert!(!filter.enabled("zbus", Level::Error));
assert_eq!(filter.max_level(), LevelFilter::Trace);
}
#[test]
fn max_level_filter_returns_more_verbose_level() {
assert_eq!(
max_level_filter(LevelFilter::Warn, LevelFilter::Debug),
LevelFilter::Debug
);
}
}
+9 -277
View File
@@ -2,6 +2,7 @@ mod about_window;
mod app;
mod app_id;
mod backend;
mod base64;
mod build_info;
mod capture;
mod cli;
@@ -12,25 +13,24 @@ mod file_uri;
mod image_decode;
mod input;
mod label_format;
mod logger;
mod notification;
mod onboarding;
mod paths;
mod session;
mod session_override;
#[cfg(test)]
mod test_temp;
mod time_utils;
mod toolbar_icons;
mod tray_action;
mod ui;
pub(crate) mod ui_text;
#[cfg(unix)]
mod unix_signals;
mod util;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use log::LevelFilter;
#[cfg(feature = "portal")]
mod zbus_stream;
pub use session_override::{
RESUME_SESSION_ENV, SESSION_OVERRIDE_FOLLOW_CONFIG, SESSION_OVERRIDE_FORCE_OFF,
@@ -40,7 +40,7 @@ pub use session_override::{
fn main() {
let cli = cli::Cli::parse();
init_logging(&cli);
logger::init(cli.daemon || cli.active);
if let Err(err) = app::run(cli) {
let already_running = err
@@ -54,271 +54,3 @@ fn main() {
std::process::exit(1);
}
}
fn init_logging(cli: &cli::Cli) {
let mut builder = env_logger::Builder::from_default_env();
if env::var_os("RUST_LOG").is_none() {
builder.filter_level(LevelFilter::Info);
}
let log_to_file = cli.daemon || cli.active;
if log_to_file {
let target = resolve_log_target();
let file_writer = DailyFileWriter::new(target);
let tee = TeeWriter::new(Box::new(io::stderr()), Box::new(file_writer));
builder.target(env_logger::Target::Pipe(Box::new(tee)));
builder.format_timestamp_millis();
}
builder.init();
}
const BYTES_PER_MB: u64 = 1024 * 1024;
const DEFAULT_LOG_MAX_BYTES: u64 = 10 * BYTES_PER_MB;
const LOG_MAX_SIZE_ENV: &str = "WAYSCRIBER_LOG_MAX_SIZE_MB";
fn resolve_log_max_bytes() -> u64 {
env::var(LOG_MAX_SIZE_ENV)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|value| *value > 0)
.map(|value| value.saturating_mul(BYTES_PER_MB))
.unwrap_or(DEFAULT_LOG_MAX_BYTES)
}
fn resolve_log_target() -> LogFileTarget {
if let Ok(path) = env::var("WAYSCRIBER_LOG_FILE")
&& !path.trim().is_empty()
{
let trimmed = path.trim();
let expanded = paths::expand_tilde(trimmed);
let mut treat_as_dir = trimmed.ends_with('/') || trimmed.ends_with('\\');
if !treat_as_dir && let Ok(metadata) = fs::metadata(&expanded) {
treat_as_dir = metadata.is_dir();
}
let append_date = if treat_as_dir {
true
} else {
expanded.extension().is_none()
};
return LogFileTarget {
base: expanded,
treat_as_dir,
append_date,
};
}
LogFileTarget {
base: paths::log_dir(),
treat_as_dir: true,
append_date: true,
}
}
fn current_log_date() -> String {
time_utils::format_with_template(time_utils::now_local(), "%Y-%m-%d")
}
struct LogFileTarget {
base: PathBuf,
treat_as_dir: bool,
append_date: bool,
}
impl LogFileTarget {
fn path_for_date(&self, date: &str) -> PathBuf {
if self.treat_as_dir {
return self.base.join(format!("wayscriber-{}.log", date));
}
if !self.append_date {
return self.base.clone();
}
let file_name = dated_log_file_name(&self.base, date);
self.base.with_file_name(file_name)
}
fn path_for_date_and_index(&self, date: &str, index: u32) -> PathBuf {
let base = self.path_for_date(date);
if index == 0 {
base
} else {
append_index_to_path(&base, index)
}
}
}
fn dated_log_file_name(path: &Path, date: &str) -> OsString {
let mut name = OsString::new();
if let Some(stem) = path.file_stem() {
name.push(stem);
} else if let Some(file_name) = path.file_name() {
name.push(file_name);
} else {
name.push("wayscriber");
}
name.push("-");
name.push(date);
if let Some(ext) = path.extension() {
name.push(".");
name.push(ext);
} else {
name.push(".log");
}
name
}
fn append_index_to_path(path: &Path, index: u32) -> PathBuf {
let mut name = OsString::new();
if let Some(stem) = path.file_stem() {
name.push(stem);
} else if let Some(file_name) = path.file_name() {
name.push(file_name);
} else {
name.push("wayscriber");
}
name.push("-");
name.push(index.to_string());
if let Some(ext) = path.extension() {
name.push(".");
name.push(ext);
}
path.with_file_name(name)
}
struct DailyFileWriter {
target: LogFileTarget,
max_bytes: u64,
current_date: Option<String>,
current_size: u64,
file: Option<fs::File>,
error_date: Option<String>,
}
impl DailyFileWriter {
fn new(target: LogFileTarget) -> Self {
Self {
target,
max_bytes: resolve_log_max_bytes(),
current_date: None,
current_size: 0,
file: None,
error_date: None,
}
}
fn ensure_file(&mut self) {
let date = current_log_date();
if self.error_date.as_deref() == Some(date.as_str()) {
return;
}
if self.current_date.as_deref() == Some(date.as_str())
&& self.file.is_some()
&& self.current_size < self.max_bytes
{
return;
}
let (path, size) = match self.select_log_path(&date) {
Ok(result) => result,
Err(err) => {
eprintln!("Failed to resolve log file for {}: {}", date, err);
self.error_date = Some(date);
return;
}
};
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
eprintln!(
"Failed to create log directory {}: {}",
parent.display(),
err
);
self.error_date = Some(date);
return;
}
match fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => {
self.file = Some(file);
self.current_date = Some(date);
self.current_size = size;
self.error_date = None;
}
Err(err) => {
eprintln!("Failed to open log file {}: {}", path.display(), err);
self.error_date = Some(date);
}
}
}
fn select_log_path(&self, date: &str) -> io::Result<(PathBuf, u64)> {
let mut index = 0u32;
loop {
let path = self.target.path_for_date_and_index(date, index);
match fs::metadata(&path) {
Ok(metadata) => {
let size = metadata.len();
if size < self.max_bytes {
return Ok((path, size));
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok((path, 0));
}
Err(err) => return Err(err),
}
index = index
.checked_add(1)
.ok_or_else(|| io::Error::other("log index overflow"))?;
}
}
}
impl Write for DailyFileWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.ensure_file();
if let Some(file) = self.file.as_mut() {
file.write_all(buf)?;
self.current_size = self.current_size.saturating_add(buf.len() as u64);
if self.current_size >= self.max_bytes {
self.file = None;
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.ensure_file();
if let Some(file) = self.file.as_mut() {
file.flush()?;
}
Ok(())
}
}
struct TeeWriter {
left: Box<dyn Write + Send>,
right: Box<dyn Write + Send>,
}
impl TeeWriter {
fn new(left: Box<dyn Write + Send>, right: Box<dyn Write + Send>) -> Self {
Self { left, right }
}
}
impl Write for TeeWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.left.write_all(buf)?;
self.right.write_all(buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.left.flush()?;
self.right.flush()
}
}
+4 -4
View File
@@ -419,7 +419,7 @@ mod tests {
#[test]
fn onboarding_defaults_when_missing() {
let tmp = tempfile::tempdir().expect("tempdir should succeed");
let tmp = crate::test_temp::tempdir().expect("tempdir should succeed");
let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE);
let store = OnboardingStore::load_from_path(path.clone());
assert!(!store.state().welcome_shown);
@@ -433,7 +433,7 @@ mod tests {
#[test]
fn onboarding_persists_flags() {
let tmp = tempfile::tempdir().expect("tempdir should succeed");
let tmp = crate::test_temp::tempdir().expect("tempdir should succeed");
let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE);
let mut store = OnboardingStore::load_from_path(path.clone());
store.state_mut().welcome_shown = true;
@@ -449,7 +449,7 @@ mod tests {
#[test]
fn onboarding_recovers_from_parse_error() {
let tmp = tempfile::tempdir().expect("tempdir should succeed");
let tmp = crate::test_temp::tempdir().expect("tempdir should succeed");
let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create onboarding dir");
@@ -481,7 +481,7 @@ mod tests {
#[test]
fn onboarding_version_bump_saves() {
let tmp = tempfile::tempdir().expect("tempdir should succeed");
let tmp = crate::test_temp::tempdir().expect("tempdir should succeed");
let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create onboarding dir");
+8 -8
View File
@@ -10,7 +10,7 @@ fn tray_action_prefers_runtime_dir_when_set() {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
// SAFETY: serialised via ENV_MUTEX
unsafe {
@@ -41,7 +41,7 @@ fn config_dir_prefers_xdg_config_home_when_set() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
let prev_userprofile = env::var_os("USERPROFILE");
let prev_xdg = env::var_os("XDG_CONFIG_HOME");
@@ -75,7 +75,7 @@ fn config_dir_falls_back_to_home_config() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
let prev_userprofile = env::var_os("USERPROFILE");
let prev_xdg = env::var_os("XDG_CONFIG_HOME");
@@ -109,7 +109,7 @@ fn data_dir_prefers_xdg_data_home_when_set() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
let prev_userprofile = env::var_os("USERPROFILE");
let prev_xdg = env::var_os("XDG_DATA_HOME");
@@ -143,7 +143,7 @@ fn data_dir_falls_back_to_home_share() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
let prev_userprofile = env::var_os("USERPROFILE");
let prev_xdg = env::var_os("XDG_DATA_HOME");
@@ -177,7 +177,7 @@ fn pictures_dir_prefers_xdg_when_set() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
let prev_userprofile = env::var_os("USERPROFILE");
let prev_xdg = env::var_os("XDG_PICTURES_DIR");
@@ -211,7 +211,7 @@ fn pictures_dir_falls_back_to_home() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
let prev_userprofile = env::var_os("USERPROFILE");
let prev_xdg = env::var_os("XDG_PICTURES_DIR");
@@ -245,7 +245,7 @@ fn expand_tilde_replaces_home() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev_home = env::var_os("HOME");
unsafe {
+1 -1
View File
@@ -10,8 +10,8 @@ use super::types::{
use super::{load_snapshot, save_snapshot};
use crate::draw::{Color, Frame, Shape};
use crate::session::options::{CompressionMode, SessionOptions};
use crate::test_temp::tempdir;
use crate::time_utils::now_rfc3339;
use tempfile::tempdir;
fn sample_frame() -> Frame {
let mut frame = Frame::new();
+2 -2
View File
@@ -7,7 +7,7 @@ use crate::session::{
#[test]
fn clear_session_removes_all_variants_for_prefix() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-1");
options.per_output = true;
@@ -53,7 +53,7 @@ fn clear_session_removes_all_variants_for_prefix() {
#[test]
fn inspect_session_reports_counts_and_flags() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-inspect");
options.persist_transparent = true;
options.persist_whiteboard = false;
+4 -4
View File
@@ -7,7 +7,7 @@ use std::fs;
#[test]
fn snapshot_preserves_history_only_frames() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-history");
options.persist_transparent = true;
options.persist_history = true;
@@ -59,7 +59,7 @@ fn snapshot_preserves_history_only_frames() {
#[test]
fn modify_delete_cycle_survives_restore() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-modify-delete");
options.persist_transparent = true;
options.persist_history = true;
@@ -152,7 +152,7 @@ fn modify_delete_cycle_survives_restore() {
#[test]
fn clear_all_can_be_undone_after_restore() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-clear-all");
options.persist_transparent = true;
options.persist_history = true;
@@ -199,7 +199,7 @@ fn clear_all_can_be_undone_after_restore() {
#[test]
fn corrupted_history_is_dropped_but_shapes_load() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-corrupt");
options.persist_transparent = true;
options.persist_history = true;
+7 -7
View File
@@ -8,7 +8,7 @@ use std::path::Path;
#[test]
fn save_snapshot_errors_when_payload_exceeds_max_file_size() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-too-big");
options.persist_transparent = false;
options.persist_whiteboard = false;
@@ -63,7 +63,7 @@ fn save_snapshot_errors_when_payload_exceeds_max_file_size() {
#[test]
fn load_snapshot_refuses_file_larger_than_max() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-large-file");
options.persist_transparent = true;
options.max_file_size_bytes = 8; // very small
@@ -85,7 +85,7 @@ fn load_snapshot_refuses_file_larger_than_max() {
#[test]
fn load_snapshot_truncates_shapes_when_exceeding_max_shapes_per_frame() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut save_options = SessionOptions::new(temp.path().to_path_buf(), "display-shape-limit");
save_options.persist_transparent = true;
@@ -140,7 +140,7 @@ fn save_snapshot_allows_compressed_payload_that_fits_limit() {
const ACTIVE_PAGE: usize = 10;
const IMAGE_BYTES: usize = 64 * 1024;
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-compressed-fit");
options.persist_whiteboard = true;
options.persist_history = true;
@@ -225,7 +225,7 @@ fn save_snapshot_allows_compressed_payload_that_fits_limit() {
#[test]
fn save_snapshot_drops_history_when_modified_stroke_exceeds_limit() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut input = dummy_input_state();
let point_count = 1_500;
@@ -324,7 +324,7 @@ fn save_snapshot_drops_history_when_modified_stroke_exceeds_limit() {
#[test]
fn save_snapshot_keeps_largest_recent_history_depth_that_fits() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut input = dummy_input_state();
let point_count = 600;
@@ -411,7 +411,7 @@ fn save_snapshot_keeps_largest_recent_history_depth_that_fits() {
#[test]
fn save_snapshot_keeps_depth_one_when_visible_payload_is_near_limit() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut input = dummy_input_state();
{
let frame = input.boards.active_frame_mut();
+2 -2
View File
@@ -3,7 +3,7 @@ use crate::config::{SessionConfig, SessionStorageMode};
#[test]
fn options_from_config_custom_storage() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let custom_dir = temp.path().join("sessions");
let cfg = SessionConfig {
@@ -29,7 +29,7 @@ fn options_from_config_custom_storage() {
#[test]
fn options_from_config_config_storage_uses_config_dir() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let cfg = SessionConfig {
persist_whiteboard: true,
+5 -5
View File
@@ -7,7 +7,7 @@ use std::fs;
#[test]
fn session_roundtrip_preserves_shapes_across_frames() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-2");
options.persist_transparent = true;
options.persist_whiteboard = true;
@@ -87,7 +87,7 @@ fn session_roundtrip_preserves_pages_beyond_visible_shortcuts() {
const PAGE_COUNT: usize = 12;
const ACTIVE_PAGE: usize = 10;
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-many-pages");
options.persist_whiteboard = true;
@@ -140,7 +140,7 @@ fn session_roundtrip_preserves_pages_beyond_visible_shortcuts() {
#[test]
fn save_snapshot_rotates_backup_when_enabled() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-backup");
options.persist_transparent = true;
options.backup_retention = 1;
@@ -186,7 +186,7 @@ fn save_snapshot_rotates_backup_when_enabled() {
#[test]
fn save_snapshot_skips_backup_when_disabled() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-no-backup");
options.persist_transparent = true;
options.backup_retention = 0;
@@ -229,7 +229,7 @@ fn save_snapshot_skips_backup_when_disabled() {
#[test]
fn corrupt_session_is_backed_up_and_reset() {
let temp = tempfile::tempdir().unwrap();
let temp = crate::test_temp::tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "display-bad");
options.persist_transparent = true;
+45
View File
@@ -0,0 +1,45 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{fs, io};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
pub(crate) struct TempDir {
path: PathBuf,
}
impl TempDir {
pub(crate) fn new() -> io::Result<Self> {
tempdir()
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
pub(crate) fn tempdir() -> io::Result<TempDir> {
let base = std::env::temp_dir();
let pid = std::process::id();
for _ in 0..100 {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = base.join(format!("wayscriber-test-{pid}-{id}"));
match fs::create_dir(&path) {
Ok(()) => return Ok(TempDir { path }),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"failed to create a unique temporary test directory",
))
}
+1 -1
View File
@@ -212,7 +212,7 @@ mod tests {
let _guard = ENV_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let tmp = tempfile::tempdir().unwrap();
let tmp = crate::test_temp::tempdir().unwrap();
let prev = env::var_os("XDG_RUNTIME_DIR");
unsafe {
env::set_var("XDG_RUNTIME_DIR", tmp.path());
+555
View File
@@ -0,0 +1,555 @@
use std::io;
use std::os::fd::RawFd;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
const MAX_SIGNAL_SLOTS: usize = 8;
static SIGNAL_WRITE_FD: AtomicI32 = AtomicI32::new(-1);
static LISTENER_ACTIVE: AtomicBool = AtomicBool::new(false);
static REGISTERED_SIGNALS: [AtomicI32; MAX_SIGNAL_SLOTS] =
[const { AtomicI32::new(0) }; MAX_SIGNAL_SLOTS];
static PENDING_SIGNALS: [AtomicUsize; MAX_SIGNAL_SLOTS] =
[const { AtomicUsize::new(0) }; MAX_SIGNAL_SLOTS];
pub(crate) fn spawn_listener<F>(signals: &[libc::c_int], on_signal: F) -> io::Result<JoinHandle<()>>
where
F: Fn(libc::c_int) + Send + 'static,
{
if signals.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"at least one signal must be registered",
));
}
// This lightweight signal bridge intentionally owns the process handlers for
// the requested signals rather than chaining prior handlers. Keep a single
// listener per process so signal delivery stays deterministic.
if LISTENER_ACTIVE.swap(true, Ordering::AcqRel) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"a signal listener is already active",
));
}
if let Err(err) = register_signals(signals) {
LISTENER_ACTIVE.store(false, Ordering::Release);
return Err(err);
}
let (read_fd, write_fd) = match create_pipe() {
Ok(pipe) => pipe,
Err(err) => {
clear_registered_signals();
LISTENER_ACTIVE.store(false, Ordering::Release);
return Err(err);
}
};
SIGNAL_WRITE_FD.store(write_fd, Ordering::Release);
let mut installed_handlers = Vec::with_capacity(signals.len());
for &signal in signals {
match install_handler(signal) {
Ok(previous) => installed_handlers.push((signal, previous)),
Err(err) => {
restore_handlers(&installed_handlers);
SIGNAL_WRITE_FD.store(-1, Ordering::Release);
clear_registered_signals();
close_fd(read_fd);
close_fd(write_fd);
LISTENER_ACTIVE.store(false, Ordering::Release);
return Err(err);
}
}
}
Ok(thread::spawn(move || read_signal_loop(read_fd, on_signal)))
}
fn restore_handlers(handlers: &[(libc::c_int, libc::sigaction)]) {
for (signal, previous) in handlers.iter().rev() {
// SAFETY: `previous` was returned by `sigaction` for the same signal.
let _ = unsafe { libc::sigaction(*signal, previous, std::ptr::null_mut()) };
}
}
fn register_signals(signals: &[libc::c_int]) -> io::Result<()> {
if signals.len() > MAX_SIGNAL_SLOTS {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"too many signals registered",
));
}
clear_registered_signals();
for (index, &signal) in signals.iter().enumerate() {
if signal <= 0 {
clear_registered_signals();
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"signal numbers must be positive",
));
}
if signals[..index].contains(&signal) {
clear_registered_signals();
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"duplicate signals are not supported",
));
}
REGISTERED_SIGNALS[index].store(signal, Ordering::Release);
}
Ok(())
}
fn clear_registered_signals() {
for index in 0..MAX_SIGNAL_SLOTS {
PENDING_SIGNALS[index].store(0, Ordering::Release);
REGISTERED_SIGNALS[index].store(0, Ordering::Release);
}
}
fn create_pipe() -> io::Result<(RawFd, RawFd)> {
let mut fds = [-1; 2];
// SAFETY: `fds` points to two valid c_int slots for libc to fill.
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return Err(io::Error::last_os_error());
}
if let Err(err) = configure_pipe(fds[0], fds[1]) {
close_fd(fds[0]);
close_fd(fds[1]);
return Err(err);
}
Ok((fds[0], fds[1]))
}
fn configure_pipe(read_fd: RawFd, write_fd: RawFd) -> io::Result<()> {
set_fd_flag(read_fd, libc::FD_CLOEXEC)?;
set_fd_flag(write_fd, libc::FD_CLOEXEC)?;
set_status_flag(write_fd, libc::O_NONBLOCK)
}
fn set_fd_flag(fd: RawFd, flag: libc::c_int) -> io::Result<()> {
// SAFETY: `fd` is an open file descriptor owned by this module.
let current = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if current < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `fd` is valid and `current | flag` is a valid F_SETFD bitset.
if unsafe { libc::fcntl(fd, libc::F_SETFD, current | flag) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn set_status_flag(fd: RawFd, flag: libc::c_int) -> io::Result<()> {
// SAFETY: `fd` is an open file descriptor owned by this module.
let current = unsafe { libc::fcntl(fd, libc::F_GETFL) };
if current < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `fd` is valid and `current | flag` is a valid F_SETFL bitset.
if unsafe { libc::fcntl(fd, libc::F_SETFL, current | flag) } < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn install_handler(signal: libc::c_int) -> io::Result<libc::sigaction> {
// SAFETY: Zeroed sigaction is immediately initialized before use.
let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
action.sa_sigaction = signal_handler as *const () as usize;
action.sa_flags = signal_action_flags();
// SAFETY: `action.sa_mask` points to a valid sigset_t field.
if unsafe { libc::sigemptyset(&mut action.sa_mask) } != 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: Zeroed sigaction is filled by libc when installation succeeds.
let mut previous = unsafe { std::mem::zeroed::<libc::sigaction>() };
// SAFETY: Installs a process-wide handler for the requested signal.
if unsafe { libc::sigaction(signal, &action, &mut previous) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(previous)
}
fn signal_action_flags() -> libc::c_int {
libc::SA_RESTART
}
extern "C" fn signal_handler(signal: libc::c_int) {
let _errno_guard = ErrnoGuard::new();
if !mark_pending(signal) {
return;
}
let fd = SIGNAL_WRITE_FD.load(Ordering::Acquire);
if fd < 0 {
return;
}
let wakeup = 1u8;
// SAFETY: `fd` is a nonblocking pipe write end. `write` is async-signal-safe.
// The pipe is only a wakeup channel; pending signal counters above preserve
// signal state if this best-effort write fails with EAGAIN.
let _ = unsafe {
libc::write(
fd,
(&wakeup as *const u8).cast::<libc::c_void>(),
std::mem::size_of::<u8>(),
)
};
}
struct ErrnoGuard {
location: *mut libc::c_int,
saved: libc::c_int,
}
impl ErrnoGuard {
fn new() -> Self {
let location = errno_location();
let saved = if location.is_null() {
0
} else {
// SAFETY: `location` points to the current thread's errno slot.
unsafe { *location }
};
Self { location, saved }
}
}
impl Drop for ErrnoGuard {
fn drop(&mut self) {
if !self.location.is_null() {
// SAFETY: `location` points to the current thread's errno slot.
unsafe {
*self.location = self.saved;
}
}
}
}
#[cfg(any(
target_os = "emscripten",
target_os = "hurd",
target_os = "linux",
target_os = "redox"
))]
fn errno_location() -> *mut libc::c_int {
// SAFETY: Returns the current thread's errno slot on these libc targets.
unsafe { libc::__errno_location() }
}
#[cfg(any(target_os = "android", target_os = "cygwin", target_os = "netbsd"))]
fn errno_location() -> *mut libc::c_int {
// SAFETY: Returns the current thread's errno slot on these libc targets.
unsafe { libc::__errno() }
}
#[cfg(any(
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "tvos",
target_os = "visionos",
target_os = "watchos"
))]
fn errno_location() -> *mut libc::c_int {
// SAFETY: Returns the current thread's errno slot on these libc targets.
unsafe { libc::__error() }
}
#[cfg(target_os = "dragonfly")]
fn errno_location() -> *mut libc::c_int {
// SAFETY: Returns the current thread's errno slot on DragonFly BSD.
unsafe { libc::__errno_location() }
}
#[cfg(any(target_os = "illumos", target_os = "solaris"))]
fn errno_location() -> *mut libc::c_int {
// SAFETY: Returns the current thread's errno slot on Solaris-family targets.
unsafe { libc::___errno() }
}
#[cfg(not(any(
target_os = "android",
target_os = "cygwin",
target_os = "dragonfly",
target_os = "emscripten",
target_os = "freebsd",
target_os = "hurd",
target_os = "illumos",
target_os = "ios",
target_os = "linux",
target_os = "macos",
target_os = "netbsd",
target_os = "redox",
target_os = "solaris",
target_os = "tvos",
target_os = "visionos",
target_os = "watchos"
)))]
fn errno_location() -> *mut libc::c_int {
std::ptr::null_mut()
}
fn mark_pending(signal: libc::c_int) -> bool {
for index in 0..MAX_SIGNAL_SLOTS {
if REGISTERED_SIGNALS[index].load(Ordering::Acquire) == signal {
PENDING_SIGNALS[index].fetch_add(1, Ordering::Release);
return true;
}
}
false
}
fn dispatch_pending_signals<F>(on_signal: &F)
where
F: Fn(libc::c_int),
{
for index in 0..MAX_SIGNAL_SLOTS {
let signal = REGISTERED_SIGNALS[index].load(Ordering::Acquire);
if signal <= 0 {
continue;
}
let pending = PENDING_SIGNALS[index].swap(0, Ordering::AcqRel);
for _ in 0..pending {
on_signal(signal);
}
}
}
fn read_signal_loop<F>(read_fd: RawFd, on_signal: F)
where
F: Fn(libc::c_int),
{
loop {
match read_wakeup(read_fd) {
Ok(true) => dispatch_pending_signals(&on_signal),
Ok(false) => break,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => {
log::warn!("Signal listener stopped: {}", err);
break;
}
}
}
close_fd(read_fd);
}
fn read_wakeup(read_fd: RawFd) -> io::Result<bool> {
let mut wakeup = 0u8;
loop {
// SAFETY: `wakeup` points to one writable byte, and `read_fd` is the
// blocking pipe read end owned by the listener thread.
let count = unsafe {
libc::read(
read_fd,
(&mut wakeup as *mut u8).cast::<libc::c_void>(),
std::mem::size_of::<u8>(),
)
};
if count == 0 {
return Ok(false);
}
if count == 1 {
return Ok(true);
}
if count < 0 {
return Err(io::Error::last_os_error());
}
}
}
fn close_fd(fd: RawFd) {
if fd >= 0 {
// SAFETY: Closing an owned file descriptor; errors are intentionally ignored.
let _ = unsafe { libc::close(fd) };
}
}
#[cfg(test)]
mod tests {
use super::{
PENDING_SIGNALS, SIGNAL_WRITE_FD, clear_registered_signals, close_fd,
dispatch_pending_signals, errno_location, register_signals, signal_action_flags,
signal_handler,
};
use std::cell::RefCell;
use std::io;
use std::sync::{Mutex, atomic::Ordering};
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn signal_handlers_restart_interrupted_syscalls() {
let _guard = TEST_LOCK.lock().unwrap();
assert_ne!(signal_action_flags() & libc::SA_RESTART, 0);
}
#[cfg(any(
target_os = "android",
target_os = "cygwin",
target_os = "dragonfly",
target_os = "emscripten",
target_os = "freebsd",
target_os = "hurd",
target_os = "illumos",
target_os = "ios",
target_os = "linux",
target_os = "macos",
target_os = "netbsd",
target_os = "redox",
target_os = "solaris",
target_os = "tvos",
target_os = "visionos",
target_os = "watchos"
))]
#[test]
fn signal_handler_preserves_errno() {
let _guard = TEST_LOCK.lock().unwrap();
clear_registered_signals();
register_signals(&[libc::SIGTERM]).unwrap();
SIGNAL_WRITE_FD.store(i32::MAX, Ordering::Release);
set_errno(libc::E2BIG);
signal_handler(libc::SIGTERM);
assert_eq!(current_errno(), libc::E2BIG);
SIGNAL_WRITE_FD.store(-1, Ordering::Release);
clear_registered_signals();
}
#[test]
fn spawn_listener_restores_installed_handlers_after_partial_failure() {
let _guard = TEST_LOCK.lock().unwrap();
let signal = libc::SIGWINCH;
let before = current_sigaction(signal).unwrap();
let err = super::spawn_listener(&[signal, i32::MAX], |_| {}).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
let after = current_sigaction(signal).unwrap();
assert_eq!(after.sa_sigaction, before.sa_sigaction);
clear_registered_signals();
SIGNAL_WRITE_FD.store(-1, Ordering::Release);
}
#[test]
fn pending_signals_are_preserved_when_wakeup_write_is_unavailable() {
let _guard = TEST_LOCK.lock().unwrap();
clear_registered_signals();
register_signals(&[libc::SIGTERM]).unwrap();
SIGNAL_WRITE_FD.store(-1, Ordering::Release);
signal_handler(libc::SIGTERM);
let dispatched = RefCell::new(Vec::new());
dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal));
assert_eq!(dispatched.into_inner(), vec![libc::SIGTERM]);
assert_eq!(PENDING_SIGNALS[0].load(Ordering::Acquire), 0);
clear_registered_signals();
}
#[test]
fn pending_signal_counters_preserve_repeated_delivery() {
let _guard = TEST_LOCK.lock().unwrap();
clear_registered_signals();
register_signals(&[libc::SIGUSR1]).unwrap();
SIGNAL_WRITE_FD.store(-1, Ordering::Release);
signal_handler(libc::SIGUSR1);
signal_handler(libc::SIGUSR1);
let dispatched = RefCell::new(Vec::new());
dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal));
assert_eq!(dispatched.into_inner(), vec![libc::SIGUSR1, libc::SIGUSR1]);
clear_registered_signals();
}
#[test]
fn pending_state_survives_full_self_pipe() {
let _guard = TEST_LOCK.lock().unwrap();
clear_registered_signals();
register_signals(&[libc::SIGTERM]).unwrap();
let (read_fd, write_fd) = super::create_pipe().unwrap();
SIGNAL_WRITE_FD.store(write_fd, Ordering::Release);
let wakeup = 1u8;
loop {
// SAFETY: `write_fd` is a nonblocking pipe write end and `wakeup`
// points to one readable byte.
let count = unsafe {
libc::write(
write_fd,
(&wakeup as *const u8).cast::<libc::c_void>(),
std::mem::size_of::<u8>(),
)
};
if count == 1 {
continue;
}
assert!(count < 0);
let err = std::io::Error::last_os_error();
assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock);
break;
}
signal_handler(libc::SIGTERM);
let dispatched = RefCell::new(Vec::new());
dispatch_pending_signals(&|signal| dispatched.borrow_mut().push(signal));
assert_eq!(dispatched.into_inner(), vec![libc::SIGTERM]);
SIGNAL_WRITE_FD.store(-1, Ordering::Release);
clear_registered_signals();
close_fd(read_fd);
close_fd(write_fd);
}
fn current_errno() -> libc::c_int {
let location = errno_location();
assert!(!location.is_null());
// SAFETY: `location` points to the current thread's errno slot.
unsafe { *location }
}
fn set_errno(value: libc::c_int) {
let location = errno_location();
assert!(!location.is_null());
// SAFETY: `location` points to the current thread's errno slot.
unsafe {
*location = value;
}
}
fn current_sigaction(signal: libc::c_int) -> io::Result<libc::sigaction> {
// SAFETY: Zeroed sigaction is filled by libc when the query succeeds.
let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
// SAFETY: Null new action queries the current handler for `signal`.
if unsafe { libc::sigaction(signal, std::ptr::null(), &mut action) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(action)
}
}
+10
View File
@@ -0,0 +1,10 @@
use std::{future::poll_fn, pin::Pin};
use zbus::export::futures_core::Stream;
pub(crate) async fn next<S>(stream: &mut S) -> Option<S::Item>
where
S: Stream + Unpin,
{
poll_fn(|cx| Pin::new(&mut *stream).poll_next(cx)).await
}
+161 -55
View File
@@ -1,10 +1,124 @@
use assert_cmd::{Command, cargo::cargo_bin_cmd};
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use wayscriber::runtime_capabilities::RUNTIME_CAPABILITIES_FLAG;
fn write_session_config(temp: &TempDir, custom_dir: &std::path::Path) {
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> std::io::Result<Self> {
let base = std::env::temp_dir();
let pid = std::process::id();
for _ in 0..100 {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = base.join(format!("wayscriber-cli-test-{pid}-{id}"));
match fs::create_dir(&path) {
Ok(()) => return Ok(Self { path }),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"failed to create a unique temporary test directory",
))
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
struct CommandOutput {
output: Output,
}
impl CommandOutput {
fn success(self) -> Self {
assert!(
self.output.status.success(),
"expected success\nstdout:\n{}\nstderr:\n{}",
self.stdout_text(),
self.stderr_text()
);
self
}
fn failure(self) -> Self {
assert!(
!self.output.status.success(),
"expected failure\nstdout:\n{}\nstderr:\n{}",
self.stdout_text(),
self.stderr_text()
);
self
}
fn stdout_contains(self, needle: &str) -> Self {
let stdout = self.stdout_text();
assert!(
stdout.contains(needle),
"stdout did not contain {needle:?}\nstdout:\n{stdout}\nstderr:\n{}",
self.stderr_text()
);
self
}
fn stdout_starts_with(self, prefix: &str) -> Self {
let stdout = self.stdout_text();
assert!(
stdout.starts_with(prefix),
"stdout did not start with {prefix:?}\nstdout:\n{stdout}\nstderr:\n{}",
self.stderr_text()
);
self
}
fn stdout_eq(self, expected: &str) -> Self {
let stdout = self.stdout_text();
assert_eq!(stdout, expected, "stderr:\n{}", self.stderr_text());
self
}
fn stderr_contains(self, needle: &str) -> Self {
let stderr = self.stderr_text();
assert!(
stderr.contains(needle),
"stderr did not contain {needle:?}\nstdout:\n{}\nstderr:\n{stderr}",
self.stdout_text()
);
self
}
fn stdout_text(&self) -> String {
String::from_utf8_lossy(&self.output.stdout).into_owned()
}
fn stderr_text(&self) -> String {
String::from_utf8_lossy(&self.output.stderr).into_owned()
}
}
fn run_command(command: &mut Command) -> CommandOutput {
CommandOutput {
output: command.output().expect("run wayscriber command"),
}
}
fn write_session_config(temp: &TempDir, custom_dir: &Path) {
let config_dir = temp.path().join("wayscriber");
fs::create_dir_all(&config_dir).unwrap();
let config_contents = format!(
@@ -28,35 +142,27 @@ backup_retention = 1
}
fn wayscriber_cmd() -> Command {
cargo_bin_cmd!("wayscriber")
Command::new(env!("CARGO_BIN_EXE_wayscriber"))
}
#[test]
fn wayscriber_help_prints_usage() {
wayscriber_cmd()
.arg("--help")
.assert()
run_command(wayscriber_cmd().arg("--help"))
.success()
.stdout(predicate::str::contains(
"Screen annotation tool for Wayland compositors",
))
.stdout(predicate::str::contains(
"--light-toggle Toggle light passthrough mode",
))
.stdout(predicate::str::contains("--light-draw-toggle"))
.stdout(predicate::str::contains("--light-draw-on"))
.stdout(predicate::str::contains("--light-draw-off"));
.stdout_contains("Screen annotation tool for Wayland compositors")
.stdout_contains("--light-toggle Toggle light passthrough mode")
.stdout_contains("--light-draw-toggle")
.stdout_contains("--light-draw-on")
.stdout_contains("--light-draw-off");
}
#[test]
fn wayscriber_version_prints_binary_name() {
for arg in ["--version", "-V"] {
wayscriber_cmd()
.arg(arg)
.assert()
run_command(wayscriber_cmd().arg(arg))
.success()
.stdout(predicate::str::starts_with("wayscriber "))
.stdout(predicate::str::contains(wayscriber::build_info::version()));
.stdout_starts_with("wayscriber ")
.stdout_contains(wayscriber::build_info::version());
}
}
@@ -66,30 +172,28 @@ fn wayscriber_runtime_capabilities_reports_portal_feature() {
"portal={}\n",
wayscriber::shortcut_hint::portal_runtime_supported()
);
wayscriber_cmd()
.arg(RUNTIME_CAPABILITIES_FLAG)
.assert()
run_command(wayscriber_cmd().arg(RUNTIME_CAPABILITIES_FLAG))
.success()
.stdout(predicate::eq(expected));
.stdout_eq(&expected);
}
#[test]
fn bare_usage_mentions_freeze_on_show() {
wayscriber_cmd()
.assert()
run_command(&mut wayscriber_cmd())
.success()
.stdout(predicate::str::contains("--freeze-on-show"))
.stdout(predicate::str::contains("--daemon-toggle"));
.stdout_contains("--freeze-on-show")
.stdout_contains("--daemon-toggle");
}
#[test]
fn active_mode_requires_wayland_env() {
wayscriber_cmd()
.env_remove("WAYLAND_DISPLAY")
.arg("--active")
.assert()
.failure()
.stderr(predicate::str::contains("WAYLAND_DISPLAY not set"));
run_command(
wayscriber_cmd()
.env_remove("WAYLAND_DISPLAY")
.arg("--active"),
)
.failure()
.stderr_contains("WAYLAND_DISPLAY not set");
}
#[test]
@@ -98,14 +202,15 @@ fn session_clear_command_succeeds_without_files() {
let session_dir = temp.path().join("sessions");
write_session_config(&temp, &session_dir);
wayscriber_cmd()
.env("XDG_CONFIG_HOME", temp.path())
.env_remove("WAYLAND_DISPLAY")
.arg("--clear-session")
.assert()
.success()
.stdout(predicate::str::contains("Session file:"))
.stdout(predicate::str::contains("No session file present"));
run_command(
wayscriber_cmd()
.env("XDG_CONFIG_HOME", temp.path())
.env_remove("WAYLAND_DISPLAY")
.arg("--clear-session"),
)
.success()
.stdout_contains("Session file:")
.stdout_contains("No session file present");
}
#[test]
@@ -174,15 +279,16 @@ fn session_info_reports_saved_snapshot() {
wayscriber::session::save_snapshot(&snapshot, &options).unwrap();
wayscriber_cmd()
.env("XDG_CONFIG_HOME", temp.path())
.env("WAYLAND_DISPLAY", display)
.arg("--session-info")
.assert()
.success()
.stdout(predicate::str::contains("Per-output persistence: true"))
.stdout(predicate::str::contains("Session file :"))
.stdout(predicate::str::contains("Output identity: DP_1"))
.stdout(predicate::str::contains("transparent 1"))
.stdout(predicate::str::contains("Tool state stored: false"));
run_command(
wayscriber_cmd()
.env("XDG_CONFIG_HOME", temp.path())
.env("WAYLAND_DISPLAY", display)
.arg("--session-info"),
)
.success()
.stdout_contains("Per-output persistence: true")
.stdout_contains("Session file :")
.stdout_contains("Output identity: DP_1")
.stdout_contains("transparent 1")
.stdout_contains("Tool state stored: false");
}