mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-06-03 03:54:42 +02:00
refactor: trim direct dependencies
This commit is contained in:
Generated
-180
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod app;
|
||||
mod messages;
|
||||
mod models;
|
||||
#[cfg(test)]
|
||||
mod test_temp;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
app::run()
|
||||
|
||||
@@ -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",
|
||||
))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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()))?;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 ¤t == 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
@@ -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 {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user