From 2df7f7e5f5fc29d68818416327603a33a328cf57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 13:56:49 +0000 Subject: [PATCH 1/5] Initial plan From a1fcaf5c2aa83e3fe7bb68ff73d1b736a3c4e4a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:01:14 +0000 Subject: [PATCH 2/5] Avoid blocking betterdisplaycli resolution at startup --- src/app.rs | 5 +- src/utils.rs | 133 ++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 105 insertions(+), 33 deletions(-) diff --git a/src/app.rs b/src/app.rs index 68251ed..e8ab08a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::utils::{ResolvedConfig, get_betterdisplay_path, load_config, setup_logger}; +use crate::utils::{ResolvedConfig, load_config, setup_logger}; use log::{debug, error, info}; use std::panic; @@ -20,9 +20,6 @@ impl App { info!("betterdisplay-kvm starting..."); - let betterdisplay_path = get_betterdisplay_path(); - debug!("Found betterdisplaycli at: {:?}", betterdisplay_path); - // Set up panic hook to capture panics and log them Self::setup_panic_hook(); diff --git a/src/utils.rs b/src/utils.rs index abc817c..f979893 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -11,7 +11,8 @@ use std::{ io::{self, IsTerminal}, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, - process::{self, Command, Output}, + process::Command, + process::{self, Output}, }; pub const DEFAULT_DEVICE_ID: &str = "046d:c547"; @@ -55,44 +56,45 @@ pub struct ResolvedConfig { pub ddc_alt: bool, } -pub fn get_betterdisplay_path() -> PathBuf { - if let Ok(override_path) = std::env::var("BETTERDISPLAYCLI_PATH") { - let p = PathBuf::from(override_path); - if p.exists() { - return p; - } - } - - let common_candidates = [ - "/opt/homebrew/bin/betterdisplaycli", - "/usr/local/bin/betterdisplaycli", - "/usr/bin/betterdisplaycli", - "/bin/betterdisplaycli", - ]; - for candidate in common_candidates { - let p = Path::new(candidate); - if p.exists() { - return p.to_path_buf(); +fn resolve_betterdisplay_path( + override_path: Option, + candidates: &[PathBuf], +) -> anyhow::Result { + if let Some(path) = override_path { + if path.is_file() { + return Ok(path); } + return Err(anyhow::anyhow!( + "BETTERDISPLAYCLI_PATH is set but does not point to a file: {}", + path.display() + )); } - if let Some(path_var) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&path_var) { - let p = dir.join("betterdisplaycli"); - if p.exists() { - return p; - } + for candidate in candidates { + if candidate.is_file() { + return Ok(candidate.clone()); } } - error!( + Err(anyhow::anyhow!( "Could not locate 'betterdisplaycli'. Set BETTERDISPLAYCLI_PATH or install to /opt/homebrew/bin or /usr/local/bin." - ); - process::exit(1); + )) +} + +pub fn get_betterdisplay_path() -> anyhow::Result { + let override_path = std::env::var_os("BETTERDISPLAYCLI_PATH").map(PathBuf::from); + let common_candidates = [ + Path::new("/opt/homebrew/bin/betterdisplaycli").to_path_buf(), + Path::new("/usr/local/bin/betterdisplaycli").to_path_buf(), + Path::new("/usr/bin/betterdisplaycli").to_path_buf(), + Path::new("/bin/betterdisplaycli").to_path_buf(), + ]; + + resolve_betterdisplay_path(override_path, &common_candidates) } pub fn set_input(input_code: u16, use_ddc_alt: bool) -> anyhow::Result<()> { - let betterdisplay_path = get_betterdisplay_path(); + let betterdisplay_path = get_betterdisplay_path()?; // TODO: figure out how to make this path dynamic or configurable let mut cmd = Command::new(betterdisplay_path); @@ -126,6 +128,79 @@ pub fn set_input(input_code: u16, use_ddc_alt: bool) -> anyhow::Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::resolve_betterdisplay_path; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn unique_temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "betterdisplay-kvm-test-{}-{}", + std::process::id(), + nanos + )) + } + + #[test] + fn resolve_path_prefers_valid_override() { + let dir = unique_temp_dir(); + fs::create_dir_all(&dir).unwrap(); + let override_path = dir.join("override-betterdisplaycli"); + let candidate_path = dir.join("candidate-betterdisplaycli"); + fs::write(&override_path, "").unwrap(); + fs::write(&candidate_path, "").unwrap(); + + let resolved = resolve_betterdisplay_path( + Some(override_path.clone()), + std::slice::from_ref(&candidate_path), + ) + .unwrap(); + assert_eq!(resolved, override_path); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn resolve_path_errors_for_invalid_override() { + let dir = unique_temp_dir(); + fs::create_dir_all(&dir).unwrap(); + let missing_override = dir.join("missing-betterdisplaycli"); + let candidate_path = dir.join("candidate-betterdisplaycli"); + fs::write(&candidate_path, "").unwrap(); + + let err = resolve_betterdisplay_path( + Some(missing_override), + std::slice::from_ref(&candidate_path), + ) + .unwrap_err(); + assert!(err.to_string().contains("BETTERDISPLAYCLI_PATH")); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn resolve_path_uses_first_existing_candidate() { + let dir = unique_temp_dir(); + fs::create_dir_all(&dir).unwrap(); + let missing = dir.join("missing-betterdisplaycli"); + let existing = dir.join("existing-betterdisplaycli"); + fs::write(&existing, "").unwrap(); + + let resolved = resolve_betterdisplay_path(None, &[missing, existing.clone()]).unwrap(); + assert_eq!(resolved, existing); + + fs::remove_dir_all(&dir).unwrap(); + } +} + pub fn on_connect(cfg: &ResolvedConfig) { info!("switch input to the system_one_input"); if let Err(e) = set_input(cfg.system_one_input, cfg.ddc_alt) { From 2ccabbceb869c69f66b2107e410cce62f5c2a52b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 15:36:41 +0000 Subject: [PATCH 3/5] Remove path-resolution tests per PR feedback --- src/utils.rs | 73 ---------------------------------------------------- 1 file changed, 73 deletions(-) diff --git a/src/utils.rs b/src/utils.rs index f979893..4300c5c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -128,79 +128,6 @@ pub fn set_input(input_code: u16, use_ddc_alt: bool) -> anyhow::Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::resolve_betterdisplay_path; - use std::{ - fs, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, - }; - - fn unique_temp_dir() -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!( - "betterdisplay-kvm-test-{}-{}", - std::process::id(), - nanos - )) - } - - #[test] - fn resolve_path_prefers_valid_override() { - let dir = unique_temp_dir(); - fs::create_dir_all(&dir).unwrap(); - let override_path = dir.join("override-betterdisplaycli"); - let candidate_path = dir.join("candidate-betterdisplaycli"); - fs::write(&override_path, "").unwrap(); - fs::write(&candidate_path, "").unwrap(); - - let resolved = resolve_betterdisplay_path( - Some(override_path.clone()), - std::slice::from_ref(&candidate_path), - ) - .unwrap(); - assert_eq!(resolved, override_path); - - fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn resolve_path_errors_for_invalid_override() { - let dir = unique_temp_dir(); - fs::create_dir_all(&dir).unwrap(); - let missing_override = dir.join("missing-betterdisplaycli"); - let candidate_path = dir.join("candidate-betterdisplaycli"); - fs::write(&candidate_path, "").unwrap(); - - let err = resolve_betterdisplay_path( - Some(missing_override), - std::slice::from_ref(&candidate_path), - ) - .unwrap_err(); - assert!(err.to_string().contains("BETTERDISPLAYCLI_PATH")); - - fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn resolve_path_uses_first_existing_candidate() { - let dir = unique_temp_dir(); - fs::create_dir_all(&dir).unwrap(); - let missing = dir.join("missing-betterdisplaycli"); - let existing = dir.join("existing-betterdisplaycli"); - fs::write(&existing, "").unwrap(); - - let resolved = resolve_betterdisplay_path(None, &[missing, existing.clone()]).unwrap(); - assert_eq!(resolved, existing); - - fs::remove_dir_all(&dir).unwrap(); - } -} - pub fn on_connect(cfg: &ResolvedConfig) { info!("switch input to the system_one_input"); if let Err(e) = set_input(cfg.system_one_input, cfg.ddc_alt) { From c4d3a05c23c442dcf4deda9eb3d3a76ca3201bec Mon Sep 17 00:00:00 2001 From: Sean Boult <996134+Hacksore@users.noreply.github.com> Date: Sat, 30 May 2026 09:54:22 -0500 Subject: [PATCH 4/5] Readme update --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index b0335e6..c2230ab 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,22 @@ Restart the agent after changing config: launchctl kickstart -k "gui/$(id -u)/com.github.hacksore.betterdisplay-kvm" ``` +Stop the LaunchAgent when you want to run the daemon manually: + +```bash +launchctl bootout "gui/$(id -u)/com.github.hacksore.betterdisplay-kvm" +betterdisplay-kvm --status +cargo run -- --launch +``` + +Start the LaunchAgent again when you are done: + +```bash +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.github.hacksore.betterdisplay-kvm.plist" +launchctl enable "gui/$(id -u)/com.github.hacksore.betterdisplay-kvm" +launchctl kickstart -k "gui/$(id -u)/com.github.hacksore.betterdisplay-kvm" +``` + The generated plist lives at: ```text From 52688b1646bcb7344916ccc297c2b506708170d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 15:04:13 +0000 Subject: [PATCH 5/5] Cache BetterDisplay CLI path resolution --- src/app.rs | 3 ++- src/utils.rs | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index e8ab08a..c28a870 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::utils::{ResolvedConfig, load_config, setup_logger}; +use crate::utils::{ResolvedConfig, load_config, prime_betterdisplay_path_cache, setup_logger}; use log::{debug, error, info}; use std::panic; @@ -24,6 +24,7 @@ impl App { Self::setup_panic_hook(); debug!("Starting betterdisplay-kvm with config: {:?}", config); + prime_betterdisplay_path_cache(); Ok(Self { config }) } diff --git a/src/utils.rs b/src/utils.rs index 4300c5c..38bd308 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,11 +13,13 @@ use std::{ path::{Path, PathBuf}, process::Command, process::{self, Output}, + sync::OnceLock, }; pub const DEFAULT_DEVICE_ID: &str = "046d:c547"; pub const LAUNCH_AGENT_LABEL: &str = "com.github.hacksore.betterdisplay-kvm"; const BIN_NAME: &str = "betterdisplay-kvm"; +static BETTERDISPLAY_PATH_CACHE: OnceLock> = OnceLock::new(); #[derive(Debug, Serialize, Deserialize)] pub struct AppConfig { @@ -81,7 +83,7 @@ fn resolve_betterdisplay_path( )) } -pub fn get_betterdisplay_path() -> anyhow::Result { +fn detect_betterdisplay_path() -> anyhow::Result { let override_path = std::env::var_os("BETTERDISPLAYCLI_PATH").map(PathBuf::from); let common_candidates = [ Path::new("/opt/homebrew/bin/betterdisplaycli").to_path_buf(), @@ -93,6 +95,21 @@ pub fn get_betterdisplay_path() -> anyhow::Result { resolve_betterdisplay_path(override_path, &common_candidates) } +pub fn get_betterdisplay_path() -> anyhow::Result { + match BETTERDISPLAY_PATH_CACHE + .get_or_init(|| detect_betterdisplay_path().map_err(|err| err.to_string())) + { + Ok(path) => Ok(path.clone()), + Err(err) => Err(anyhow::anyhow!("{}", err)), + } +} + +pub fn prime_betterdisplay_path_cache() { + if let Err(err) = get_betterdisplay_path() { + debug!("betterdisplaycli path preflight failed: {}", err); + } +} + pub fn set_input(input_code: u16, use_ddc_alt: bool) -> anyhow::Result<()> { let betterdisplay_path = get_betterdisplay_path()?;