From 1a36423b72663c4c4a7ad19448c91b4205814568 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sat, 9 May 2026 01:52:41 +0300 Subject: [PATCH 1/4] Add background update checks --- README.md | 13 + bin/LG_Buddy_Common | 4 + configure.sh | 12 + crates/lg-buddy/src/lib.rs | 19 +- crates/lg-buddy/src/settings.rs | 415 ++++++++++++++++- crates/lg-buddy/src/updates.rs | 425 +++++++++++++++++- .../lg-buddy/tests/features/settings.feature | 20 + docs/architecture-overview.md | 22 +- docs/defaults-and-configuration.md | 19 + docs/runtime-event-handler-map.md | 2 + docs/user-guide.md | 36 +- install.sh | 29 +- scripts/build-release-bundle.sh | 2 + scripts/test-release-bundle.sh | 40 ++ systemd/LG_Buddy_update_check.service | 8 + systemd/LG_Buddy_update_check.timer | 10 + uninstall.sh | 8 + 17 files changed, 1042 insertions(+), 42 deletions(-) create mode 100644 systemd/LG_Buddy_update_check.service create mode 100644 systemd/LG_Buddy_update_check.timer diff --git a/README.md b/README.md index 697840c..7c25ce5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ LG Buddy is mostly automatic after installation. - To inspect the installed runtime version, run `lg-buddy --version` - To check GitHub releases on demand, run `lg-buddy updates check`; add `--notify` to send a desktop notification when an update is available +- Background update checks are installed by default; opt out with + `lg-buddy settings set updates.auto_check disabled` - To rerun full setup for TV IP, MAC address, or HDMI input, run `./configure.sh` - To check the screen monitor, run `systemctl --user status LG_Buddy_screen.service` - To remove LG Buddy, run `./uninstall.sh` @@ -106,6 +108,8 @@ lg-buddy settings describe screen.restore_policy lg-buddy settings set screen.idle_timeout 600 lg-buddy settings set screen.restore_policy aggressive lg-buddy settings set system.sleep_wake_policy disabled +lg-buddy settings set updates.auto_check disabled +lg-buddy settings set updates.channel prerelease lg-buddy settings unset screen.restore_policy ``` @@ -118,6 +122,8 @@ tvs_primary_input=HDMI_2 screen_idle_timeout=300 screen_restore_policy=conservative system_sleep_wake_policy=enabled +updates_auto_check=enabled +updates_channel=auto ``` `tv_ip`, `tv_mac`, and `input` are still accepted as legacy single-TV keys, but @@ -141,6 +147,13 @@ Set `screen_restore_policy=aggressive` to let session wake/activity and system w TV around system sleep and wake. The lifecycle service and NetworkManager pre-down hook stay installed and no-op while the policy is disabled. +`updates_auto_check=enabled` is the default. Set +`updates_auto_check=disabled` if you do not want the installed user timer to +check for updates and notify you when a release is available. Manual +`lg-buddy updates check` commands still work when automatic checks are disabled. +`updates_channel=auto` follows the installed build channel; set it to `stable` +or `prerelease` to control the scheduled check channel. + ## More Help - [User guide](docs/user-guide.md) diff --git a/bin/LG_Buddy_Common b/bin/LG_Buddy_Common index de3398c..d22ebcd 100644 --- a/bin/LG_Buddy_Common +++ b/bin/LG_Buddy_Common @@ -13,6 +13,8 @@ LG_BUDDY_DEFAULT_IDLE_TIMEOUT=300 LG_BUDDY_MAX_IDLE_TIMEOUT=86400 LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY="conservative" LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY="enabled" +LG_BUDDY_DEFAULT_UPDATE_AUTO_CHECK="enabled" +LG_BUDDY_DEFAULT_UPDATE_CHANNEL="auto" lg_buddy_user_config_path() { if [ -n "${LG_BUDDY_CONFIG:-}" ]; then @@ -173,6 +175,8 @@ lg_buddy_load_config() { screen_idle_timeout="$(lg_buddy_normalize_idle_timeout "$screen_idle_timeout")" screen_restore_policy="$(lg_buddy_config_get_valid "screen_restore_policy" '^(marker_only|conservative|aggressive)$' "$LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY" "$LG_BUDDY_CONFIG_FILE")" system_sleep_wake_policy="$(lg_buddy_config_get_valid "system_sleep_wake_policy" '^(enabled|disabled)$' "$LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY" "$LG_BUDDY_CONFIG_FILE")" + updates_auto_check="$(lg_buddy_config_get_valid "updates_auto_check" '^(enabled|disabled)$' "$LG_BUDDY_DEFAULT_UPDATE_AUTO_CHECK" "$LG_BUDDY_CONFIG_FILE")" + updates_channel="$(lg_buddy_config_get_valid "updates_channel" '^(auto|stable|prerelease)$' "$LG_BUDDY_DEFAULT_UPDATE_CHANNEL" "$LG_BUDDY_CONFIG_FILE")" if [ -z "$tv_ip" ] || [ -z "$tv_mac" ] || [ -z "$input" ]; then echo "LG Buddy: config file is missing required TV settings." diff --git a/configure.sh b/configure.sh index 7968479..9647908 100755 --- a/configure.sh +++ b/configure.sh @@ -83,6 +83,8 @@ current_screen_backend="$LG_BUDDY_DEFAULT_SCREEN_BACKEND" current_screen_idle_timeout="$LG_BUDDY_DEFAULT_IDLE_TIMEOUT" current_screen_restore_policy="$LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY" current_system_sleep_wake_policy="$LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY" +current_update_auto_check="$LG_BUDDY_DEFAULT_UPDATE_AUTO_CHECK" +current_update_channel="$LG_BUDDY_DEFAULT_UPDATE_CHANNEL" if lg_buddy_load_config >/dev/null 2>&1; then current_tv_ip="$tv_ip" @@ -92,6 +94,8 @@ if lg_buddy_load_config >/dev/null 2>&1; then current_screen_idle_timeout="$screen_idle_timeout" current_screen_restore_policy="$(normalize_restore_policy "$screen_restore_policy")" current_system_sleep_wake_policy="$system_sleep_wake_policy" + current_update_auto_check="$updates_auto_check" + current_update_channel="$updates_channel" echo "Loaded existing configuration from $LG_BUDDY_CONFIG_FILE" fi @@ -103,6 +107,8 @@ if [ "${LG_BUDDY_NONINTERACTIVE:-0}" = "1" ]; then screen_idle_timeout="${LG_BUDDY_SCREEN_IDLE_TIMEOUT:-$current_screen_idle_timeout}" screen_restore_policy="${LG_BUDDY_SCREEN_RESTORE_POLICY:-$current_screen_restore_policy}" system_sleep_wake_policy="${LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY:-$current_system_sleep_wake_policy}" + update_auto_check="$current_update_auto_check" + update_channel="$current_update_channel" if [ -z "${LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY:-}" ] && [ -n "${LG_BUDDY_DISABLE_SLEEP_WAKE:-}" ]; then case "$LG_BUDDY_DISABLE_SLEEP_WAKE" in [Yy]*|1|true|TRUE|True|yes|YES|Yes) system_sleep_wake_policy="disabled" ;; @@ -280,6 +286,8 @@ else done system_sleep_wake_policy="$current_system_sleep_wake_policy" + update_auto_check="$current_update_auto_check" + update_channel="$current_update_channel" fi echo "" @@ -291,6 +299,8 @@ echo " Screen Backend: $screen_backend" echo " Screen Idle Timeout: $screen_idle_timeout" echo " Screen Restore: $screen_restore_policy" echo " System Sleep/Wake: $system_sleep_wake_policy" +echo " Update Checks: $update_auto_check" +echo " Update Channel: $update_channel" echo " Config File: $CONFIG_FILE" echo "" @@ -316,6 +326,8 @@ screen_backend=$screen_backend screen_idle_timeout=$screen_idle_timeout screen_restore_policy=$screen_restore_policy system_sleep_wake_policy=$system_sleep_wake_policy +updates_auto_check=$update_auto_check +updates_channel=$update_channel EOF chmod 600 "$CONFIG_FILE" diff --git a/crates/lg-buddy/src/lib.rs b/crates/lg-buddy/src/lib.rs index 860de0d..3a0719d 100644 --- a/crates/lg-buddy/src/lib.rs +++ b/crates/lg-buddy/src/lib.rs @@ -273,7 +273,7 @@ Commands: lifecycle Run the system lifecycle monitor loop detect-backend Detect the active screen backend settings Inspect and edit structured LG Buddy settings - updates Check GitHub releases on demand + updates Check GitHub releases on demand or from the user timer Startup modes: auto Restore on wake when LG Buddy owns the system marker, otherwise boot @@ -289,6 +289,7 @@ Settings: Updates: updates check [--channel stable|prerelease] [--notify] + updates background-check " ) } @@ -632,6 +633,12 @@ mod tests { } ))) ); + assert_eq!( + parse_args(["updates", "background-check"]), + Ok(ParseOutcome::Command(Command::Updates( + UpdatesCommand::BackgroundCheck + ))) + ); } #[test] @@ -746,6 +753,15 @@ mod tests { parse_args(["updates", "check", "--notify", "--notify"]), Err(ParseError::Updates(UpdatesParseError::DuplicateNotify)) ); + assert_eq!( + parse_args(["updates", "background-check", "extra"]), + Err(ParseError::Updates( + UpdatesParseError::UnexpectedArguments { + subcommand: "background-check", + arguments: vec!["extra".to_string()] + } + )) + ); } #[test] @@ -799,6 +815,7 @@ mod tests { "settings set ", "settings unset ", "updates check [--channel stable|prerelease] [--notify]", + "updates background-check", ] { assert!(help.contains(command), "missing `{command}` from help"); } diff --git a/crates/lg-buddy/src/settings.rs b/crates/lg-buddy/src/settings.rs index 18fd5f8..9434fa7 100644 --- a/crates/lg-buddy/src/settings.rs +++ b/crates/lg-buddy/src/settings.rs @@ -14,6 +14,7 @@ use crate::config::{ const SETTINGS_SUBCOMMANDS: &[&str] = &["list", "describe", "get", "set", "unset"]; const SCREEN_SERVICE_NAME: &str = "LG_Buddy_screen.service"; +const UPDATE_CHECK_TIMER_NAME: &str = "LG_Buddy_update_check.timer"; const READ_WRITE_OPERATIONS: &[SettingOperation] = &[ SettingOperation::Get, @@ -40,6 +41,8 @@ const TV_INPUT_VALUES: &[&str] = &["HDMI_1", "HDMI_2", "HDMI_3", "HDMI_4"]; const SCREEN_BACKEND_VALUES: &[&str] = &["auto", "gnome", "swayidle"]; const SCREEN_RESTORE_POLICY_VALUES: &[&str] = &["conservative", "aggressive"]; const SYSTEM_SLEEP_WAKE_POLICY_VALUES: &[&str] = &["enabled", "disabled"]; +const UPDATE_AUTO_CHECK_VALUES: &[&str] = &["enabled", "disabled"]; +const UPDATE_CHANNEL_VALUES: &[&str] = &["auto", "stable", "prerelease"]; const SETTING_DEFINITIONS: &[SettingDefinition] = &[ SettingDefinition { @@ -134,6 +137,34 @@ const SETTING_DEFINITIONS: &[SettingDefinition] = &[ apply_strategy: ApplyStrategy::RuntimePolicyOnly, description: "System sleep and wake policy for lifecycle hooks.", }, + SettingDefinition { + key: "updates.auto_check", + storage_key: "updates_auto_check", + fallback_storage_keys: EMPTY_STORAGE_KEYS, + value_type: SettingType::Enum(EnumSettingType { + values: UPDATE_AUTO_CHECK_VALUES, + aliases: EMPTY_ALIASES, + }), + default_value: Some(SettingValue::Enum("enabled")), + mutability: SettingMutability::ReadWrite, + operations: READ_WRITE_OPERATIONS, + apply_strategy: ApplyStrategy::ManageUpdateCheckTimer, + description: "Automatic background update checks and update notifications.", + }, + SettingDefinition { + key: "updates.channel", + storage_key: "updates_channel", + fallback_storage_keys: EMPTY_STORAGE_KEYS, + value_type: SettingType::Enum(EnumSettingType { + values: UPDATE_CHANNEL_VALUES, + aliases: EMPTY_ALIASES, + }), + default_value: Some(SettingValue::Enum("auto")), + mutability: SettingMutability::ReadWrite, + operations: READ_WRITE_OPERATIONS, + apply_strategy: ApplyStrategy::RuntimePolicyOnly, + description: "Release channel used by automatic background update checks.", + }, ]; pub static SETTINGS_REGISTRY: SettingsRegistry = SettingsRegistry { @@ -657,6 +688,8 @@ impl SettingsChange { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsApplyOutcome { Restarted { service: &'static str }, + EnabledStarted { unit: &'static str }, + DisabledStopped { unit: &'static str }, NotInstalled { service: &'static str }, InactiveDisabled { service: &'static str }, Skipped { reason: String }, @@ -667,6 +700,8 @@ impl fmt::Display for SettingsApplyOutcome { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Restarted { service } => write!(f, "restarted {service}"), + Self::EnabledStarted { unit } => write!(f, "enabled and started {unit}"), + Self::DisabledStopped { unit } => write!(f, "disabled and stopped {unit}"), Self::NotInstalled { service } => { write!( f, @@ -698,6 +733,10 @@ pub trait ServiceController { fn user_service_state(&self, service: &str) -> Result; fn restart_user_service(&self, service: &str) -> Result<(), SettingsError>; + + fn enable_start_user_unit(&self, unit: &str) -> Result<(), SettingsError>; + + fn disable_stop_user_unit(&self, unit: &str) -> Result<(), SettingsError>; } #[derive(Debug, Clone)] @@ -731,6 +770,28 @@ impl SystemdUserServiceController { .status() .map(|status| status.success()) } + + fn run_user_systemctl(&self, args: &[&str]) -> Result<(), SettingsError> { + let output = ProcessCommand::new(&self.command_path) + .arg("--user") + .args(args) + .output() + .map_err(|err| SettingsError::Apply { + message: format!("could not run systemctl: {err}"), + })?; + + if output.status.success() { + Ok(()) + } else { + Err(SettingsError::Apply { + message: format_command_failure( + output.status.code(), + &output.stdout, + &output.stderr, + ), + }) + } + } } impl ServiceController for SystemdUserServiceController { @@ -761,26 +822,15 @@ impl ServiceController for SystemdUserServiceController { } fn restart_user_service(&self, service: &str) -> Result<(), SettingsError> { - let output = ProcessCommand::new(&self.command_path) - .arg("--user") - .arg("restart") - .arg(service) - .output() - .map_err(|err| SettingsError::Apply { - message: format!("could not run systemctl: {err}"), - })?; + self.run_user_systemctl(&["restart", service]) + } - if output.status.success() { - Ok(()) - } else { - Err(SettingsError::Apply { - message: format_command_failure( - output.status.code(), - &output.stdout, - &output.stderr, - ), - }) - } + fn enable_start_user_unit(&self, unit: &str) -> Result<(), SettingsError> { + self.run_user_systemctl(&["enable", "--now", unit]) + } + + fn disable_stop_user_unit(&self, unit: &str) -> Result<(), SettingsError> { + self.run_user_systemctl(&["disable", "--now", unit]) } } @@ -805,6 +855,7 @@ impl SettingsApplier { pub fn apply(&self, change: &SettingsChange) -> Result { match change.mutation().definition().apply_strategy() { ApplyStrategy::RestartUserScreenService => self.apply_screen_service_restart(), + ApplyStrategy::ManageUpdateCheckTimer => self.apply_update_check_timer(change), ApplyStrategy::RuntimePolicyOnly | ApplyStrategy::NoRuntimeApplyRequired => { Ok(SettingsApplyOutcome::NoActionRequired) } @@ -837,6 +888,66 @@ impl SettingsApplier { } } } + + fn apply_update_check_timer( + &self, + change: &SettingsChange, + ) -> Result { + if self.service_controller.systemd_actions_disabled() { + return Ok(SettingsApplyOutcome::Skipped { + reason: "skipped systemd apply because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1".to_string(), + }); + } + + let enabled = match change.mutation().new_value()?.as_enum() { + Some("enabled") => true, + Some("disabled") => false, + _ => { + return Err(SettingsError::Apply { + message: "updates.auto_check resolved to an invalid value".to_string(), + }); + } + }; + + match self + .service_controller + .user_service_state(UPDATE_CHECK_TIMER_NAME)? + { + UserServiceState::Missing => Ok(SettingsApplyOutcome::NotInstalled { + service: UPDATE_CHECK_TIMER_NAME, + }), + UserServiceState::InactiveDisabled => { + if enabled { + self.service_controller + .enable_start_user_unit(UPDATE_CHECK_TIMER_NAME)?; + Ok(SettingsApplyOutcome::EnabledStarted { + unit: UPDATE_CHECK_TIMER_NAME, + }) + } else { + self.service_controller + .disable_stop_user_unit(UPDATE_CHECK_TIMER_NAME)?; + Ok(SettingsApplyOutcome::DisabledStopped { + unit: UPDATE_CHECK_TIMER_NAME, + }) + } + } + UserServiceState::ActiveOrEnabled => { + if enabled { + self.service_controller + .enable_start_user_unit(UPDATE_CHECK_TIMER_NAME)?; + Ok(SettingsApplyOutcome::EnabledStarted { + unit: UPDATE_CHECK_TIMER_NAME, + }) + } else { + self.service_controller + .disable_stop_user_unit(UPDATE_CHECK_TIMER_NAME)?; + Ok(SettingsApplyOutcome::DisabledStopped { + unit: UPDATE_CHECK_TIMER_NAME, + }) + } + } + } + } } #[derive(Debug)] @@ -1783,6 +1894,7 @@ impl fmt::Display for SettingOperation { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApplyStrategy { RestartUserScreenService, + ManageUpdateCheckTimer, RuntimePolicyOnly, NoRuntimeApplyRequired, } @@ -1791,6 +1903,7 @@ impl ApplyStrategy { pub fn as_str(self) -> &'static str { match self { Self::RestartUserScreenService => "restart-user-screen-service", + Self::ManageUpdateCheckTimer => "manage-update-check-timer", Self::RuntimePolicyOnly => "runtime-policy-only", Self::NoRuntimeApplyRequired => "no-runtime-apply-required", } @@ -2105,6 +2218,8 @@ mod tests { "screen.idle_timeout", "screen.restore_policy", "system.sleep_wake_policy", + "updates.auto_check", + "updates.channel", ] ); } @@ -2127,6 +2242,8 @@ mod tests { ("screen.idle_timeout", "screen_idle_timeout"), ("screen.restore_policy", "screen_restore_policy"), ("system.sleep_wake_policy", "system_sleep_wake_policy"), + ("updates.auto_check", "updates_auto_check"), + ("updates.channel", "updates_channel"), ] ); } @@ -2149,6 +2266,8 @@ mod tests { "screen.idle_timeout | storage=screen_idle_timeout | fallbacks=(none) | type=integer range=1..=86400 | default=300 | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Idle timeout in seconds before LG Buddy blanks the configured screen.", "screen.restore_policy | storage=screen_restore_policy | fallbacks=(none) | type=enum values=conservative,aggressive aliases=marker_only->conservative | default=conservative | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Screen restore policy after LG Buddy blanks the configured screen.", "system.sleep_wake_policy | storage=system_sleep_wake_policy | fallbacks=(none) | type=enum values=enabled,disabled aliases=(none) | default=enabled | mutability=read-write | ops=get,describe,set,unset | apply=runtime-policy-only | description=System sleep and wake policy for lifecycle hooks.", + "updates.auto_check | storage=updates_auto_check | fallbacks=(none) | type=enum values=enabled,disabled aliases=(none) | default=enabled | mutability=read-write | ops=get,describe,set,unset | apply=manage-update-check-timer | description=Automatic background update checks and update notifications.", + "updates.channel | storage=updates_channel | fallbacks=(none) | type=enum values=auto,stable,prerelease aliases=(none) | default=auto | mutability=read-write | ops=get,describe,set,unset | apply=runtime-policy-only | description=Release channel used by automatic background update checks.", ] ); } @@ -2236,6 +2355,8 @@ screen.backend=gnome (config.env, read-write, ops: get,describe,set,unset) screen.idle_timeout=300 (default, read-write, ops: get,describe,set,unset) screen.restore_policy=conservative (default, read-write, ops: get,describe,set,unset) system.sleep_wake_policy=disabled (config.env, read-write, ops: get,describe,set,unset) +updates.auto_check=enabled (default, read-write, ops: get,describe,set,unset) +updates.channel=auto (default, read-write, ops: get,describe,set,unset) " ); } @@ -2382,6 +2503,30 @@ system.sleep_wake_policy allowed values: enabled, disabled apply: runtime-policy-only description: System sleep and wake policy for lifecycle hooks. + +updates.auto_check + storage key: updates_auto_check + type: enum + current: enabled + source: default + default: enabled + mutability: read-write + supported operations: get, describe, set, unset + allowed values: enabled, disabled + apply: manage-update-check-timer + description: Automatic background update checks and update notifications. + +updates.channel + storage key: updates_channel + type: enum + current: auto + source: default + default: auto + mutability: read-write + supported operations: get, describe, set, unset + allowed values: auto, stable, prerelease + apply: runtime-policy-only + description: Release channel used by automatic background update checks. " ); } @@ -2692,6 +2837,167 @@ screen_restore_policy=aggressive let _ = fs::remove_file(path); } + #[test] + fn settings_runner_disables_update_timer_when_auto_check_is_disabled() { + let path = unique_test_path("disable-update-checks"); + fs::write(&path, "updates_auto_check=enabled\n").unwrap(); + let store = SettingsStore::load(&path).unwrap(); + let fake_service = FakeServiceController::active_or_enabled(); + let disables = fake_service.disables.clone(); + let runner = SettingsCommandRunner::with_applier(store, SettingsApplier::new(fake_service)); + let mut output = Vec::new(); + + runner + .run( + SettingsCommand::Set { + key: "updates.auto_check".to_string(), + value: "disabled".to_string(), + }, + &mut output, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "updates_auto_check=disabled\n" + ); + assert_eq!(disables.get(), 1); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("updates.auto_check=disabled (saved to ")); + assert!(output.contains("apply: disabled and stopped LG_Buddy_update_check.timer\n")); + + let _ = fs::remove_file(path); + } + + #[test] + fn settings_runner_enables_update_timer_when_auto_check_is_enabled() { + let path = unique_test_path("enable-update-checks"); + fs::write(&path, "updates_auto_check=disabled\n").unwrap(); + let store = SettingsStore::load(&path).unwrap(); + let fake_service = FakeServiceController::inactive_disabled(); + let enables = fake_service.enables.clone(); + let runner = SettingsCommandRunner::with_applier(store, SettingsApplier::new(fake_service)); + let mut output = Vec::new(); + + runner + .run( + SettingsCommand::Set { + key: "updates.auto_check".to_string(), + value: "enabled".to_string(), + }, + &mut output, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "updates_auto_check=enabled\n" + ); + assert_eq!(enables.get(), 1); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("updates.auto_check=enabled (saved to ")); + assert!(output.contains("apply: enabled and started LG_Buddy_update_check.timer\n")); + + let _ = fs::remove_file(path); + } + + #[test] + fn settings_runner_reports_missing_update_timer_after_persisting_auto_check() { + let path = unique_test_path("missing-update-check-timer"); + fs::write(&path, "updates_auto_check=enabled\n").unwrap(); + let store = SettingsStore::load(&path).unwrap(); + let runner = SettingsCommandRunner::with_applier( + store, + SettingsApplier::new(FakeServiceController::missing()), + ); + let mut output = Vec::new(); + + runner + .run( + SettingsCommand::Set { + key: "updates.auto_check".to_string(), + value: "disabled".to_string(), + }, + &mut output, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "updates_auto_check=disabled\n" + ); + assert!(String::from_utf8(output) + .unwrap() + .contains("apply: LG_Buddy_update_check.timer is not installed")); + + let _ = fs::remove_file(path); + } + + #[test] + fn settings_runner_skips_update_timer_apply_when_systemd_actions_are_disabled() { + let path = unique_test_path("skip-update-timer-apply"); + fs::write(&path, "updates_auto_check=enabled\n").unwrap(); + let store = SettingsStore::load(&path).unwrap(); + let mut fake_service = FakeServiceController::active_or_enabled(); + fake_service.skip_actions = true; + let disables = fake_service.disables.clone(); + let runner = SettingsCommandRunner::with_applier(store, SettingsApplier::new(fake_service)); + let mut output = Vec::new(); + + runner + .run( + SettingsCommand::Set { + key: "updates.auto_check".to_string(), + value: "disabled".to_string(), + }, + &mut output, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "updates_auto_check=disabled\n" + ); + assert_eq!(disables.get(), 0); + assert!(String::from_utf8(output) + .unwrap() + .contains("apply: skipped systemd apply")); + + let _ = fs::remove_file(path); + } + + #[test] + fn settings_runner_reports_update_timer_apply_failure_after_persisting_value() { + let path = unique_test_path("update-timer-apply-fail"); + fs::write(&path, "updates_auto_check=enabled\n").unwrap(); + let store = SettingsStore::load(&path).unwrap(); + let runner = SettingsCommandRunner::with_applier( + store, + SettingsApplier::new(FakeServiceController::failing_unit_action()), + ); + let mut output = Vec::new(); + + let err = runner + .run( + SettingsCommand::Set { + key: "updates.auto_check".to_string(), + value: "disabled".to_string(), + }, + &mut output, + ) + .unwrap_err(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "updates_auto_check=disabled\n" + ); + assert!(matches!(err, SettingsError::ApplyAfterPersist { .. })); + assert!(err.to_string().contains("was saved")); + assert!(output.is_empty()); + + let _ = fs::remove_file(path); + } + #[test] fn settings_runner_sets_tv_value_to_canonical_storage_without_restart() { let path = unique_test_path("tv-set"); @@ -3058,6 +3364,8 @@ tvs_primary_ip=192.0.2.43 "screen.idle_timeout", "screen.restore_policy", "system.sleep_wake_policy", + "updates.auto_check", + "updates.channel", ] ); assert_eq!( @@ -3070,6 +3378,8 @@ tvs_primary_ip=192.0.2.43 "300", "conservative", "disabled", + "enabled", + "auto", ] ); assert_eq!( @@ -3082,6 +3392,8 @@ tvs_primary_ip=192.0.2.43 SettingSource::Default, SettingSource::Default, SettingSource::ConfigEnv, + SettingSource::Default, + SettingSource::Default, ] ); } @@ -3292,6 +3604,20 @@ tvs_primary_ip=192.0.2.43 sleep_policy.apply_strategy(), ApplyStrategy::RuntimePolicyOnly ); + + let auto_check = SETTINGS_REGISTRY.get_by_name("updates.auto_check").unwrap(); + assert!(matches!(auto_check.value_type(), SettingType::Enum(_))); + assert_eq!( + auto_check.apply_strategy(), + ApplyStrategy::ManageUpdateCheckTimer + ); + + let update_channel = SETTINGS_REGISTRY.get_by_name("updates.channel").unwrap(); + assert!(matches!(update_channel.value_type(), SettingType::Enum(_))); + assert_eq!( + update_channel.apply_strategy(), + ApplyStrategy::RuntimePolicyOnly + ); } fn unique_test_path(name: &str) -> PathBuf { @@ -3372,7 +3698,10 @@ tvs_primary_ip=192.0.2.43 struct FakeServiceController { state: UserServiceState, restarts: Rc>, + enables: Rc>, + disables: Rc>, restart_error: Option<&'static str>, + unit_action_error: Option<&'static str>, skip_actions: bool, } @@ -3381,7 +3710,10 @@ tvs_primary_ip=192.0.2.43 Self { state: UserServiceState::ActiveOrEnabled, restarts: Rc::new(Cell::new(0)), + enables: Rc::new(Cell::new(0)), + disables: Rc::new(Cell::new(0)), restart_error: None, + unit_action_error: None, skip_actions: false, } } @@ -3390,7 +3722,10 @@ tvs_primary_ip=192.0.2.43 Self { state: UserServiceState::InactiveDisabled, restarts: Rc::new(Cell::new(0)), + enables: Rc::new(Cell::new(0)), + disables: Rc::new(Cell::new(0)), restart_error: None, + unit_action_error: None, skip_actions: false, } } @@ -3399,7 +3734,10 @@ tvs_primary_ip=192.0.2.43 Self { state: UserServiceState::Missing, restarts: Rc::new(Cell::new(0)), + enables: Rc::new(Cell::new(0)), + disables: Rc::new(Cell::new(0)), restart_error: None, + unit_action_error: None, skip_actions: false, } } @@ -3408,7 +3746,22 @@ tvs_primary_ip=192.0.2.43 Self { state: UserServiceState::ActiveOrEnabled, restarts: Rc::new(Cell::new(0)), + enables: Rc::new(Cell::new(0)), + disables: Rc::new(Cell::new(0)), restart_error: Some("restart failed"), + unit_action_error: None, + skip_actions: false, + } + } + + fn failing_unit_action() -> Self { + Self { + state: UserServiceState::ActiveOrEnabled, + restarts: Rc::new(Cell::new(0)), + enables: Rc::new(Cell::new(0)), + disables: Rc::new(Cell::new(0)), + restart_error: None, + unit_action_error: Some("unit action failed"), skip_actions: false, } } @@ -3434,5 +3787,29 @@ tvs_primary_ip=192.0.2.43 Ok(()) } } + + fn enable_start_user_unit(&self, _unit: &str) -> Result<(), SettingsError> { + self.enables.set(self.enables.get() + 1); + + if let Some(message) = self.unit_action_error { + Err(SettingsError::Apply { + message: message.to_string(), + }) + } else { + Ok(()) + } + } + + fn disable_stop_user_unit(&self, _unit: &str) -> Result<(), SettingsError> { + self.disables.set(self.disables.get() + 1); + + if let Some(message) = self.unit_action_error { + Err(SettingsError::Apply { + message: message.to_string(), + }) + } else { + Ok(()) + } + } } } diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 4facb2d..af9599f 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -13,6 +13,7 @@ use crate::session_notifications::{ SessionBusUpdateNotificationHandoff, UpdateNotificationError, UpdateNotificationHandoff, UpdateNotificationRequest, }; +use crate::settings::{SettingsError, SettingsStore}; use crate::version::{ReleaseChannel, VersionInfo}; const GITHUB_RELEASES_API_BASE: &str = @@ -31,6 +32,7 @@ pub enum UpdatesCommand { channel: Option, notify: bool, }, + BackgroundCheck, } impl UpdatesCommand { @@ -46,6 +48,7 @@ impl UpdatesCommand { match subcommand.as_ref() { "check" => parse_check_args(args), + "background-check" => parse_background_check_args(args), other => Err(UpdatesParseError::UnknownSubcommand(other.to_string())), } } @@ -53,16 +56,37 @@ impl UpdatesCommand { pub fn as_str(&self) -> &'static str { match self { Self::Check { .. } => "check", + Self::BackgroundCheck => "background-check", } } fn notify(&self) -> bool { match self { Self::Check { notify, .. } => *notify, + Self::BackgroundCheck => true, } } } +fn parse_background_check_args(args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let extra_args: Vec = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect(); + if extra_args.is_empty() { + Ok(UpdatesCommand::BackgroundCheck) + } else { + Err(UpdatesParseError::UnexpectedArguments { + subcommand: "background-check", + arguments: extra_args, + }) + } +} + fn parse_check_args(args: I) -> Result where I: IntoIterator, @@ -122,7 +146,7 @@ impl fmt::Display for UpdatesParseError { match self { Self::MissingSubcommand => write!( f, - "missing updates command; expected `updates check [--channel stable|prerelease] [--notify]`" + "missing updates command; expected `updates check [--channel stable|prerelease] [--notify]` or `updates background-check`" ), Self::UnknownSubcommand(subcommand) => { write!(f, "unknown updates command `{subcommand}`") @@ -179,6 +203,101 @@ impl UpdateChannel { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackgroundUpdateAutoCheck { + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackgroundUpdateChannel { + Auto, + Stable, + Prerelease, +} + +impl BackgroundUpdateChannel { + fn check_channel(self) -> Option { + match self { + Self::Auto => None, + Self::Stable => Some(UpdateChannel::Stable), + Self::Prerelease => Some(UpdateChannel::Prerelease), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BackgroundUpdateCheckPolicy { + auto_check: BackgroundUpdateAutoCheck, + channel: BackgroundUpdateChannel, +} + +impl BackgroundUpdateCheckPolicy { + fn from_settings(store: &SettingsStore) -> Result { + let auto_check = match required_enum_setting(store, "updates.auto_check")? { + "enabled" => BackgroundUpdateAutoCheck::Enabled, + "disabled" => BackgroundUpdateAutoCheck::Disabled, + _ => { + return Err(UpdatesError::SettingsInvariant( + "updates.auto_check resolved to an unsupported value".to_string(), + )); + } + }; + let channel = match required_enum_setting(store, "updates.channel")? { + "auto" => BackgroundUpdateChannel::Auto, + "stable" => BackgroundUpdateChannel::Stable, + "prerelease" => BackgroundUpdateChannel::Prerelease, + _ => { + return Err(UpdatesError::SettingsInvariant( + "updates.channel resolved to an unsupported value".to_string(), + )); + } + }; + + Ok(Self { + auto_check, + channel, + }) + } + + fn check_command(self) -> Option { + match self.auto_check { + BackgroundUpdateAutoCheck::Disabled => None, + BackgroundUpdateAutoCheck::Enabled => Some(UpdatesCommand::Check { + channel: self.channel.check_channel(), + notify: true, + }), + } + } +} + +fn required_enum_setting( + store: &SettingsStore, + key: &'static str, +) -> Result<&'static str, UpdatesError> { + store + .effective_by_name(key)? + .required_value()? + .as_enum() + .ok_or_else(|| { + UpdatesError::SettingsInvariant(format!("{key} resolved to a non-enum value")) + }) +} + +trait BackgroundUpdateSettings { + fn background_update_check_policy(&self) -> Result; +} + +#[derive(Debug, Clone, Copy)] +struct EnvBackgroundUpdateSettings; + +impl BackgroundUpdateSettings for EnvBackgroundUpdateSettings { + fn background_update_check_policy(&self) -> Result { + let store = SettingsStore::load_from_env()?; + BackgroundUpdateCheckPolicy::from_settings(&store) + } +} + #[derive(Debug, Clone, Default)] struct UpdateCachePathSources<'a> { xdg_cache_home: Option<&'a Path>, @@ -267,6 +386,8 @@ pub enum UpdatesError { source: serde_json::Error, }, CacheEncode(serde_json::Error), + Settings(SettingsError), + SettingsInvariant(String), DeferredFailures(Vec), Notification(UpdateNotificationError), Io(io::Error), @@ -348,6 +469,10 @@ impl fmt::Display for UpdatesError { ) } Self::CacheEncode(err) => write!(f, "could not encode update check cache: {err}"), + Self::Settings(err) => write!(f, "could not read update settings: {err}"), + Self::SettingsInvariant(message) => { + write!(f, "invalid update settings metadata: {message}") + } Self::DeferredFailures(failures) => { write!(f, "update check completed with deferred failure")?; if failures.len() != 1 { @@ -378,6 +503,7 @@ impl Error for UpdatesError { Self::CachePath(err) => Some(err), Self::CacheDecode { source, .. } => Some(source), Self::CacheEncode(err) => Some(err), + Self::Settings(err) => Some(err), Self::DeferredFailures(failures) => { failures.iter().find_map(|failure| failure.source()) } @@ -386,7 +512,8 @@ impl Error for UpdatesError { Self::Http { .. } | Self::ApiStatus { .. } | Self::NoMatchingRelease { .. } - | Self::NotModifiedWithoutCache { .. } => None, + | Self::NotModifiedWithoutCache { .. } + | Self::SettingsInvariant(_) => None, } } } @@ -397,6 +524,12 @@ impl From for UpdatesError { } } +impl From for UpdatesError { + fn from(value: SettingsError) -> Self { + Self::Settings(value) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseInfo { version: Version, @@ -914,16 +1047,67 @@ fn run_updates_command_with< cache_store: &S, now_unix_seconds: u64, ) -> Result<(), UpdatesError> { + let background_settings = EnvBackgroundUpdateSettings; + let context = UpdatesRunContext { + version, + client, + notifier, + cache_store, + background_settings: &background_settings, + now_unix_seconds, + }; + run_updates_command_with_background_settings(command, writer, context) +} + +struct UpdatesRunContext<'a, C, N, S, B> { + version: VersionInfo, + client: &'a C, + notifier: &'a N, + cache_store: &'a S, + background_settings: &'a B, + now_unix_seconds: u64, +} + +fn run_updates_command_with_background_settings< + W: io::Write, + C: GitHubReleasesClient, + N: UpdateNotificationHandoff, + S: UpdateCacheStore, + B: BackgroundUpdateSettings, +>( + command: UpdatesCommand, + writer: &mut W, + context: UpdatesRunContext<'_, C, N, S, B>, +) -> Result<(), UpdatesError> { + let command = match command { + UpdatesCommand::Check { .. } => command, + UpdatesCommand::BackgroundCheck => { + let policy = context + .background_settings + .background_update_check_policy()?; + let Some(command) = policy.check_command() else { + writer.write_all(b"background: skipped (automatic update checks disabled)\n")?; + return Ok(()); + }; + command + } + }; let notify = command.notify(); let mut deferred_failures = Vec::new(); - let mut cache = match cache_store.load() { + let mut cache = match context.cache_store.load() { Ok(cache) => cache, Err(err) => { deferred_failures.push(UpdatesDeferredFailure::Cache(Box::new(err))); UpdateCheckCache::default() } }; - let result = check_updates_with_cache(command, version, client, &mut cache, now_unix_seconds)?; + let result = check_updates_with_cache( + command, + context.version, + context.client, + &mut cache, + context.now_unix_seconds, + )?; writer.write_all(result.render().as_bytes())?; let notification_decision = @@ -939,13 +1123,13 @@ fn run_updates_command_with< UpdateNotificationDecision::Notify { reason } => { let notification_result = result .notification_request() - .and_then(|request| notifier.show_update_notification(&request)); + .and_then(|request| context.notifier.show_update_notification(&request)); match notification_result { Ok(_) => { cache.record_notification( result.check_channel, &result.latest, - now_unix_seconds, + context.now_unix_seconds, ); writer.write_all(render_update_notification_sent(reason).as_bytes())?; } @@ -963,7 +1147,7 @@ fn run_updates_command_with< } } } - if let Err(err) = cache_store.save(&cache) { + if let Err(err) = context.cache_store.save(&cache) { deferred_failures.push(UpdatesDeferredFailure::Cache(Box::new(err))); } @@ -1009,6 +1193,16 @@ fn check_updates_with_cache( latest, }) } + UpdatesCommand::BackgroundCheck => check_updates_with_cache( + UpdatesCommand::Check { + channel: None, + notify: true, + }, + current, + client, + cache, + now_unix_seconds, + ), } } @@ -1144,13 +1338,15 @@ mod tests { use super::{ atomic_write_file, check_updates, check_updates_with_cache, evaluate_update_notification_policy, parse_release_version, resolve_update_cache_path, - run_updates_command_with, CachedReleaseInfo, CachedUpdateCheck, CachedUpdateNotification, + run_updates_command_with, run_updates_command_with_background_settings, + BackgroundUpdateAutoCheck, BackgroundUpdateChannel, BackgroundUpdateCheckPolicy, + BackgroundUpdateSettings, CachedReleaseInfo, CachedUpdateCheck, CachedUpdateNotification, DefaultUpdateCacheStore, FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, ReleaseEndpoint, ReleaseInfo, UpdateCachePathError, UpdateCachePathSources, UpdateCacheStore, UpdateChannel, UpdateCheckCache, UpdateNotificationDecision, UpdateNotificationPolicyInput, UpdateNotificationReason, UpdateNotificationSkipReason, - UpdatesCommand, UpdatesDeferredFailure, UpdatesError, UreqGitHubReleasesClient, - PRERELEASE_PAGE_SIZE, + UpdatesCommand, UpdatesDeferredFailure, UpdatesError, UpdatesRunContext, + UreqGitHubReleasesClient, PRERELEASE_PAGE_SIZE, }; use crate::session_notifications::{ UpdateNotificationError, UpdateNotificationHandoff, UpdateNotificationOutcome, @@ -1314,6 +1510,57 @@ mod tests { } } + #[derive(Debug, Clone, Copy)] + struct StaticBackgroundUpdateSettings { + policy: BackgroundUpdateCheckPolicy, + } + + impl StaticBackgroundUpdateSettings { + fn enabled(channel: BackgroundUpdateChannel) -> Self { + Self { + policy: BackgroundUpdateCheckPolicy { + auto_check: BackgroundUpdateAutoCheck::Enabled, + channel, + }, + } + } + + fn disabled() -> Self { + Self { + policy: BackgroundUpdateCheckPolicy { + auto_check: BackgroundUpdateAutoCheck::Disabled, + channel: BackgroundUpdateChannel::Auto, + }, + } + } + } + + impl BackgroundUpdateSettings for StaticBackgroundUpdateSettings { + fn background_update_check_policy( + &self, + ) -> Result { + Ok(self.policy) + } + } + + fn updates_run_context<'a, C, N, S, B>( + version: VersionInfo, + client: &'a C, + notifier: &'a N, + cache_store: &'a S, + background_settings: &'a B, + now_unix_seconds: u64, + ) -> UpdatesRunContext<'a, C, N, S, B> { + UpdatesRunContext { + version, + client, + notifier, + cache_store, + background_settings, + now_unix_seconds, + } + } + fn version_info(version: &'static str, channel: ReleaseChannel) -> VersionInfo { VersionInfo::for_testing(version, channel, Some("test")) } @@ -1422,6 +1669,10 @@ mod tests { } } + fn background_check() -> UpdatesCommand { + UpdatesCommand::BackgroundCheck + } + #[test] fn notification_policy_skips_when_notification_was_not_requested() { let latest = release_info( @@ -1979,6 +2230,160 @@ mod tests { assert!(notifier.notifications().is_empty()); } + #[test] + fn background_update_check_skips_without_github_or_cache_when_disabled() { + let client = MockGitHubReleasesClient::new_responses(vec![]); + let notifier = RecordingNotifier::default(); + let cache_store = MemoryUpdateCacheStore::default(); + let background_settings = StaticBackgroundUpdateSettings::disabled(); + let mut output = Vec::new(); + + run_updates_command_with_background_settings( + background_check(), + &mut output, + updates_run_context( + version_info("1.1.0", ReleaseChannel::Stable), + &client, + ¬ifier, + &cache_store, + &background_settings, + TEST_NOW, + ), + ) + .expect("disabled background update check should succeed"); + + assert_eq!( + rendered(&output), + "background: skipped (automatic update checks disabled)\n" + ); + assert!(client.requests_with_etags().is_empty()); + assert!(notifier.notifications().is_empty()); + assert_eq!(cache_store.cache(), UpdateCheckCache::default()); + } + + #[test] + fn background_update_check_uses_auto_channel_and_notifies() { + let client = MockGitHubReleasesClient::new(vec![Ok(stable_release("v1.1.1"))]); + let notifier = RecordingNotifier::default(); + let cache_store = MemoryUpdateCacheStore::default(); + let background_settings = + StaticBackgroundUpdateSettings::enabled(BackgroundUpdateChannel::Auto); + let mut output = Vec::new(); + + run_updates_command_with_background_settings( + background_check(), + &mut output, + updates_run_context( + version_info("1.1.0", ReleaseChannel::Stable), + &client, + ¬ifier, + &cache_store, + &background_settings, + TEST_NOW, + ), + ) + .expect("enabled background update check should succeed"); + + assert!(rendered(&output).contains("status: update available")); + assert!(rendered(&output).contains("notification: sent (new release)")); + assert_eq!(notifier.notifications().len(), 1); + assert_eq!( + client.requests(), + vec![( + "https://api.example.test/releases/latest".to_string(), + "lg-buddy/1.1.0".to_string() + )] + ); + } + + #[test] + fn background_update_check_uses_configured_prerelease_channel() { + let client = MockGitHubReleasesClient::new(vec![Ok(format!( + "[{},{}]", + stable_release("v1.1.0"), + prerelease("v1.2.0-beta.1") + ))]); + let notifier = RecordingNotifier::default(); + let cache_store = MemoryUpdateCacheStore::default(); + let background_settings = + StaticBackgroundUpdateSettings::enabled(BackgroundUpdateChannel::Prerelease); + let mut output = Vec::new(); + + run_updates_command_with_background_settings( + background_check(), + &mut output, + updates_run_context( + version_info("1.1.0", ReleaseChannel::Stable), + &client, + ¬ifier, + &cache_store, + &background_settings, + TEST_NOW, + ), + ) + .expect("configured prerelease background update check should succeed"); + + assert!(rendered(&output).contains("latest: 1.2.0-beta.1 (prerelease)")); + assert_eq!(notifier.notifications().len(), 1); + assert_eq!( + client.requests(), + vec![( + "https://api.example.test/releases?per_page=20".to_string(), + "lg-buddy/1.1.0".to_string() + )] + ); + } + + #[test] + fn background_update_check_reuses_notification_policy_for_repeated_release() { + let client = MockGitHubReleasesClient::new_responses(vec![ + Ok(api_response( + stable_release("v1.1.1"), + Some("\"stable-etag\""), + )), + Ok(GitHubReleaseResponse::NotModified), + ]); + let notifier = RecordingNotifier::default(); + let cache_store = MemoryUpdateCacheStore::default(); + let background_settings = + StaticBackgroundUpdateSettings::enabled(BackgroundUpdateChannel::Stable); + let mut first_output = Vec::new(); + let mut second_output = Vec::new(); + + run_updates_command_with_background_settings( + background_check(), + &mut first_output, + updates_run_context( + version_info("1.1.0", ReleaseChannel::Stable), + &client, + ¬ifier, + &cache_store, + &background_settings, + TEST_NOW, + ), + ) + .expect("initial background update check should succeed"); + run_updates_command_with_background_settings( + background_check(), + &mut second_output, + updates_run_context( + version_info("1.1.0", ReleaseChannel::Stable), + &client, + ¬ifier, + &cache_store, + &background_settings, + TEST_NOW + 1, + ), + ) + .expect("repeated background update check should succeed"); + + assert!(rendered(&first_output).contains("notification: sent (new release)")); + assert!( + rendered(&second_output).contains("notification: skipped (already shown for 1.1.1)") + ); + assert_eq!(notifier.notifications().len(), 1); + } + #[test] fn unavailable_cache_path_does_not_block_update_check_but_fails_after_result() { let client = MockGitHubReleasesClient::new(vec![Ok(stable_release("v1.1.1"))]); diff --git a/crates/lg-buddy/tests/features/settings.feature b/crates/lg-buddy/tests/features/settings.feature index dc47534..e0ce205 100644 --- a/crates/lg-buddy/tests/features/settings.feature +++ b/crates/lg-buddy/tests/features/settings.feature @@ -11,6 +11,8 @@ Feature: Settings CLI And stdout contains "screen.backend=auto (config.env, read-write, ops: get,describe,set,unset)" And stdout contains "screen.restore_policy=conservative (default, read-write, ops: get,describe,set,unset)" And stdout contains "system.sleep_wake_policy=enabled (default, read-write, ops: get,describe,set,unset)" + And stdout contains "updates.auto_check=enabled (default, read-write, ops: get,describe,set,unset)" + And stdout contains "updates.channel=auto (default, read-write, ops: get,describe,set,unset)" Scenario: settings describe shows required TV operations Given a temporary LG Buddy config using input HDMI_2 @@ -29,6 +31,15 @@ Feature: Settings CLI And stdout contains "mutability: read-write" And stdout contains "supported operations: get, describe, set, unset" + Scenario: settings describe shows update check operations + Given a temporary LG Buddy config using input HDMI_2 + When I run the command "settings describe updates.auto_check" + Then the command succeeds + And stdout contains "updates.auto_check" + And stdout contains "storage key: updates_auto_check" + And stdout contains "allowed values: enabled, disabled" + And stdout contains "apply: manage-update-check-timer" + Scenario: settings set writes config.env and reports skipped apply Given a temporary LG Buddy config using input HDMI_2 And systemd apply actions are skipped @@ -98,3 +109,12 @@ Feature: Settings CLI And stdout contains "system.sleep_wake_policy=disabled" And stdout contains "apply: no runtime apply action required" And config.env contains "system_sleep_wake_policy=disabled" + + Scenario: settings set writes update check opt out + Given a temporary LG Buddy config using input HDMI_2 + And systemd apply actions are skipped + When I run the command "settings set updates.auto_check disabled" + Then the command succeeds + And stdout contains "updates.auto_check=disabled" + And stdout contains "apply: skipped systemd apply" + And config.env contains "updates_auto_check=disabled" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 11ffd3c..00a7590 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -75,6 +75,7 @@ flowchart LR subgraph SystemLifecycle["System Lifecycle"] LOGIND["logind system bus
PrepareForSleep"] NM["NetworkManager dispatcher
pre-down"] + UPDATE_TIMER["systemd user timer
background update checks"] end subgraph TTY["TTY / CLI"] @@ -324,20 +325,25 @@ The binary currently supports these commands: - `detect-backend` - `settings` - `updates check [--channel stable|prerelease] [--notify]` +- `updates background-check` `lib.rs` parses the command line into a typed command enum and dispatches into the runtime command handlers in `commands.rs` and `session/runner.rs`. `commands.rs` then delegates screen and lifecycle decisions to their domain modules and delegates platform ingestion to `sources/`. The on-demand `updates check` command consumes the GitHub Releases API without entering the -screen, lifecycle, settings, or scheduling paths. When `--notify` is passed and -an update is available, the one-shot CLI process hands the resolved update facts -to the LG Buddy-owned user-session D-Bus surface. The running session process -then owns desktop notification dispatch, notification ids, and the `View -Release` action. `updates check` owns an operational cache under the user cache -directory for GitHub ETag, latest release metadata, and last-notified release -state used by the observable update notification policy; that cache is not user -configuration and is not part of the settings API. +screen, lifecycle, settings, or scheduling paths. `updates background-check` is +the timer-owned wrapper: it reads update settings, exits before GitHub/cache +work when automatic checks are disabled, and otherwise delegates to the same +update check path with notification intent enabled. When notification is +requested and an update is available, the one-shot CLI process hands the +resolved update facts to the LG Buddy-owned user-session D-Bus surface. The +running session process then owns desktop notification dispatch, notification +ids, and the `View Release` action. The update command owns an operational +cache under the user cache directory for GitHub ETag, latest release metadata, +and last-notified release state used by the observable update notification +policy; that cache is not user configuration and is not part of the settings +API. The `brightness get` and `brightness set` commands use the TV picture abstraction in `tv.rs` for typed OLED brightness validation and live TV read/write operations. The interactive brightness dialog delegates its TV diff --git a/docs/defaults-and-configuration.md b/docs/defaults-and-configuration.md index 4d8a8ff..b2905f2 100644 --- a/docs/defaults-and-configuration.md +++ b/docs/defaults-and-configuration.md @@ -78,6 +78,8 @@ tvs_primary_mac=aa:bb:cc:dd:ee:ff tvs_primary_input=HDMI_2 screen_restore_policy=conservative system_sleep_wake_policy=enabled +updates_auto_check=enabled +updates_channel=auto ``` Avoid adding installer-only state for product behavior. Environment variables @@ -121,3 +123,20 @@ choice. - the supported values are `enabled` and `disabled` - lifecycle service and NetworkManager hook installation are integration topology, while this setting controls runtime policy + +`updates_auto_check` follows the same opt-out model: + +- automatic update checks should default to enabled +- the installer should not ask every user whether update checks should run +- users who do not want background checks should opt out through `config.env` or + `lg-buddy settings set updates.auto_check disabled` +- disabling automatic checks disables/stops the user timer instead of merely + suppressing notifications after doing the work + +`updates_channel` keeps scheduled update checks configurable without changing +manual diagnostics: + +- `auto` is the default and follows the installed build channel +- `stable` and `prerelease` are explicit scheduled-check choices +- manual `lg-buddy updates check --channel ...` still overrides channel for + that invocation only diff --git a/docs/runtime-event-handler-map.md b/docs/runtime-event-handler-map.md index a96278f..0db0eb3 100644 --- a/docs/runtime-event-handler-map.md +++ b/docs/runtime-event-handler-map.md @@ -44,6 +44,7 @@ same normalized inactivity observations. | user graphical session start | `lg-buddy monitor` | `session::runner::run_monitor` | Detect the session backend and run the selected monitor path. | | manual screen blank | `lg-buddy screen-off` | `commands` -> `screen` | Blank or power off the TV if LG Buddy owns the configured input. | | manual screen restore | `lg-buddy screen-on` | `commands` -> `screen` | Restore the screen when marker and restore-policy rules allow it. | +| user update-check timer | `lg-buddy updates background-check` | `updates` -> GitHub releases API -> session notification handoff | Check for updates when automatic checks are enabled and notify once per release. | Compatibility command surfaces still exist for direct/manual invocation: @@ -52,6 +53,7 @@ Compatibility command surfaces still exist for direct/manual invocation: | `lg-buddy sleep-pre` | Direct pre-sleep policy command retained for manual/debug invocation. | | `lg-buddy startup wake` | Direct wake restore policy command retained for manual/debug invocation. | | `lg-buddy sleep` | Legacy NetworkManager pre-down behavior. It is not installed as a default event handler. | +| `lg-buddy updates check` | Manual update diagnostic command retained independently of automatic update-check settings. | These handlers are intentionally conservative around ownership: diff --git a/docs/user-guide.md b/docs/user-guide.md index 3b4970d..6f0cdb0 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -40,6 +40,7 @@ lg-buddy brightness set 65 lg-buddy --version lg-buddy updates check lg-buddy updates check --notify +lg-buddy updates background-check ``` In normal use, systemd starts the relevant commands automatically. Most users @@ -63,6 +64,10 @@ the desktop notification service supports actions, the notification includes a opener. After a notification is delivered for a release, repeated `--notify` checks for the same release skip the notification and print the notification policy decision; a newer release can notify again. +`updates background-check` is the service-owned path used by the installed user +timer. It reads update settings, skips all update work when automatic checks are +disabled, and otherwise uses the same release API, cache, notification handoff, +and repeat-notification policy as `updates check --notify`. `lifecycle`, `nm-pre-down`, `sleep-pre`, and `startup wake` are normally service-owned system lifecycle commands. They are documented for @@ -167,14 +172,18 @@ To change supported settings: lg-buddy settings set tv.input HDMI_2 lg-buddy settings set screen.idle_timeout 600 lg-buddy settings set screen.restore_policy aggressive +lg-buddy settings set updates.auto_check disabled +lg-buddy settings set updates.channel prerelease lg-buddy settings unset screen.restore_policy ``` `set` and `unset` write `config.env` and then apply the setting when an explicit runtime apply step is needed. Screen-monitor settings restart `LG_Buddy_screen.service` when the user service is installed and active or -enabled. TV identity and system sleep/wake policy changes are read by later -runtime actions and do not require a service restart. +enabled. `updates.auto_check` enables or disables the installed user timer for +background update checks. TV identity, system sleep/wake policy, and update +channel changes are read by later runtime actions and do not require a service +restart. To rerun full setup for TV IP, MAC address, HDMI input, or install-time service wiring: @@ -199,6 +208,8 @@ Current config keys: - `screen_idle_timeout` - `screen_restore_policy` - `system_sleep_wake_policy` +- `updates_auto_check` +- `updates_channel` Legacy single-TV keys `tv_ip`, `tv_mac`, and `input` are still read as fallback values for existing installs. New writes use `tvs_primary_*` storage keys. @@ -221,6 +232,8 @@ Current structured settings: | `screen.idle_timeout` | `screen_idle_timeout` | `get`, `describe`, `set`, `unset` | | `screen.restore_policy` | `screen_restore_policy` | `get`, `describe`, `set`, `unset` | | `system.sleep_wake_policy` | `system_sleep_wake_policy` | `get`, `describe`, `set`, `unset` | +| `updates.auto_check` | `updates_auto_check` | `get`, `describe`, `set`, `unset` | +| `updates.channel` | `updates_channel` | `get`, `describe`, `set`, `unset` | The `tv.*` settings expose the single supported TV in the public API. Their storage keys are profile-shaped only to leave room for future storage growth; @@ -250,6 +263,23 @@ value is `disabled`. The NetworkManager pre-down hook also reads config on each invocation, so `lg-buddy settings set system.sleep_wake_policy ` changes runtime policy without reinstalling services. +`updates_auto_check` controls automatic background update checks: + +- `enabled`: default behavior, let the installed user timer periodically check + GitHub releases and notify when an update is available +- `disabled`: leave the timer units installed, but disable the timer and skip + background update work + +Manual `lg-buddy updates check` commands still work when automatic checks are +disabled. + +`updates_channel` controls the channel used by automatic background checks: + +- `auto`: default behavior, stable/dev builds check stable releases and + prerelease builds check the prerelease channel +- `stable`: only consider stable releases +- `prerelease`: consider prereleases and stable releases + Example: ```ini @@ -259,6 +289,8 @@ tvs_primary_input=HDMI_2 screen_idle_timeout=300 screen_restore_policy=aggressive system_sleep_wake_policy=enabled +updates_auto_check=enabled +updates_channel=auto ``` Installed services receive the resolved config path through `LG_BUDDY_CONFIG`. diff --git a/install.sh b/install.sh index 4ab02ec..d11d57d 100755 --- a/install.sh +++ b/install.sh @@ -108,6 +108,9 @@ DESKTOP_ENTRY_PATH="${APPLICATIONS_DIR}/LG_Buddy_Brightness.desktop" USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" USER_SCREEN_SERVICE_PATH="${USER_SYSTEMD_DIR}/LG_Buddy_screen.service" USER_SCREEN_OVERRIDE_DIR="${USER_SYSTEMD_DIR}/LG_Buddy_screen.service.d" +USER_UPDATE_CHECK_SERVICE_PATH="${USER_SYSTEMD_DIR}/LG_Buddy_update_check.service" +USER_UPDATE_CHECK_TIMER_PATH="${USER_SYSTEMD_DIR}/LG_Buddy_update_check.timer" +USER_UPDATE_CHECK_OVERRIDE_DIR="${USER_SYSTEMD_DIR}/LG_Buddy_update_check.service.d" check_dep() { local label="$1" @@ -287,6 +290,11 @@ case "$SYSTEM_SLEEP_WAKE_POLICY" in enabled|disabled) ;; *) SYSTEM_SLEEP_WAKE_POLICY="enabled" ;; esac +UPDATE_AUTO_CHECK="$(sed -n 's/^updates_auto_check=//p' "$CONFIG_FILE" | tail -n1)" +case "$UPDATE_AUTO_CHECK" in + enabled|disabled) ;; + *) UPDATE_AUTO_CHECK="enabled" ;; +esac echo "Using configuration file at $CONFIG_FILE" echo "Configuration complete." @@ -411,9 +419,16 @@ else fi echo "Done." -# 8. INSTALL SCREEN MONITOR USER SERVICE -echo "Installing screen monitor user service..." +# 8. INSTALL USER SERVICES +echo "Installing background update check user timer..." mkdir -p "$USER_SYSTEMD_DIR" +cp "$SCRIPT_DIR/systemd/LG_Buddy_update_check.service" "$USER_UPDATE_CHECK_SERVICE_PATH" +cp "$SCRIPT_DIR/systemd/LG_Buddy_update_check.timer" "$USER_UPDATE_CHECK_TIMER_PATH" +mkdir -p "$USER_UPDATE_CHECK_OVERRIDE_DIR" +write_config_override "${USER_UPDATE_CHECK_OVERRIDE_DIR}/config.conf" "$CONFIG_FILE" +echo "Done." + +echo "Installing screen monitor user service..." cp "$SCRIPT_DIR/systemd/LG_Buddy_screen.service" "$USER_SCREEN_SERVICE_PATH" mkdir -p "$USER_SCREEN_OVERRIDE_DIR" write_config_override "${USER_SCREEN_OVERRIDE_DIR}/config.conf" "$CONFIG_FILE" @@ -421,6 +436,16 @@ if [ "$SKIP_SYSTEMD_ACTIONS" != "1" ]; then systemctl --user daemon-reload fi +if [ "$SKIP_SYSTEMD_ACTIONS" = "1" ]; then + echo "Skipping update check timer enable/start because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1." +elif [ "$UPDATE_AUTO_CHECK" = "enabled" ]; then + systemctl --user enable --now LG_Buddy_update_check.timer + echo "LG_Buddy_update_check.timer enabled and started." +else + systemctl --user disable --now LG_Buddy_update_check.timer 2>/dev/null || true + echo "LG_Buddy_update_check.timer installed but disabled by config." +fi + if [ "$SCREEN_MONITOR_AVAILABLE" -eq 1 ]; then ENABLE_SCREEN_MONITOR="${LG_BUDDY_ENABLE_SCREEN_MONITOR:-}" if [ -z "$ENABLE_SCREEN_MONITOR" ] && [ "$NONINTERACTIVE" != "1" ]; then diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index b164b67..ffdd21b 100755 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -64,6 +64,8 @@ install -m 644 "$REPO_ROOT/LICENSE" "$BUNDLE_DIR/LICENSE" install -m 644 "$REPO_ROOT/systemd/LG_Buddy.service" "$BUNDLE_DIR/systemd/LG_Buddy.service" install -m 644 "$REPO_ROOT/systemd/LG_Buddy_lifecycle.service" "$BUNDLE_DIR/systemd/LG_Buddy_lifecycle.service" install -m 644 "$REPO_ROOT/systemd/LG_Buddy_screen.service" "$BUNDLE_DIR/systemd/LG_Buddy_screen.service" +install -m 644 "$REPO_ROOT/systemd/LG_Buddy_update_check.service" "$BUNDLE_DIR/systemd/LG_Buddy_update_check.service" +install -m 644 "$REPO_ROOT/systemd/LG_Buddy_update_check.timer" "$BUNDLE_DIR/systemd/LG_Buddy_update_check.timer" install -m 644 "$REPO_ROOT/systemd/lg_buddy.conf" "$BUNDLE_DIR/systemd/lg_buddy.conf" cp -R "$REPO_ROOT/docs/." "$BUNDLE_DIR/docs/" diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index e27ad37..d7a4442 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -118,12 +118,15 @@ assert_file "$BUNDLE_DIR/docs/release-process.md" assert_file "$BUNDLE_DIR/systemd/LG_Buddy.service" assert_file "$BUNDLE_DIR/systemd/LG_Buddy_lifecycle.service" assert_file "$BUNDLE_DIR/systemd/LG_Buddy_screen.service" +assert_file "$BUNDLE_DIR/systemd/LG_Buddy_update_check.service" +assert_file "$BUNDLE_DIR/systemd/LG_Buddy_update_check.timer" HELP_OUTPUT="$("$BUNDLE_DIR/lg-buddy" 2>&1 || true)" printf '%s\n' "$HELP_OUTPUT" | grep -q "lg-buddy" printf '%s\n' "$HELP_OUTPUT" | grep -q "settings list" printf '%s\n' "$HELP_OUTPUT" | grep -q "settings set " printf '%s\n' "$HELP_OUTPUT" | grep -F -q "updates check [--channel stable|prerelease] [--notify]" +printf '%s\n' "$HELP_OUTPUT" | grep -F -q "updates background-check" VERSION_OUTPUT="$("$BUNDLE_DIR/lg-buddy" --version)" printf '%s\n' "$VERSION_OUTPUT" | grep -q "^lg-buddy " @@ -165,6 +168,9 @@ LIFECYCLE_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_lifecycle.service" LEGACY_SLEEP_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_sleep.service" LEGACY_WAKE_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_wake.service" USER_SCREEN_SERVICE="$HOME/.config/systemd/user/LG_Buddy_screen.service" +USER_UPDATE_CHECK_SERVICE="$HOME/.config/systemd/user/LG_Buddy_update_check.service" +USER_UPDATE_CHECK_TIMER="$HOME/.config/systemd/user/LG_Buddy_update_check.timer" +USER_UPDATE_CHECK_OVERRIDE="$HOME/.config/systemd/user/LG_Buddy_update_check.service.d/config.conf" DESKTOP_ENTRY="$INSTALL_ROOT/usr/share/applications/LG_Buddy_Brightness.desktop" NM_SLEEP_HOOK="$INSTALL_ROOT/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_sleep" NM_LIFECYCLE_HOOK="$INSTALL_ROOT/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_lifecycle" @@ -180,6 +186,9 @@ assert_file "$INSTALLED_POINTER" assert_file "$SYSTEM_SERVICE" assert_file "$LIFECYCLE_SERVICE" assert_file "$USER_SCREEN_SERVICE" +assert_file "$USER_UPDATE_CHECK_SERVICE" +assert_file "$USER_UPDATE_CHECK_TIMER" +assert_file "$USER_UPDATE_CHECK_OVERRIDE" assert_file "$DESKTOP_ENTRY" [ ! -e "$LEGACY_SLEEP_SERVICE" ] || { echo "Legacy sleep service installed unexpectedly: $LEGACY_SLEEP_SERVICE" @@ -216,6 +225,7 @@ printf '%s\n' "$INSTALLED_HELP_OUTPUT" | grep -q "lg-buddy" printf '%s\n' "$INSTALLED_HELP_OUTPUT" | grep -q "settings list" printf '%s\n' "$INSTALLED_HELP_OUTPUT" | grep -q "settings set " printf '%s\n' "$INSTALLED_HELP_OUTPUT" | grep -F -q "updates check [--channel stable|prerelease] [--notify]" +printf '%s\n' "$INSTALLED_HELP_OUTPUT" | grep -F -q "updates background-check" INSTALLED_VERSION_OUTPUT="$("$INSTALLED_BINARY" --version)" printf '%s\n' "$INSTALLED_VERSION_OUTPUT" | grep -q "^lg-buddy " @@ -232,12 +242,17 @@ grep -q '^screen_idle_timeout=86400$' "$CONFIG_FILE" "$INSTALLED_BINARY" settings set tv.ip 192.168.1.12 "$INSTALLED_BINARY" settings set tv.mac 22:33:44:55:66:77 "$INSTALLED_BINARY" settings set tv.input HDMI_4 +"$INSTALLED_BINARY" settings get updates.auto_check | grep -q '^enabled$' +"$INSTALLED_BINARY" settings set updates.auto_check disabled +"$INSTALLED_BINARY" settings set updates.channel prerelease grep -q '^screen_backend=gnome$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" grep -q '^tvs_primary_ip=192.168.1.12$' "$CONFIG_FILE" grep -q '^tvs_primary_mac=22:33:44:55:66:77$' "$CONFIG_FILE" grep -q '^tvs_primary_input=HDMI_4$' "$CONFIG_FILE" +grep -q '^updates_auto_check=disabled$' "$CONFIG_FILE" +grep -q '^updates_channel=prerelease$' "$CONFIG_FILE" ( unset LG_BUDDY_SCREEN_BACKEND @@ -258,6 +273,8 @@ grep -q '^screen_backend=gnome$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" grep -q '^system_sleep_wake_policy=enabled$' "$CONFIG_FILE" +grep -q '^updates_auto_check=disabled$' "$CONFIG_FILE" +grep -q '^updates_channel=prerelease$' "$CONFIG_FILE" export LG_BUDDY_REMOVE_CONFIG="1" ( @@ -289,6 +306,18 @@ export LG_BUDDY_REMOVE_CONFIG="1" echo "User screen service still present after uninstall: $USER_SCREEN_SERVICE" exit 1 } +[ ! -e "$USER_UPDATE_CHECK_SERVICE" ] || { + echo "User update check service still present after uninstall: $USER_UPDATE_CHECK_SERVICE" + exit 1 +} +[ ! -e "$USER_UPDATE_CHECK_TIMER" ] || { + echo "User update check timer still present after uninstall: $USER_UPDATE_CHECK_TIMER" + exit 1 +} +[ ! -e "$USER_UPDATE_CHECK_OVERRIDE" ] || { + echo "User update check override still present after uninstall: $USER_UPDATE_CHECK_OVERRIDE" + exit 1 +} [ ! -e "$DESKTOP_ENTRY" ] || { echo "Desktop entry still present after uninstall: $DESKTOP_ENTRY" exit 1 @@ -318,6 +347,9 @@ assert_executable "$INSTALLED_BINARY" assert_file "$SYSTEM_SERVICE" assert_file "$LIFECYCLE_SERVICE" assert_file "$USER_SCREEN_SERVICE" +assert_file "$USER_UPDATE_CHECK_SERVICE" +assert_file "$USER_UPDATE_CHECK_TIMER" +assert_file "$USER_UPDATE_CHECK_OVERRIDE" [ ! -e "$LEGACY_SLEEP_SERVICE" ] || { echo "Legacy sleep service installed unexpectedly: $LEGACY_SLEEP_SERVICE" exit 1 @@ -347,6 +379,14 @@ grep -q '^system_sleep_wake_policy=disabled$' "$CONFIG_FILE" echo "Lifecycle service still present after disabled-policy uninstall: $LIFECYCLE_SERVICE" exit 1 } +[ ! -e "$USER_UPDATE_CHECK_SERVICE" ] || { + echo "User update check service still present after disabled-policy uninstall: $USER_UPDATE_CHECK_SERVICE" + exit 1 +} +[ ! -e "$USER_UPDATE_CHECK_TIMER" ] || { + echo "User update check timer still present after disabled-policy uninstall: $USER_UPDATE_CHECK_TIMER" + exit 1 +} [ ! -e "$CONFIG_FILE" ] || { echo "User config still present after disabled-policy uninstall: $CONFIG_FILE" exit 1 diff --git a/systemd/LG_Buddy_update_check.service b/systemd/LG_Buddy_update_check.service new file mode 100644 index 0000000..4ad8f0c --- /dev/null +++ b/systemd/LG_Buddy_update_check.service @@ -0,0 +1,8 @@ +[Unit] +Description=LG Buddy background update check +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/lg-buddy updates background-check diff --git a/systemd/LG_Buddy_update_check.timer b/systemd/LG_Buddy_update_check.timer new file mode 100644 index 0000000..27d97d7 --- /dev/null +++ b/systemd/LG_Buddy_update_check.timer @@ -0,0 +1,10 @@ +[Unit] +Description=LG Buddy background update check timer + +[Timer] +OnCalendar=daily +RandomizedDelaySec=6h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/uninstall.sh b/uninstall.sh index 714e457..e9fc270 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -51,6 +51,9 @@ RUN_STATE_DIR="$(prefix_path "/run/lg_buddy")" USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" USER_SCREEN_SERVICE_PATH="${USER_SYSTEMD_DIR}/LG_Buddy_screen.service" USER_SCREEN_OVERRIDE_DIR="${USER_SYSTEMD_DIR}/LG_Buddy_screen.service.d" +USER_UPDATE_CHECK_SERVICE_PATH="${USER_SYSTEMD_DIR}/LG_Buddy_update_check.service" +USER_UPDATE_CHECK_TIMER_PATH="${USER_SYSTEMD_DIR}/LG_Buddy_update_check.timer" +USER_UPDATE_CHECK_OVERRIDE_DIR="${USER_SYSTEMD_DIR}/LG_Buddy_update_check.service.d" if [ -r "$SCRIPT_DIR/bin/LG_Buddy_Common" ]; then . "$SCRIPT_DIR/bin/LG_Buddy_Common" @@ -77,11 +80,13 @@ else run_privileged systemctl disable LG_Buddy_lifecycle.service 2>/dev/null || true run_privileged systemctl disable LG_Buddy_wake.service 2>/dev/null || true run_privileged systemctl disable LG_Buddy_sleep.service 2>/dev/null || true + systemctl --user disable LG_Buddy_update_check.timer 2>/dev/null || true systemctl --user disable LG_Buddy_screen.service 2>/dev/null || true run_privileged systemctl stop LG_Buddy.service 2>/dev/null || true run_privileged systemctl stop LG_Buddy_lifecycle.service 2>/dev/null || true run_privileged systemctl stop LG_Buddy_wake.service 2>/dev/null || true run_privileged systemctl stop LG_Buddy_sleep.service 2>/dev/null || true + systemctl --user stop LG_Buddy_update_check.timer 2>/dev/null || true systemctl --user stop LG_Buddy_screen.service 2>/dev/null || true fi run_privileged rm -f "$SYSTEMD_SERVICE_PATH" @@ -98,6 +103,9 @@ run_privileged rmdir "$SYSTEMD_WAKE_OVERRIDE_DIR" 2>/dev/null || true run_privileged rmdir "$SYSTEMD_SLEEP_OVERRIDE_DIR" 2>/dev/null || true rm -f "$USER_SCREEN_SERVICE_PATH" rm -rf "$USER_SCREEN_OVERRIDE_DIR" +rm -f "$USER_UPDATE_CHECK_SERVICE_PATH" +rm -f "$USER_UPDATE_CHECK_TIMER_PATH" +rm -rf "$USER_UPDATE_CHECK_OVERRIDE_DIR" if [ "$SKIP_SYSTEMD_ACTIONS" != "1" ]; then run_privileged systemctl daemon-reload systemctl --user daemon-reload From 7bce15f7412a3309fcd99aa3b44c5e958c370e6e Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sat, 9 May 2026 03:09:28 +0300 Subject: [PATCH 2/4] Keep session service enabled for update notifications --- README.md | 12 +- bin/LG_Buddy_Common | 2 + configure.sh | 115 ++++++++----- crates/lg-buddy/src/commands.rs | 4 +- crates/lg-buddy/src/config.rs | 79 ++++++++- crates/lg-buddy/src/lifecycle.rs | 4 +- crates/lg-buddy/src/screen.rs | 4 +- crates/lg-buddy/src/session/runner.rs | 120 ++++++++++++-- crates/lg-buddy/src/settings.rs | 49 ++++++ .../src/sources/linux/network_manager.rs | 4 +- .../lg-buddy/tests/cucumber_support/steps.rs | 5 + .../lg-buddy/tests/cucumber_support/world.rs | 7 + .../tests/features/monitor_gnome.feature | 14 ++ .../lg-buddy/tests/features/settings.feature | 10 ++ docs/architecture-overview.md | 6 + docs/defaults-and-configuration.md | 10 ++ docs/user-guide.md | 27 +++- install.sh | 153 ++++++++---------- scripts/test-release-bundle.sh | 6 +- 19 files changed, 484 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 7c25ce5..c55014b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ chmod +x ./install.sh ./install.sh ``` -The installer will prompt for your TV IP, MAC address, HDMI input, idle-monitor backend, idle timeout, and screen restore policy, then install the required services. System sleep/wake handling uses the default lifecycle service plus NetworkManager pre-down gate unless you opt out in `config.env`. +The installer will prompt for your TV IP, MAC address, HDMI input, and session idle blanking details, then install the required services. System sleep/wake handling uses the default lifecycle service plus NetworkManager pre-down gate unless you opt out in `config.env`. On first use, you may need to accept a pairing prompt on the TV: @@ -95,7 +95,7 @@ LG Buddy is mostly automatic after installation. - Background update checks are installed by default; opt out with `lg-buddy settings set updates.auto_check disabled` - To rerun full setup for TV IP, MAC address, or HDMI input, run `./configure.sh` -- To check the screen monitor, run `systemctl --user status LG_Buddy_screen.service` +- To check the user-session service, run `systemctl --user status LG_Buddy_screen.service` - To remove LG Buddy, run `./uninstall.sh` The settings CLI is a structured layer over `config.env`. These examples write @@ -104,6 +104,7 @@ the same file that manual editing and `configure.sh` use: ```bash lg-buddy settings describe tv.input lg-buddy settings set tv.input HDMI_2 +lg-buddy settings set screen.idle_blank disabled lg-buddy settings describe screen.restore_policy lg-buddy settings set screen.idle_timeout 600 lg-buddy settings set screen.restore_policy aggressive @@ -119,6 +120,8 @@ Settings can also be edited directly in `config.env`: tvs_primary_ip=192.168.1.100 tvs_primary_mac=aa:bb:cc:dd:ee:ff tvs_primary_input=HDMI_2 +screen_idle_blank=enabled +screen_backend=auto screen_idle_timeout=300 screen_restore_policy=conservative system_sleep_wake_policy=enabled @@ -142,6 +145,11 @@ Set `screen_restore_policy=aggressive` to let session wake/activity and system w `marker_only` is still accepted as a legacy alias for `conservative`. +`screen_idle_blank=enabled` is the default. Set +`screen_idle_blank=disabled` if you want the user-session service to stay +available for update notifications without running idle-driven TV blank/restore +behavior. + `system_sleep_wake_policy=enabled` is the default. Set `system_sleep_wake_policy=disabled` if you do not want LG Buddy to control the TV around system sleep and wake. The lifecycle service and NetworkManager diff --git a/bin/LG_Buddy_Common b/bin/LG_Buddy_Common index d22ebcd..6559b63 100644 --- a/bin/LG_Buddy_Common +++ b/bin/LG_Buddy_Common @@ -9,6 +9,7 @@ else fi LG_BUDDY_POINTER_FILE="${LG_BUDDY_INSTALL_DIR}/config-path" LG_BUDDY_DEFAULT_SCREEN_BACKEND="auto" +LG_BUDDY_DEFAULT_SCREEN_IDLE_BLANK="enabled" LG_BUDDY_DEFAULT_IDLE_TIMEOUT=300 LG_BUDDY_MAX_IDLE_TIMEOUT=86400 LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY="conservative" @@ -171,6 +172,7 @@ lg_buddy_load_config() { tv_mac="$(lg_buddy_config_get_valid_any '^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$' "" "$LG_BUDDY_CONFIG_FILE" "tvs_primary_mac" "tv_mac")" input="$(lg_buddy_config_get_valid_any '^HDMI_[1-4]$' "" "$LG_BUDDY_CONFIG_FILE" "tvs_primary_input" "input")" screen_backend="$(lg_buddy_config_get_valid "screen_backend" '^(auto|gnome|swayidle)$' "$LG_BUDDY_DEFAULT_SCREEN_BACKEND" "$LG_BUDDY_CONFIG_FILE")" + screen_idle_blank="$(lg_buddy_config_get_valid "screen_idle_blank" '^(enabled|disabled)$' "$LG_BUDDY_DEFAULT_SCREEN_IDLE_BLANK" "$LG_BUDDY_CONFIG_FILE")" screen_idle_timeout="$(lg_buddy_config_get_valid "screen_idle_timeout" '^[0-9]+$' "$LG_BUDDY_DEFAULT_IDLE_TIMEOUT" "$LG_BUDDY_CONFIG_FILE")" screen_idle_timeout="$(lg_buddy_normalize_idle_timeout "$screen_idle_timeout")" screen_restore_policy="$(lg_buddy_config_get_valid "screen_restore_policy" '^(marker_only|conservative|aggressive)$' "$LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY" "$LG_BUDDY_CONFIG_FILE")" diff --git a/configure.sh b/configure.sh index 9647908..fb3130d 100755 --- a/configure.sh +++ b/configure.sh @@ -50,6 +50,13 @@ validate_backend() { esac } +validate_screen_idle_blank() { + case "$1" in + enabled|disabled) return 0 ;; + *) return 1 ;; + esac +} + validate_restore_policy() { case "$1" in marker_only|conservative|aggressive) return 0 ;; @@ -80,6 +87,7 @@ current_tv_ip="" current_tv_mac="" current_input="HDMI_1" current_screen_backend="$LG_BUDDY_DEFAULT_SCREEN_BACKEND" +current_screen_idle_blank="$LG_BUDDY_DEFAULT_SCREEN_IDLE_BLANK" current_screen_idle_timeout="$LG_BUDDY_DEFAULT_IDLE_TIMEOUT" current_screen_restore_policy="$LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY" current_system_sleep_wake_policy="$LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY" @@ -91,6 +99,7 @@ if lg_buddy_load_config >/dev/null 2>&1; then current_tv_mac="$tv_mac" current_input="$input" current_screen_backend="$screen_backend" + current_screen_idle_blank="$screen_idle_blank" current_screen_idle_timeout="$screen_idle_timeout" current_screen_restore_policy="$(normalize_restore_policy "$screen_restore_policy")" current_system_sleep_wake_policy="$system_sleep_wake_policy" @@ -104,6 +113,7 @@ if [ "${LG_BUDDY_NONINTERACTIVE:-0}" = "1" ]; then tv_mac="${LG_BUDDY_TV_MAC:-$current_tv_mac}" input="${LG_BUDDY_INPUT:-$current_input}" screen_backend="${LG_BUDDY_SCREEN_BACKEND:-$current_screen_backend}" + screen_idle_blank="$current_screen_idle_blank" screen_idle_timeout="${LG_BUDDY_SCREEN_IDLE_TIMEOUT:-$current_screen_idle_timeout}" screen_restore_policy="${LG_BUDDY_SCREEN_RESTORE_POLICY:-$current_screen_restore_policy}" system_sleep_wake_policy="${LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY:-$current_system_sleep_wake_policy}" @@ -132,6 +142,10 @@ if [ "${LG_BUDDY_NONINTERACTIVE:-0}" = "1" ]; then echo "LG_BUDDY_SCREEN_BACKEND must be one of auto, gnome, or swayidle." exit 1 } + validate_screen_idle_blank "$screen_idle_blank" || { + echo "screen_idle_blank must be one of enabled or disabled." + exit 1 + } validate_restore_policy "$screen_restore_policy" || { echo "LG_BUDDY_SCREEN_RESTORE_POLICY must be one of conservative or aggressive (legacy marker_only is also accepted)." exit 1 @@ -235,55 +249,76 @@ else esac done - echo "Choose the screen idle backend:" - echo " 1) auto" - echo " 2) gnome" - echo " 3) swayidle" - - case "$current_screen_backend" in - auto) default_backend_choice="1" ;; - gnome) default_backend_choice="2" ;; - swayidle) default_backend_choice="3" ;; - *) default_backend_choice="1" ;; + case "$current_screen_idle_blank" in + enabled) default_idle_blank_choice="Y" ;; + disabled) default_idle_blank_choice="n" ;; + *) default_idle_blank_choice="Y" ;; esac while true; do - BACKEND_CHOICE="$(prompt_with_default "Enter number (1-3)" "$default_backend_choice")" - case "$BACKEND_CHOICE" in - 1) screen_backend="auto"; break ;; - 2) screen_backend="gnome"; break ;; - 3) screen_backend="swayidle"; break ;; - *) echo " Please enter a number between 1 and 3." ;; + IDLE_BLANK_CHOICE="$(prompt_with_default "Enable automatic idle blank/restore? (Y/n)" "$default_idle_blank_choice")" + case "$IDLE_BLANK_CHOICE" in + ""|[Yy]*|1|true|TRUE|True|yes|YES|Yes) screen_idle_blank="enabled"; break ;; + [Nn]*|0|false|FALSE|False|no|NO|No) screen_idle_blank="disabled"; break ;; + *) echo " Please answer yes or no." ;; esac done - while true; do - screen_idle_timeout="$(prompt_with_default "Enter idle timeout in seconds" "$current_screen_idle_timeout")" - if validate_idle_timeout "$screen_idle_timeout"; then - screen_idle_timeout="$(lg_buddy_normalize_idle_timeout "$screen_idle_timeout")" - break - fi - echo " Please enter a positive number of seconds." - done + if [ "$screen_idle_blank" = "enabled" ]; then + echo "Choose the screen idle backend:" + echo " 1) auto" + echo " 2) gnome" + echo " 3) swayidle" + + case "$current_screen_backend" in + auto) default_backend_choice="1" ;; + gnome) default_backend_choice="2" ;; + swayidle) default_backend_choice="3" ;; + *) default_backend_choice="1" ;; + esac + + while true; do + BACKEND_CHOICE="$(prompt_with_default "Enter number (1-3)" "$default_backend_choice")" + case "$BACKEND_CHOICE" in + 1) screen_backend="auto"; break ;; + 2) screen_backend="gnome"; break ;; + 3) screen_backend="swayidle"; break ;; + *) echo " Please enter a number between 1 and 3." ;; + esac + done - echo "Choose how aggressively LG Buddy should restore the display:" - echo " 1) conservative (only restore when LG Buddy knows it blanked or powered off the TV)" - echo " 2) aggressive (restore on wake/activity even without prior LG Buddy ownership)" + while true; do + screen_idle_timeout="$(prompt_with_default "Enter idle timeout in seconds" "$current_screen_idle_timeout")" + if validate_idle_timeout "$screen_idle_timeout"; then + screen_idle_timeout="$(lg_buddy_normalize_idle_timeout "$screen_idle_timeout")" + break + fi + echo " Please enter a positive number of seconds." + done - case "$current_screen_restore_policy" in - conservative|marker_only) default_restore_policy_choice="1" ;; - aggressive) default_restore_policy_choice="2" ;; - *) default_restore_policy_choice="1" ;; - esac + echo "Choose how aggressively LG Buddy should restore the display:" + echo " 1) conservative (only restore when LG Buddy knows it blanked or powered off the TV)" + echo " 2) aggressive (restore on wake/activity even without prior LG Buddy ownership)" - while true; do - RESTORE_POLICY_CHOICE="$(prompt_with_default "Enter number (1-2)" "$default_restore_policy_choice")" - case "$RESTORE_POLICY_CHOICE" in - 1) screen_restore_policy="conservative"; break ;; - 2) screen_restore_policy="aggressive"; break ;; - *) echo " Please enter a number between 1 and 2." ;; + case "$current_screen_restore_policy" in + conservative|marker_only) default_restore_policy_choice="1" ;; + aggressive) default_restore_policy_choice="2" ;; + *) default_restore_policy_choice="1" ;; esac - done + + while true; do + RESTORE_POLICY_CHOICE="$(prompt_with_default "Enter number (1-2)" "$default_restore_policy_choice")" + case "$RESTORE_POLICY_CHOICE" in + 1) screen_restore_policy="conservative"; break ;; + 2) screen_restore_policy="aggressive"; break ;; + *) echo " Please enter a number between 1 and 2." ;; + esac + done + else + screen_backend="$current_screen_backend" + screen_idle_timeout="$current_screen_idle_timeout" + screen_restore_policy="$current_screen_restore_policy" + fi system_sleep_wake_policy="$current_system_sleep_wake_policy" update_auto_check="$current_update_auto_check" @@ -295,6 +330,7 @@ echo "Configuration to apply:" echo " TV IP: $tv_ip" echo " TV MAC: $tv_mac" echo " PC Input: $input" +echo " Screen Idle Blank: $screen_idle_blank" echo " Screen Backend: $screen_backend" echo " Screen Idle Timeout: $screen_idle_timeout" echo " Screen Restore: $screen_restore_policy" @@ -322,6 +358,7 @@ cat >"$CONFIG_FILE" < &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } + + pub fn is_enabled(&self) -> bool { + matches!(self, Self::Enabled) + } +} + +impl FromStr for ScreenIdleBlankPolicy { + type Err = (); + + fn from_str(value: &str) -> Result { + match value { + "enabled" => Ok(Self::Enabled), + "disabled" => Ok(Self::Disabled), + _ => Err(()), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemSleepWakePolicy { Enabled, @@ -242,6 +273,7 @@ pub struct Config { pub screen_backend: ScreenBackend, pub screen_idle_timeout: u64, pub screen_restore_policy: ScreenRestorePolicy, + pub screen_idle_blank: ScreenIdleBlankPolicy, pub system_sleep_wake_policy: SystemSleepWakePolicy, } @@ -433,6 +465,11 @@ pub fn parse_config(contents: &str) -> Result { .and_then(|value| value.parse::().ok()) .unwrap_or(ScreenRestorePolicy::MarkerOnly); + let screen_idle_blank = entries + .get("screen_idle_blank") + .and_then(|value| value.parse::().ok()) + .unwrap_or(ScreenIdleBlankPolicy::Enabled); + let system_sleep_wake_policy = entries .get("system_sleep_wake_policy") .and_then(|value| value.parse::().ok()) @@ -445,6 +482,7 @@ pub fn parse_config(contents: &str) -> Result { screen_backend, screen_idle_timeout, screen_restore_policy, + screen_idle_blank, system_sleep_wake_policy, }) } @@ -493,8 +531,8 @@ fn sanitize_config_value(value: &str) -> String { mod tests { use super::{ parse_config, parse_home_from_passwd_entries, resolve_config_path, Config, ConfigError, - ConfigPathError, ConfigPathSources, HdmiInput, ScreenBackend, ScreenRestorePolicy, - SystemSleepWakePolicy, DEFAULT_IDLE_TIMEOUT, MAX_IDLE_TIMEOUT, + ConfigPathError, ConfigPathSources, HdmiInput, ScreenBackend, ScreenIdleBlankPolicy, + ScreenRestorePolicy, SystemSleepWakePolicy, DEFAULT_IDLE_TIMEOUT, MAX_IDLE_TIMEOUT, }; use std::path::Path; @@ -595,6 +633,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; screen_backend=gnome screen_idle_timeout=450 screen_restore_policy=aggressive + screen_idle_blank=disabled system_sleep_wake_policy=disabled ", ) @@ -609,6 +648,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; config.screen_restore_policy, ScreenRestorePolicy::Aggressive ); + assert_eq!(config.screen_idle_blank, ScreenIdleBlankPolicy::Disabled); assert_eq!( config.system_sleep_wake_policy, SystemSleepWakePolicy::Disabled @@ -690,6 +730,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; screen_backend: ScreenBackend::Auto, screen_idle_timeout: DEFAULT_IDLE_TIMEOUT, screen_restore_policy: ScreenRestorePolicy::MarkerOnly, + screen_idle_blank: ScreenIdleBlankPolicy::Enabled, system_sleep_wake_policy: SystemSleepWakePolicy::Enabled, } ); @@ -705,6 +746,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; screen_backend=gnome # use GNOME screen_idle_timeout=450 # seconds screen_restore_policy=aggressive # restore on wake without a marker + screen_idle_blank=disabled # keep the session agent passive system_sleep_wake_policy=disabled # disable lifecycle TV control ", ) @@ -719,6 +761,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; config.screen_restore_policy, ScreenRestorePolicy::Aggressive ); + assert_eq!(config.screen_idle_blank, ScreenIdleBlankPolicy::Disabled); assert_eq!( config.system_sleep_wake_policy, SystemSleepWakePolicy::Disabled @@ -753,6 +796,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; screen_backend=not-a-backend screen_idle_timeout=not-a-number screen_restore_policy=not-a-policy + screen_idle_blank=not-a-policy system_sleep_wake_policy=not-a-policy ", ) @@ -764,6 +808,7 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; config.screen_restore_policy, ScreenRestorePolicy::MarkerOnly ); + assert_eq!(config.screen_idle_blank, ScreenIdleBlankPolicy::Enabled); assert_eq!( config.system_sleep_wake_policy, SystemSleepWakePolicy::Enabled @@ -806,6 +851,36 @@ vas:x:1000:1000:vas:/home/vas:/bin/bash\n"; assert_eq!(SystemSleepWakePolicy::Disabled.as_str(), "disabled"); } + #[test] + fn parse_accepts_screen_idle_blank_policy_values() { + let enabled = parse_config( + "\ + tv_ip=192.168.1.42 + tv_mac=aa:bb:cc:dd:ee:ff + input=HDMI_1 + screen_idle_blank=enabled + ", + ) + .expect("parse enabled idle blank policy"); + + let disabled = parse_config( + "\ + tv_ip=192.168.1.42 + tv_mac=aa:bb:cc:dd:ee:ff + input=HDMI_1 + screen_idle_blank=disabled + ", + ) + .expect("parse disabled idle blank policy"); + + assert_eq!(enabled.screen_idle_blank, ScreenIdleBlankPolicy::Enabled); + assert!(enabled.screen_idle_blank.is_enabled()); + assert_eq!(disabled.screen_idle_blank, ScreenIdleBlankPolicy::Disabled); + assert!(!disabled.screen_idle_blank.is_enabled()); + assert_eq!(ScreenIdleBlankPolicy::Enabled.as_str(), "enabled"); + assert_eq!(ScreenIdleBlankPolicy::Disabled.as_str(), "disabled"); + } + #[test] fn parse_uses_last_duplicate_value() { let config = parse_config( diff --git a/crates/lg-buddy/src/lifecycle.rs b/crates/lg-buddy/src/lifecycle.rs index 3c641e5..8786b37 100644 --- a/crates/lg-buddy/src/lifecycle.rs +++ b/crates/lg-buddy/src/lifecycle.rs @@ -1567,7 +1567,8 @@ mod tests { SystemSleepPowerOffContext, TvEffectObservation, TvInputObservation, }; use crate::config::{ - Config, HdmiInput, MacAddress, ScreenBackend, ScreenRestorePolicy, SystemSleepWakePolicy, + Config, HdmiInput, MacAddress, ScreenBackend, ScreenIdleBlankPolicy, ScreenRestorePolicy, + SystemSleepWakePolicy, }; use crate::events::{EventSource, RuntimeEvent, RuntimeEventKind}; use crate::policy::{ @@ -2327,6 +2328,7 @@ mod tests { .expect("parse mac"), input, screen_backend: ScreenBackend::Auto, + screen_idle_blank: ScreenIdleBlankPolicy::Enabled, screen_idle_timeout: 300, screen_restore_policy: ScreenRestorePolicy::MarkerOnly, system_sleep_wake_policy: SystemSleepWakePolicy::Enabled, diff --git a/crates/lg-buddy/src/screen.rs b/crates/lg-buddy/src/screen.rs index a97899c..a45b49c 100644 --- a/crates/lg-buddy/src/screen.rs +++ b/crates/lg-buddy/src/screen.rs @@ -1131,7 +1131,8 @@ mod tests { ScreenOnNext, Sleeper, SystemLifecycleStatusProvider, }; use crate::config::{ - Config, HdmiInput, MacAddress, ScreenBackend, ScreenRestorePolicy, SystemSleepWakePolicy, + Config, HdmiInput, MacAddress, ScreenBackend, ScreenIdleBlankPolicy, ScreenRestorePolicy, + SystemSleepWakePolicy, }; use crate::events::{EventSource, RuntimeEvent, RuntimeEventKind}; use crate::policy::{ @@ -1632,6 +1633,7 @@ mod tests { .expect("parse mac"), input, screen_backend: ScreenBackend::Auto, + screen_idle_blank: ScreenIdleBlankPolicy::Enabled, screen_idle_timeout: 300, screen_restore_policy: ScreenRestorePolicy::MarkerOnly, system_sleep_wake_policy: SystemSleepWakePolicy::Enabled, diff --git a/crates/lg-buddy/src/session/runner.rs b/crates/lg-buddy/src/session/runner.rs index 2c29168..775b743 100644 --- a/crates/lg-buddy/src/session/runner.rs +++ b/crates/lg-buddy/src/session/runner.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::error::Error; use std::fmt; +use std::fs; use std::io::{self, Write}; use std::path::Path; use std::process::Command as ProcessCommand; @@ -17,8 +18,9 @@ use crate::backend::{ }; use crate::commands::run_system_resume; use crate::config::{ - load_config, normalize_idle_timeout_secs, parse_idle_timeout_secs, - resolve_config_path_from_env, ScreenBackend, DEFAULT_IDLE_TIMEOUT, + load_config, normalize_idle_timeout_secs, parse_config_entries, parse_idle_timeout_secs, + resolve_config_path_from_env, ConfigPathError, ScreenBackend, ScreenIdleBlankPolicy, + DEFAULT_IDLE_TIMEOUT, }; use crate::events::{EventSource, RuntimeEvent}; use crate::lifecycle::LifecycleEvent; @@ -54,6 +56,7 @@ const GAMEPAD_ACTIVITY_RECONCILE_INTERVAL: Duration = Duration::from_secs(300); const GAMEPAD_ACTIVITY_SEND_INTERVAL: Duration = Duration::from_millis(500); const LOGIND_LIFECYCLE_PROCESS_INTERVAL: Duration = Duration::from_secs(5); const LOGIND_LIFECYCLE_TEST_PROCESS_INTERVAL: Duration = Duration::from_millis(50); +const SESSION_AGENT_BACKEND_RETRY_INTERVAL: Duration = Duration::from_secs(30); const GNOME_MONITOR_TEST_TIMEOUT_SECS_ENV: &str = "LG_BUDDY_GNOME_MONITOR_TEST_TIMEOUT_SECS"; const LIFECYCLE_MONITOR_TEST_TIMEOUT_SECS_ENV: &str = "LG_BUDDY_LIFECYCLE_MONITOR_TEST_TIMEOUT_SECS"; @@ -419,11 +422,6 @@ fn run_monitor_with_executor( writer: &mut W, executor: E, ) -> Result<(), SessionRunnerError> { - let configured = - configured_backend_from_env_or_config().map_err(SessionRunnerError::BackendSelection)?; - let backend = - detect_backend_from_system(configured).map_err(SessionRunnerError::BackendDetection)?; - let mut dispatcher = SessionEventDispatcher::new(executor); let _session_service = match spawn_session_notification_service() { Ok(service) => Some(service), Err(err) => { @@ -435,16 +433,110 @@ fn run_monitor_with_executor( } }; - match backend { - ScreenBackend::Gnome => run_gnome_monitor(writer, &mut dispatcher), - ScreenBackend::Swayidle => run_swayidle_monitor(writer), - ScreenBackend::Auto => Err(SessionRunnerError::Failed { - backend, - message: "auto backend should be resolved before starting the runner".to_string(), - }), + if !screen_idle_blank_enabled_from_config()? { + writeln!( + writer, + "LG Buddy Monitor: screen idle blanking is disabled by config." + )?; + return run_passive_session_agent(writer); + } + + let mut executor = Some(executor); + let started = Instant::now(); + let test_timeout = resolve_gnome_monitor_test_timeout(); + + loop { + if test_timeout_reached(started, test_timeout) { + return Ok(()); + } + + let configured = configured_backend_from_env_or_config() + .map_err(SessionRunnerError::BackendSelection)?; + + match detect_backend_from_system(configured) { + Ok(ScreenBackend::Gnome) => { + let mut dispatcher = + SessionEventDispatcher::new(executor.take().expect("executor available")); + return run_gnome_monitor(writer, &mut dispatcher); + } + Ok(ScreenBackend::Swayidle) => return run_swayidle_monitor(writer), + Ok(ScreenBackend::Auto) => { + return Err(SessionRunnerError::Failed { + backend: ScreenBackend::Auto, + message: "auto backend should be resolved before starting the runner" + .to_string(), + }); + } + Err(err) => { + writeln!( + writer, + "LG Buddy Monitor: screen idle backend unavailable: {err}" + )?; + wait_for_backend_retry_or_test_timeout(started, test_timeout); + } + } + } +} + +fn screen_idle_blank_enabled_from_config() -> Result { + let config_path = match resolve_config_path_from_env() { + Ok(path) => path, + Err(ConfigPathError::NotConfigured) => return Ok(true), + }; + let contents = fs::read_to_string(&config_path).map_err(|err| SessionRunnerError::Failed { + backend: ScreenBackend::Auto, + message: format!( + "failed to load screen idle blank config from {}: {err}", + config_path.display() + ), + })?; + let entries = parse_config_entries(&contents); + let policy = entries + .get("screen_idle_blank") + .and_then(|value| value.parse::().ok()) + .unwrap_or(ScreenIdleBlankPolicy::Enabled); + + Ok(policy.is_enabled()) +} + +fn run_passive_session_agent(_writer: &mut W) -> Result<(), SessionRunnerError> { + let started = Instant::now(); + let test_timeout = resolve_gnome_monitor_test_timeout(); + + loop { + if test_timeout_reached(started, test_timeout) { + return Ok(()); + } + + thread::sleep(passive_sleep_duration(started, test_timeout)); } } +fn wait_for_backend_retry_or_test_timeout(started: Instant, test_timeout: Option) { + if test_timeout_reached(started, test_timeout) { + return; + } + + thread::sleep(passive_sleep_duration(started, test_timeout)); +} + +fn passive_sleep_duration(started: Instant, test_timeout: Option) -> Duration { + let mut sleep_for = SESSION_AGENT_BACKEND_RETRY_INTERVAL; + if let Some(timeout) = test_timeout { + sleep_for = sleep_for.min(timeout.saturating_sub(started.elapsed())); + } + + if sleep_for.is_zero() { + Duration::from_millis(1) + } else { + sleep_for + } +} + +fn test_timeout_reached(started: Instant, test_timeout: Option) -> bool { + test_timeout.is_some_and(|timeout| started.elapsed() >= timeout) +} + fn run_gnome_monitor( writer: &mut W, dispatcher: &mut SessionEventDispatcher, diff --git a/crates/lg-buddy/src/settings.rs b/crates/lg-buddy/src/settings.rs index 9434fa7..8bb1284 100644 --- a/crates/lg-buddy/src/settings.rs +++ b/crates/lg-buddy/src/settings.rs @@ -39,6 +39,7 @@ const SCREEN_RESTORE_POLICY_ALIASES: &[SettingAlias] = &[SettingAlias { const TV_INPUT_VALUES: &[&str] = &["HDMI_1", "HDMI_2", "HDMI_3", "HDMI_4"]; const SCREEN_BACKEND_VALUES: &[&str] = &["auto", "gnome", "swayidle"]; +const SCREEN_IDLE_BLANK_VALUES: &[&str] = &["enabled", "disabled"]; const SCREEN_RESTORE_POLICY_VALUES: &[&str] = &["conservative", "aggressive"]; const SYSTEM_SLEEP_WAKE_POLICY_VALUES: &[&str] = &["enabled", "disabled"]; const UPDATE_AUTO_CHECK_VALUES: &[&str] = &["enabled", "disabled"]; @@ -95,6 +96,20 @@ const SETTING_DEFINITIONS: &[SettingDefinition] = &[ apply_strategy: ApplyStrategy::RestartUserScreenService, description: "Screen backend selection for user-session blanking and restore behavior.", }, + SettingDefinition { + key: "screen.idle_blank", + storage_key: "screen_idle_blank", + fallback_storage_keys: EMPTY_STORAGE_KEYS, + value_type: SettingType::Enum(EnumSettingType { + values: SCREEN_IDLE_BLANK_VALUES, + aliases: EMPTY_ALIASES, + }), + default_value: Some(SettingValue::Enum("enabled")), + mutability: SettingMutability::ReadWrite, + operations: READ_WRITE_OPERATIONS, + apply_strategy: ApplyStrategy::RestartUserScreenService, + description: "Idle-driven blanking and restore behavior for the configured screen.", + }, SettingDefinition { key: "screen.idle_timeout", storage_key: "screen_idle_timeout", @@ -2215,6 +2230,7 @@ mod tests { "tv.mac", "tv.input", "screen.backend", + "screen.idle_blank", "screen.idle_timeout", "screen.restore_policy", "system.sleep_wake_policy", @@ -2239,6 +2255,7 @@ mod tests { ("tv.mac", "tvs_primary_mac"), ("tv.input", "tvs_primary_input"), ("screen.backend", "screen_backend"), + ("screen.idle_blank", "screen_idle_blank"), ("screen.idle_timeout", "screen_idle_timeout"), ("screen.restore_policy", "screen_restore_policy"), ("system.sleep_wake_policy", "system_sleep_wake_policy"), @@ -2263,6 +2280,7 @@ mod tests { "tv.mac | storage=tvs_primary_mac | fallbacks=tv_mac | type=mac-address | default=required | mutability=read-write | ops=get,describe,set | apply=no-runtime-apply-required | description=MAC address of the primary configured TV for Wake-on-LAN.", "tv.input | storage=tvs_primary_input | fallbacks=input | type=enum values=HDMI_1,HDMI_2,HDMI_3,HDMI_4 aliases=(none) | default=required | mutability=read-write | ops=get,describe,set | apply=no-runtime-apply-required | description=HDMI input used by the primary configured TV.", "screen.backend | storage=screen_backend | fallbacks=(none) | type=enum values=auto,gnome,swayidle aliases=(none) | default=auto | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Screen backend selection for user-session blanking and restore behavior.", + "screen.idle_blank | storage=screen_idle_blank | fallbacks=(none) | type=enum values=enabled,disabled aliases=(none) | default=enabled | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Idle-driven blanking and restore behavior for the configured screen.", "screen.idle_timeout | storage=screen_idle_timeout | fallbacks=(none) | type=integer range=1..=86400 | default=300 | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Idle timeout in seconds before LG Buddy blanks the configured screen.", "screen.restore_policy | storage=screen_restore_policy | fallbacks=(none) | type=enum values=conservative,aggressive aliases=marker_only->conservative | default=conservative | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Screen restore policy after LG Buddy blanks the configured screen.", "system.sleep_wake_policy | storage=system_sleep_wake_policy | fallbacks=(none) | type=enum values=enabled,disabled aliases=(none) | default=enabled | mutability=read-write | ops=get,describe,set,unset | apply=runtime-policy-only | description=System sleep and wake policy for lifecycle hooks.", @@ -2352,6 +2370,7 @@ tv.ip= (missing, read-write, ops: get,describe,set) tv.mac= (missing, read-write, ops: get,describe,set) tv.input= (missing, read-write, ops: get,describe,set) screen.backend=gnome (config.env, read-write, ops: get,describe,set,unset) +screen.idle_blank=enabled (default, read-write, ops: get,describe,set,unset) screen.idle_timeout=300 (default, read-write, ops: get,describe,set,unset) screen.restore_policy=conservative (default, read-write, ops: get,describe,set,unset) system.sleep_wake_policy=disabled (config.env, read-write, ops: get,describe,set,unset) @@ -2467,6 +2486,18 @@ screen.backend apply: restart-user-screen-service description: Screen backend selection for user-session blanking and restore behavior. +screen.idle_blank + storage key: screen_idle_blank + type: enum + current: enabled + source: default + default: enabled + mutability: read-write + supported operations: get, describe, set, unset + allowed values: enabled, disabled + apply: restart-user-screen-service + description: Idle-driven blanking and restore behavior for the configured screen. + screen.idle_timeout storage key: screen_idle_timeout type: integer @@ -3139,6 +3170,7 @@ tvs_primary_ip=192.0.2.43 "/tmp/config.env", "\ screen_backend=gnome + screen_idle_blank=disabled screen_idle_timeout=450 screen_restore_policy=aggressive system_sleep_wake_policy=disabled @@ -3150,6 +3182,10 @@ tvs_primary_ip=192.0.2.43 assert_eq!(backend.value(), Some(SettingValue::Enum("gnome"))); assert_eq!(backend.source(), SettingSource::ConfigEnv); + let idle_blank = store.effective_by_name("screen.idle_blank").unwrap(); + assert_eq!(idle_blank.value(), Some(SettingValue::Enum("disabled"))); + assert_eq!(idle_blank.source(), SettingSource::ConfigEnv); + let idle_timeout = store.effective_by_name("screen.idle_timeout").unwrap(); assert_eq!(idle_timeout.value(), Some(SettingValue::Integer(450))); assert_eq!(idle_timeout.source(), SettingSource::ConfigEnv); @@ -3229,6 +3265,7 @@ tvs_primary_ip=192.0.2.43 "/tmp/config.env", "\ screen_backend=not-a-backend + screen_idle_blank=not-a-policy screen_idle_timeout=not-a-number screen_restore_policy=not-a-policy ", @@ -3240,6 +3277,11 @@ tvs_primary_ip=192.0.2.43 assert_eq!(backend.source(), SettingSource::InvalidConfigEnv); assert_eq!(backend.invalid_value(), Some("not-a-backend")); + let idle_blank = store.effective_by_name("screen.idle_blank").unwrap(); + assert_eq!(idle_blank.value(), None); + assert_eq!(idle_blank.source(), SettingSource::InvalidConfigEnv); + assert_eq!(idle_blank.invalid_value(), Some("not-a-policy")); + let idle_timeout = store.effective_by_name("screen.idle_timeout").unwrap(); assert_eq!(idle_timeout.value(), None); assert_eq!(idle_timeout.source(), SettingSource::InvalidConfigEnv); @@ -3361,6 +3403,7 @@ tvs_primary_ip=192.0.2.43 "tv.mac", "tv.input", "screen.backend", + "screen.idle_blank", "screen.idle_timeout", "screen.restore_policy", "system.sleep_wake_policy", @@ -3375,6 +3418,7 @@ tvs_primary_ip=192.0.2.43 "", "", "gnome", + "enabled", "300", "conservative", "disabled", @@ -3391,6 +3435,7 @@ tvs_primary_ip=192.0.2.43 SettingSource::ConfigEnv, SettingSource::Default, SettingSource::Default, + SettingSource::Default, SettingSource::ConfigEnv, SettingSource::Default, SettingSource::Default, @@ -3402,8 +3447,12 @@ tvs_primary_ip=192.0.2.43 fn key_parser_accepts_supported_dotted_names() { for key in [ "screen.backend", + "screen.idle_blank", "screen.idle_timeout", + "screen.restore_policy", "system.sleep_wake_policy", + "updates.auto_check", + "updates.channel", ] { assert_eq!(SettingKey::parse(key).unwrap().as_str(), key); } diff --git a/crates/lg-buddy/src/sources/linux/network_manager.rs b/crates/lg-buddy/src/sources/linux/network_manager.rs index aaa9382..93571e6 100644 --- a/crates/lg-buddy/src/sources/linux/network_manager.rs +++ b/crates/lg-buddy/src/sources/linux/network_manager.rs @@ -71,7 +71,8 @@ mod tests { use super::{handle_pre_down_with, network_teardown_event_from_logind_property}; use crate::config::{ - Config, HdmiInput, MacAddress, ScreenBackend, ScreenRestorePolicy, SystemSleepWakePolicy, + Config, HdmiInput, MacAddress, ScreenBackend, ScreenIdleBlankPolicy, ScreenRestorePolicy, + SystemSleepWakePolicy, }; use crate::events::{EventSource, RuntimeEvent, RuntimeEventKind}; use crate::lifecycle::Sleeper; @@ -359,6 +360,7 @@ mod tests { .expect("parse mac"), input: HdmiInput::Hdmi2, screen_backend: ScreenBackend::Auto, + screen_idle_blank: ScreenIdleBlankPolicy::Enabled, screen_idle_timeout: 300, screen_restore_policy: ScreenRestorePolicy::MarkerOnly, system_sleep_wake_policy, diff --git a/crates/lg-buddy/tests/cucumber_support/steps.rs b/crates/lg-buddy/tests/cucumber_support/steps.rs index 2d77f46..a5cd6ee 100644 --- a/crates/lg-buddy/tests/cucumber_support/steps.rs +++ b/crates/lg-buddy/tests/cucumber_support/steps.rs @@ -11,6 +11,11 @@ fn screen_restore_policy(world: &mut LgBuddyWorld, policy: String) { world.set_screen_restore_policy(&policy); } +#[given(regex = r#"screen idle blanking is "(enabled|disabled)""#)] +fn screen_idle_blanking(world: &mut LgBuddyWorld, policy: String) { + world.set_screen_idle_blank(&policy); +} + #[given(regex = r#"the idle timeout is (\d+) seconds"#)] fn idle_timeout_seconds(world: &mut LgBuddyWorld, seconds: u64) { world.set_idle_timeout_secs(seconds); diff --git a/crates/lg-buddy/tests/cucumber_support/world.rs b/crates/lg-buddy/tests/cucumber_support/world.rs index 5d1ef35..ddb91de 100644 --- a/crates/lg-buddy/tests/cucumber_support/world.rs +++ b/crates/lg-buddy/tests/cucumber_support/world.rs @@ -68,6 +68,13 @@ impl LgBuddyWorld { .append_line(&format!("screen_restore_policy={policy}")); } + pub fn set_screen_idle_blank(&self, policy: &str) { + self.config + .as_ref() + .expect("temporary config should be present") + .append_line(&format!("screen_idle_blank={policy}")); + } + pub fn set_idle_timeout_secs(&mut self, seconds: u64) { self.ensure_env() .set("LG_BUDDY_IDLE_TIMEOUT", seconds.to_string()); diff --git a/crates/lg-buddy/tests/features/monitor_gnome.feature b/crates/lg-buddy/tests/features/monitor_gnome.feature index 8eeb268..a66c547 100644 --- a/crates/lg-buddy/tests/features/monitor_gnome.feature +++ b/crates/lg-buddy/tests/features/monitor_gnome.feature @@ -1,6 +1,20 @@ Feature: GNOME monitor LG Buddy should translate GNOME session signals and idle-monitor activity into TV behavior. + Scenario: disabled idle blanking keeps the session agent passive + Given a temporary LG Buddy config using input HDMI_2 + And screen idle blanking is "disabled" + And LG Buddy session runtime is isolated + And a mock TV client + And the TV is on input HDMI_2 + And the executable PATH is isolated + And GNOME monitor stays open for 0.1 seconds + When I run the command "monitor" + Then the command succeeds + And stdout contains "screen idle blanking is disabled by config" + And the TV client did not receive "get_input" + And the TV client did not receive "turn_screen_off" + Scenario: GNOME ScreenSaver idle still blanks the configured TV input Given a temporary LG Buddy config using input HDMI_2 And LG Buddy session runtime is isolated diff --git a/crates/lg-buddy/tests/features/settings.feature b/crates/lg-buddy/tests/features/settings.feature index e0ce205..ebc1dc0 100644 --- a/crates/lg-buddy/tests/features/settings.feature +++ b/crates/lg-buddy/tests/features/settings.feature @@ -9,6 +9,7 @@ Feature: Settings CLI And stdout contains "tv.mac=aa:bb:cc:dd:ee:ff (config.env, read-write, ops: get,describe,set)" And stdout contains "tv.input=HDMI_2 (config.env, read-write, ops: get,describe,set)" And stdout contains "screen.backend=auto (config.env, read-write, ops: get,describe,set,unset)" + And stdout contains "screen.idle_blank=enabled (default, read-write, ops: get,describe,set,unset)" And stdout contains "screen.restore_policy=conservative (default, read-write, ops: get,describe,set,unset)" And stdout contains "system.sleep_wake_policy=enabled (default, read-write, ops: get,describe,set,unset)" And stdout contains "updates.auto_check=enabled (default, read-write, ops: get,describe,set,unset)" @@ -31,6 +32,15 @@ Feature: Settings CLI And stdout contains "mutability: read-write" And stdout contains "supported operations: get, describe, set, unset" + Scenario: settings describe shows idle blank policy operations + Given a temporary LG Buddy config using input HDMI_2 + When I run the command "settings describe screen.idle_blank" + Then the command succeeds + And stdout contains "screen.idle_blank" + And stdout contains "storage key: screen_idle_blank" + And stdout contains "allowed values: enabled, disabled" + And stdout contains "apply: restart-user-screen-service" + Scenario: settings describe shows update check operations Given a temporary LG Buddy config using input HDMI_2 When I run the command "settings describe updates.auto_check" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 00a7590..4f9048d 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -202,6 +202,7 @@ The intended split is: - session-owned update notification dispatch through `org.freedesktop.Notifications` - update notification action handling for `View Release` + - hosted by the user-session `monitor` process - `screen.rs` - pure session screen blank and restore policy decisions over already-read observations @@ -261,6 +262,9 @@ The intended split is: - system-bus use for the logind lifecycle runtime - `session/runner.rs` - backend-neutral monitor and lifecycle runners + - starts the user-session notification surface before screen backend work + - keeps the user-session process alive when idle blanking is disabled or a + screen backend is temporarily unavailable - combines backend observations with the inactivity engine - dispatches semantic session events into screen and lifecycle policy - runs delegated `swayidle` by invoking the current executable's @@ -297,6 +301,8 @@ The session-facing pieces should be read as one subsystem: - see [gamepad-subsystem.md](gamepad-subsystem.md) for adapter and lifecycle details - `session/runner.rs` - consumes normalized session events and idletime observations and dispatches runtime policy + - treats `screen_idle_blank=disabled` as a passive user-session mode that + preserves update notification handoff without TV idle blank/restore actions - treats delegated `swayidle` as a CLI/API client for timeout/resume actions - owns the `lifecycle` event loop for system sleep/wake handling - `sources/linux/logind.rs` diff --git a/docs/defaults-and-configuration.md b/docs/defaults-and-configuration.md index b2905f2..524eeb6 100644 --- a/docs/defaults-and-configuration.md +++ b/docs/defaults-and-configuration.md @@ -77,6 +77,7 @@ tvs_primary_ip=192.168.1.100 tvs_primary_mac=aa:bb:cc:dd:ee:ff tvs_primary_input=HDMI_2 screen_restore_policy=conservative +screen_idle_blank=enabled system_sleep_wake_policy=enabled updates_auto_check=enabled updates_channel=auto @@ -113,6 +114,15 @@ choice. - it is writeable through `lg-buddy settings set screen.restore_policy ` because the command can apply screen-monitor changes +`screen_idle_blank` follows the same policy model: + +- automatic session idle blank/restore defaults to enabled +- users who want update notifications without idle-driven TV control can set + `screen_idle_blank=disabled` +- the installed user-session service still runs so notification handoff remains + available +- the supported values are `enabled` and `disabled` + `system_sleep_wake_policy` follows the same model: - automatic system sleep/wake handling should default to enabled diff --git a/docs/user-guide.md b/docs/user-guide.md index 6f0cdb0..2b9b782 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -67,7 +67,9 @@ policy decision; a newer release can notify again. `updates background-check` is the service-owned path used by the installed user timer. It reads update settings, skips all update work when automatic checks are disabled, and otherwise uses the same release API, cache, notification handoff, -and repeat-notification policy as `updates check --notify`. +and repeat-notification policy as `updates check --notify`. The notification +handoff expects the installed user-session service, +`LG_Buddy_screen.service`, to be running. `lifecycle`, `nm-pre-down`, `sleep-pre`, and `startup wake` are normally service-owned system lifecycle commands. They are documented for @@ -80,6 +82,14 @@ LG Buddy supports two session backends: - `gnome` - `swayidle` +`LG_Buddy_screen.service` is the user-session service. It owns the LG Buddy +session D-Bus surface used by update notifications and, when idle blanking is +enabled, it also runs screen idle/restore monitoring. + +`screen_idle_blank=enabled` turns on automatic idle blank/restore behavior. +`screen_idle_blank=disabled` keeps the user-session service running for +notifications but makes idle blank/restore behavior passive. + `screen_backend=auto` prefers GNOME when the current session satisfies the full GNOME contract, then falls back to `swayidle` if installed. The GNOME backend requires: @@ -170,6 +180,7 @@ To change supported settings: ```bash lg-buddy settings set tv.input HDMI_2 +lg-buddy settings set screen.idle_blank disabled lg-buddy settings set screen.idle_timeout 600 lg-buddy settings set screen.restore_policy aggressive lg-buddy settings set updates.auto_check disabled @@ -178,7 +189,7 @@ lg-buddy settings unset screen.restore_policy ``` `set` and `unset` write `config.env` and then apply the setting when an explicit -runtime apply step is needed. Screen-monitor settings restart +runtime apply step is needed. User-session screen settings restart `LG_Buddy_screen.service` when the user service is installed and active or enabled. `updates.auto_check` enables or disables the installed user timer for background update checks. TV identity, system sleep/wake policy, and update @@ -204,6 +215,7 @@ Current config keys: - `tvs_primary_ip` - `tvs_primary_mac` - `tvs_primary_input` +- `screen_idle_blank` - `screen_backend` - `screen_idle_timeout` - `screen_restore_policy` @@ -229,6 +241,7 @@ Current structured settings: | `tv.mac` | `tvs_primary_mac` | `get`, `describe`, `set` | | `tv.input` | `tvs_primary_input` | `get`, `describe`, `set` | | `screen.backend` | `screen_backend` | `get`, `describe`, `set`, `unset` | +| `screen.idle_blank` | `screen_idle_blank` | `get`, `describe`, `set`, `unset` | | `screen.idle_timeout` | `screen_idle_timeout` | `get`, `describe`, `set`, `unset` | | `screen.restore_policy` | `screen_restore_policy` | `get`, `describe`, `set`, `unset` | | `system.sleep_wake_policy` | `system_sleep_wake_policy` | `get`, `describe`, `set`, `unset` | @@ -240,6 +253,14 @@ storage keys are profile-shaped only to leave room for future storage growth; this version does not expose multiple TVs or TV profile selection. These values are required, so `unset` is not supported. +`screen_idle_blank` controls whether the user-session service performs automatic +idle-driven blank/restore behavior: + +- `enabled`: default behavior, run the configured screen backend and control + the TV around session idle/activity +- `disabled`: keep the user-session service running for notification handling, + but skip idle-driven TV blank/restore behavior + `screen_idle_timeout` is the inactivity threshold in seconds used by the session monitor. LG Buddy currently uses that timeout for both the GNOME and `swayidle` backends. Values above 86400 seconds are capped at 86400 seconds. @@ -286,6 +307,8 @@ Example: tvs_primary_ip=192.168.1.100 tvs_primary_mac=aa:bb:cc:dd:ee:ff tvs_primary_input=HDMI_2 +screen_idle_blank=enabled +screen_backend=auto screen_idle_timeout=300 screen_restore_policy=aggressive system_sleep_wake_policy=enabled diff --git a/install.sh b/install.sh index d11d57d..34911e7 100755 --- a/install.sh +++ b/install.sh @@ -59,6 +59,7 @@ MISSING_PKGS=() SCREEN_MONITOR_AVAILABLE=0 SCREEN_MONITOR_CONFIGURED_BACKEND="auto" SCREEN_MONITOR_RUNTIME_BACKEND="" +SCREEN_IDLE_BLANK="enabled" SYSTEM_CONFIG_OVERRIDE_TMP="" CONFIG_POINTER_TMP="" NM_HOOK_TMP="" @@ -283,6 +284,11 @@ if [ ! -x "$SCRIPT_DIR/configure.sh" ]; then fi "$SCRIPT_DIR/configure.sh" CONFIG_FILE="$(bash "$SCRIPT_DIR/bin/LG_Buddy_Common" --user-config-path)" +SCREEN_IDLE_BLANK="$(sed -n 's/^screen_idle_blank=//p' "$CONFIG_FILE" | tail -n1)" +case "$SCREEN_IDLE_BLANK" in + enabled|disabled) ;; + *) SCREEN_IDLE_BLANK="enabled" ;; +esac SCREEN_MONITOR_CONFIGURED_BACKEND="$(sed -n 's/^screen_backend=//p' "$CONFIG_FILE" | tail -n1)" SCREEN_MONITOR_CONFIGURED_BACKEND="${SCREEN_MONITOR_CONFIGURED_BACKEND:-auto}" SYSTEM_SLEEP_WAKE_POLICY="$(sed -n 's/^system_sleep_wake_policy=//p' "$CONFIG_FILE" | tail -n1)" @@ -299,46 +305,52 @@ echo "Using configuration file at $CONFIG_FILE" echo "Configuration complete." echo "" -echo "Checking screen idle/resume backend for configured mode ($SCREEN_MONITOR_CONFIGURED_BACKEND)..." -case "$SCREEN_MONITOR_CONFIGURED_BACKEND" in - gnome) - SCREEN_MONITOR_AVAILABLE=1 - SCREEN_MONITOR_RUNTIME_BACKEND="$(LG_BUDDY_SCREEN_BACKEND=gnome "$RUNTIME_BINARY" detect-backend 2>/dev/null || true)" - if [ "$SCREEN_MONITOR_RUNTIME_BACKEND" = "gnome" ]; then - echo " [OK] current session satisfies the GNOME backend contract" - else - SCREEN_MONITOR_RUNTIME_BACKEND="" - echo " [INFO] current session did not verify the full GNOME backend contract" - echo " GNOME requires GNOME Shell, org.gnome.ScreenSaver, and org.gnome.Mutter.IdleMonitor." - echo " The user service will start automatically in a compatible GNOME session." - fi - ;; - swayidle) - if command -v swayidle &>/dev/null; then - echo " [OK] swayidle (configured backend)" - SCREEN_MONITOR_AVAILABLE=1 - SCREEN_MONITOR_RUNTIME_BACKEND="swayidle" - else - echo " [MISSING] swayidle (required for the configured backend)" - fi - ;; - *) - if command -v swayidle &>/dev/null; then - echo " [OK] swayidle (wlroots/COSMIC backend)" +if [ "$SCREEN_IDLE_BLANK" = "disabled" ]; then + echo "Screen idle blanking is disabled by config; user-session service will still run for notifications." +else + echo "Checking screen idle/resume backend for configured mode ($SCREEN_MONITOR_CONFIGURED_BACKEND)..." + case "$SCREEN_MONITOR_CONFIGURED_BACKEND" in + gnome) SCREEN_MONITOR_AVAILABLE=1 - else - echo " [OPTIONAL] swayidle (required for wlroots/COSMIC backend)" - fi + SCREEN_MONITOR_RUNTIME_BACKEND="$(LG_BUDDY_SCREEN_BACKEND=gnome "$RUNTIME_BINARY" detect-backend 2>/dev/null || true)" + if [ "$SCREEN_MONITOR_RUNTIME_BACKEND" = "gnome" ]; then + echo " [OK] current session satisfies the GNOME backend contract" + else + SCREEN_MONITOR_RUNTIME_BACKEND="" + echo " [INFO] current session did not verify the full GNOME backend contract" + echo " GNOME requires GNOME Shell, org.gnome.ScreenSaver, and org.gnome.Mutter.IdleMonitor." + echo " The user-session service will retry until a compatible session is available." + fi + ;; + swayidle) + if command -v swayidle &>/dev/null; then + echo " [OK] swayidle (configured backend)" + SCREEN_MONITOR_AVAILABLE=1 + SCREEN_MONITOR_RUNTIME_BACKEND="swayidle" + else + echo " [MISSING] swayidle (required for the configured backend)" + echo " The user-session service will retry until swayidle is available." + fi + ;; + *) + if command -v swayidle &>/dev/null; then + echo " [OK] swayidle (wlroots/COSMIC backend)" + SCREEN_MONITOR_AVAILABLE=1 + else + echo " [OPTIONAL] swayidle (required for wlroots/COSMIC backend)" + fi - SCREEN_MONITOR_RUNTIME_BACKEND="$("$RUNTIME_BINARY" detect-backend 2>/dev/null || true)" - if [ -n "$SCREEN_MONITOR_RUNTIME_BACKEND" ]; then - SCREEN_MONITOR_AVAILABLE=1 - echo " [OK] current session backend: $SCREEN_MONITOR_RUNTIME_BACKEND" - else - echo " [INFO] no supported backend detected in the current session" - fi - ;; -esac + SCREEN_MONITOR_RUNTIME_BACKEND="$("$RUNTIME_BINARY" detect-backend 2>/dev/null || true)" + if [ -n "$SCREEN_MONITOR_RUNTIME_BACKEND" ]; then + SCREEN_MONITOR_AVAILABLE=1 + echo " [OK] current session backend: $SCREEN_MONITOR_RUNTIME_BACKEND" + else + echo " [INFO] no supported backend detected in the current session" + echo " The user-session service will retry until a supported backend is available." + fi + ;; + esac +fi # 4. CREATE VIRTUAL ENVIRONMENT echo "Creating Python virtual environment at $VENV_DIR..." @@ -437,53 +449,28 @@ if [ "$SKIP_SYSTEMD_ACTIONS" != "1" ]; then fi if [ "$SKIP_SYSTEMD_ACTIONS" = "1" ]; then - echo "Skipping update check timer enable/start because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1." -elif [ "$UPDATE_AUTO_CHECK" = "enabled" ]; then - systemctl --user enable --now LG_Buddy_update_check.timer - echo "LG_Buddy_update_check.timer enabled and started." + echo "Skipping user service enable/start because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1." else - systemctl --user disable --now LG_Buddy_update_check.timer 2>/dev/null || true - echo "LG_Buddy_update_check.timer installed but disabled by config." -fi + systemctl --user enable LG_Buddy_screen.service + systemctl --user restart LG_Buddy_screen.service + if [ "$SCREEN_IDLE_BLANK" = "disabled" ]; then + echo "LG_Buddy_screen.service enabled and started for session notifications; idle blanking is disabled by config." + elif [ -n "$SCREEN_MONITOR_RUNTIME_BACKEND" ]; then + echo "LG_Buddy_screen.service enabled and started using the $SCREEN_MONITOR_RUNTIME_BACKEND backend." + elif [ "$SCREEN_MONITOR_AVAILABLE" -eq 1 ]; then + echo "LG_Buddy_screen.service enabled and started; it will retry until the configured screen backend is available." + else + echo "LG_Buddy_screen.service enabled and started for session notifications." + echo "It will retry idle blanking until a compatible screen backend is available." + fi -if [ "$SCREEN_MONITOR_AVAILABLE" -eq 1 ]; then - ENABLE_SCREEN_MONITOR="${LG_BUDDY_ENABLE_SCREEN_MONITOR:-}" - if [ -z "$ENABLE_SCREEN_MONITOR" ] && [ "$NONINTERACTIVE" != "1" ]; then - read -p "Enable the screen idle/resume monitor now? [Y/n] " ENABLE_SCREEN_MONITOR + if [ "$UPDATE_AUTO_CHECK" = "enabled" ]; then + systemctl --user enable --now LG_Buddy_update_check.timer + echo "LG_Buddy_update_check.timer enabled and started." + else + systemctl --user disable --now LG_Buddy_update_check.timer 2>/dev/null || true + echo "LG_Buddy_update_check.timer installed but disabled by config." fi - case "$ENABLE_SCREEN_MONITOR" in - [Nn]*|0|false|FALSE|False|no|NO|No) - echo "Leaving LG_Buddy_screen.service installed but disabled." - ;; - *) - if [ "$SKIP_SYSTEMD_ACTIONS" = "1" ]; then - echo "Skipping user service enable/restart because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1." - else - systemctl --user enable LG_Buddy_screen.service - if [ -n "$SCREEN_MONITOR_RUNTIME_BACKEND" ]; then - systemctl --user restart LG_Buddy_screen.service - echo "LG_Buddy_screen.service enabled and started using the $SCREEN_MONITOR_RUNTIME_BACKEND backend." - else - echo "LG_Buddy_screen.service enabled." - echo "It will start automatically the next time a supported graphical session is available." - fi - fi - ;; - esac -else - echo "No supported screen idle backend detected for the configured mode ($SCREEN_MONITOR_CONFIGURED_BACKEND)." - case "$SCREEN_MONITOR_CONFIGURED_BACKEND" in - gnome) - echo "Use a GNOME session with GNOME Shell, org.gnome.ScreenSaver, and org.gnome.Mutter.IdleMonitor." - echo "Then enable LG_Buddy_screen.service later." - ;; - swayidle) - echo "Install swayidle, then enable LG_Buddy_screen.service later." - ;; - *) - echo "Use a compatible GNOME session or install swayidle for wlroots/COSMIC, then enable LG_Buddy_screen.service later." - ;; - esac fi if [ "$SYSTEM_SLEEP_WAKE_POLICY" = "enabled" ]; then @@ -493,6 +480,6 @@ else fi echo "Installation complete!" -echo "The screen monitor service has been installed." +echo "The user-session service has been installed." echo "Please restart your computer for all changes to take full effect." echo "NOTE: On first use, you may need to accept a prompt on your TV to allow this application to connect." diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index d7a4442..1da705f 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -144,7 +144,6 @@ export LG_BUDDY_TV_IP="192.168.1.10" export LG_BUDDY_TV_MAC="aa:bb:cc:dd:ee:ff" export LG_BUDDY_INPUT="HDMI_2" export LG_BUDDY_SCREEN_BACKEND="auto" -export LG_BUDDY_ENABLE_SCREEN_MONITOR="0" export LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY="enabled" export PIP_DISABLE_PIP_VERSION_CHECK="1" export PIP_NO_PYTHON_VERSION_WARNING="1" @@ -212,6 +211,7 @@ fi grep -q '^tvs_primary_ip=192.168.1.10$' "$CONFIG_FILE" grep -q '^tvs_primary_mac=aa:bb:cc:dd:ee:ff$' "$CONFIG_FILE" grep -q '^tvs_primary_input=HDMI_2$' "$CONFIG_FILE" +grep -q '^screen_idle_blank=enabled$' "$CONFIG_FILE" grep -q '^screen_backend=auto$' "$CONFIG_FILE" grep -q '^system_sleep_wake_policy=enabled$' "$CONFIG_FILE" grep -q "$CONFIG_FILE" "$INSTALLED_POINTER" @@ -239,6 +239,7 @@ printf '%s\n' "$INSTALLED_VERSION_OUTPUT" | grep -q "^commit: " grep -q '^screen_idle_timeout=86400$' "$CONFIG_FILE" "$INSTALLED_BINARY" settings set screen.idle_timeout 900 "$INSTALLED_BINARY" settings set screen.restore_policy aggressive +"$INSTALLED_BINARY" settings set screen.idle_blank disabled "$INSTALLED_BINARY" settings set tv.ip 192.168.1.12 "$INSTALLED_BINARY" settings set tv.mac 22:33:44:55:66:77 "$INSTALLED_BINARY" settings set tv.input HDMI_4 @@ -246,6 +247,7 @@ grep -q '^screen_idle_timeout=86400$' "$CONFIG_FILE" "$INSTALLED_BINARY" settings set updates.auto_check disabled "$INSTALLED_BINARY" settings set updates.channel prerelease grep -q '^screen_backend=gnome$' "$CONFIG_FILE" +grep -q '^screen_idle_blank=disabled$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" grep -q '^tvs_primary_ip=192.168.1.12$' "$CONFIG_FILE" @@ -270,6 +272,7 @@ grep -q '^tvs_primary_ip=192.168.1.11$' "$CONFIG_FILE" grep -q '^tvs_primary_mac=11:22:33:44:55:66$' "$CONFIG_FILE" grep -q '^tvs_primary_input=HDMI_3$' "$CONFIG_FILE" grep -q '^screen_backend=gnome$' "$CONFIG_FILE" +grep -q '^screen_idle_blank=disabled$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" grep -q '^system_sleep_wake_policy=enabled$' "$CONFIG_FILE" @@ -364,6 +367,7 @@ assert_file "$USER_UPDATE_CHECK_OVERRIDE" } assert_executable "$NM_LIFECYCLE_HOOK" grep -q 'lg-buddy nm-pre-down' "$NM_LIFECYCLE_HOOK" +grep -q '^screen_idle_blank=enabled$' "$CONFIG_FILE" grep -q '^system_sleep_wake_policy=disabled$' "$CONFIG_FILE" ( From 99ef9a0910505a02b83f4345b62e1972761d6852 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sat, 9 May 2026 03:22:28 +0300 Subject: [PATCH 3/4] Address background update timer review --- README.md | 2 +- crates/lg-buddy/src/settings.rs | 57 +++++++++++++++++------ crates/lg-buddy/src/updates.rs | 70 ++++++++++++++--------------- docs/defaults-and-configuration.md | 2 + docs/user-guide.md | 2 +- install.sh | 9 +++- scripts/test-release-bundle.sh | 4 ++ systemd/LG_Buddy_update_check.timer | 8 ++-- 8 files changed, 97 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index c55014b..26d4f99 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ LG Buddy is mostly automatic after installation. - To inspect the installed runtime version, run `lg-buddy --version` - To check GitHub releases on demand, run `lg-buddy updates check`; add `--notify` to send a desktop notification when an update is available -- Background update checks are installed by default; opt out with +- Weekly background update checks are installed by default; opt out with `lg-buddy settings set updates.auto_check disabled` - To rerun full setup for TV IP, MAC address, or HDMI input, run `./configure.sh` - To check the user-session service, run `systemctl --user status LG_Buddy_screen.service` diff --git a/crates/lg-buddy/src/settings.rs b/crates/lg-buddy/src/settings.rs index 8bb1284..dc28062 100644 --- a/crates/lg-buddy/src/settings.rs +++ b/crates/lg-buddy/src/settings.rs @@ -703,6 +703,7 @@ impl SettingsChange { #[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsApplyOutcome { Restarted { service: &'static str }, + Enabled { unit: &'static str }, EnabledStarted { unit: &'static str }, DisabledStopped { unit: &'static str }, NotInstalled { service: &'static str }, @@ -715,6 +716,7 @@ impl fmt::Display for SettingsApplyOutcome { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Restarted { service } => write!(f, "restarted {service}"), + Self::Enabled { unit } => write!(f, "enabled {unit}"), Self::EnabledStarted { unit } => write!(f, "enabled and started {unit}"), Self::DisabledStopped { unit } => write!(f, "disabled and stopped {unit}"), Self::NotInstalled { service } => { @@ -740,6 +742,12 @@ pub enum UserServiceState { ActiveOrEnabled, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserUnitEnableOutcome { + Enabled, + EnabledStarted, +} + pub trait ServiceController { fn systemd_actions_disabled(&self) -> bool { false @@ -749,7 +757,7 @@ pub trait ServiceController { fn restart_user_service(&self, service: &str) -> Result<(), SettingsError>; - fn enable_start_user_unit(&self, unit: &str) -> Result<(), SettingsError>; + fn enable_start_user_unit(&self, unit: &str) -> Result; fn disable_stop_user_unit(&self, unit: &str) -> Result<(), SettingsError>; } @@ -840,8 +848,17 @@ impl ServiceController for SystemdUserServiceController { self.run_user_systemctl(&["restart", service]) } - fn enable_start_user_unit(&self, unit: &str) -> Result<(), SettingsError> { - self.run_user_systemctl(&["enable", "--now", unit]) + fn enable_start_user_unit(&self, unit: &str) -> Result { + self.run_user_systemctl(&["enable", unit])?; + if self + .user_systemctl_status(&["is-active", "--quiet", "graphical-session.target"]) + .unwrap_or(false) + { + self.run_user_systemctl(&["start", unit])?; + Ok(UserUnitEnableOutcome::EnabledStarted) + } else { + Ok(UserUnitEnableOutcome::Enabled) + } } fn disable_stop_user_unit(&self, unit: &str) -> Result<(), SettingsError> { @@ -933,11 +950,10 @@ impl SettingsApplier { }), UserServiceState::InactiveDisabled => { if enabled { - self.service_controller + let outcome = self + .service_controller .enable_start_user_unit(UPDATE_CHECK_TIMER_NAME)?; - Ok(SettingsApplyOutcome::EnabledStarted { - unit: UPDATE_CHECK_TIMER_NAME, - }) + Ok(settings_enable_outcome(UPDATE_CHECK_TIMER_NAME, outcome)) } else { self.service_controller .disable_stop_user_unit(UPDATE_CHECK_TIMER_NAME)?; @@ -948,11 +964,10 @@ impl SettingsApplier { } UserServiceState::ActiveOrEnabled => { if enabled { - self.service_controller + let outcome = self + .service_controller .enable_start_user_unit(UPDATE_CHECK_TIMER_NAME)?; - Ok(SettingsApplyOutcome::EnabledStarted { - unit: UPDATE_CHECK_TIMER_NAME, - }) + Ok(settings_enable_outcome(UPDATE_CHECK_TIMER_NAME, outcome)) } else { self.service_controller .disable_stop_user_unit(UPDATE_CHECK_TIMER_NAME)?; @@ -965,6 +980,16 @@ impl SettingsApplier { } } +fn settings_enable_outcome( + unit: &'static str, + outcome: UserUnitEnableOutcome, +) -> SettingsApplyOutcome { + match outcome { + UserUnitEnableOutcome::Enabled => SettingsApplyOutcome::Enabled { unit }, + UserUnitEnableOutcome::EnabledStarted => SettingsApplyOutcome::EnabledStarted { unit }, + } +} + #[derive(Debug)] pub struct SettingsCommandRunner { store: SettingsStore, @@ -2201,7 +2226,8 @@ mod tests { format_effective_value, ApplyStrategy, ConfigEnvReader, ConfigPathResolver, ServiceController, SettingKey, SettingMutability, SettingOperation, SettingSource, SettingType, SettingValue, SettingsApplier, SettingsCommand, SettingsCommandRunner, - SettingsError, SettingsParseError, SettingsStore, UserServiceState, SETTINGS_REGISTRY, + SettingsError, SettingsParseError, SettingsStore, UserServiceState, UserUnitEnableOutcome, + SETTINGS_REGISTRY, }; use crate::config::{ConfigPathSources, MAX_IDLE_TIMEOUT}; use std::cell::Cell; @@ -3837,7 +3863,10 @@ tvs_primary_ip=192.0.2.43 } } - fn enable_start_user_unit(&self, _unit: &str) -> Result<(), SettingsError> { + fn enable_start_user_unit( + &self, + _unit: &str, + ) -> Result { self.enables.set(self.enables.get() + 1); if let Some(message) = self.unit_action_error { @@ -3845,7 +3874,7 @@ tvs_primary_ip=192.0.2.43 message: message.to_string(), }) } else { - Ok(()) + Ok(UserUnitEnableOutcome::EnabledStarted) } } diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index af9599f..2d424f9 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -66,6 +66,13 @@ impl UpdatesCommand { Self::BackgroundCheck => true, } } + + fn check_channel(&self) -> Option { + match self { + Self::Check { channel, .. } => *channel, + Self::BackgroundCheck => None, + } + } } fn parse_background_check_args(args: I) -> Result @@ -1102,7 +1109,7 @@ fn run_updates_command_with_background_settings< } }; let result = check_updates_with_cache( - command, + command.check_channel(), context.version, context.client, &mut cache, @@ -1165,45 +1172,36 @@ fn check_updates( client: &C, ) -> Result { let mut cache = UpdateCheckCache::default(); - check_updates_with_cache(command, current, client, &mut cache, current_unix_seconds()) + check_updates_with_cache( + command.check_channel(), + current, + client, + &mut cache, + current_unix_seconds(), + ) } fn check_updates_with_cache( - command: UpdatesCommand, + channel: Option, current: VersionInfo, client: &C, cache: &mut UpdateCheckCache, now_unix_seconds: u64, ) -> Result { - match command { - UpdatesCommand::Check { channel, .. } => { - let channel = channel.unwrap_or_else(|| UpdateChannel::default_for(current)); - let current_version = Version::parse(current.version()).map_err(|source| { - UpdatesError::InvalidLocalVersion { - version: current.version().to_string(), - source, - } - })?; - let latest = fetch_latest_release(channel, current, client, cache, now_unix_seconds)?; - - Ok(UpdateCheckResult { - check_channel: channel, - current_version, - current_channel: current.channel(), - latest, - }) - } - UpdatesCommand::BackgroundCheck => check_updates_with_cache( - UpdatesCommand::Check { - channel: None, - notify: true, - }, - current, - client, - cache, - now_unix_seconds, - ), - } + let channel = channel.unwrap_or_else(|| UpdateChannel::default_for(current)); + let current_version = + Version::parse(current.version()).map_err(|source| UpdatesError::InvalidLocalVersion { + version: current.version().to_string(), + source, + })?; + let latest = fetch_latest_release(channel, current, client, cache, now_unix_seconds)?; + + Ok(UpdateCheckResult { + check_channel: channel, + current_version, + current_channel: current.channel(), + latest, + }) } fn fetch_latest_release( @@ -1997,7 +1995,7 @@ mod tests { ); let result = check_updates_with_cache( - check(Some(UpdateChannel::Stable)), + Some(UpdateChannel::Stable), version_info("1.1.0", ReleaseChannel::Stable), &client, &mut cache, @@ -2080,7 +2078,7 @@ mod tests { ); let result = check_updates_with_cache( - check(Some(UpdateChannel::Stable)), + Some(UpdateChannel::Stable), version_info("1.1.0", ReleaseChannel::Stable), &client, &mut cache, @@ -2125,7 +2123,7 @@ mod tests { ); let result = check_updates_with_cache( - check(Some(UpdateChannel::Prerelease)), + Some(UpdateChannel::Prerelease), version_info("1.2.0-beta.1", ReleaseChannel::Prerelease), &client, &mut cache, @@ -2152,7 +2150,7 @@ mod tests { let mut cache = UpdateCheckCache::default(); let err = check_updates_with_cache( - check(Some(UpdateChannel::Stable)), + Some(UpdateChannel::Stable), version_info("1.1.0", ReleaseChannel::Stable), &client, &mut cache, diff --git a/docs/defaults-and-configuration.md b/docs/defaults-and-configuration.md index 524eeb6..27ad0dc 100644 --- a/docs/defaults-and-configuration.md +++ b/docs/defaults-and-configuration.md @@ -137,6 +137,8 @@ choice. `updates_auto_check` follows the same opt-out model: - automatic update checks should default to enabled +- background checks should be low-frequency, currently weekly with randomized + delay - the installer should not ask every user whether update checks should run - users who do not want background checks should opt out through `config.env` or `lg-buddy settings set updates.auto_check disabled` diff --git a/docs/user-guide.md b/docs/user-guide.md index 2b9b782..d30397c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -287,7 +287,7 @@ runtime policy without reinstalling services. `updates_auto_check` controls automatic background update checks: - `enabled`: default behavior, let the installed user timer periodically check - GitHub releases and notify when an update is available + GitHub releases about weekly and notify when an update is available - `disabled`: leave the timer units installed, but disable the timer and skip background update work diff --git a/install.sh b/install.sh index 34911e7..04c81ff 100755 --- a/install.sh +++ b/install.sh @@ -465,8 +465,13 @@ else fi if [ "$UPDATE_AUTO_CHECK" = "enabled" ]; then - systemctl --user enable --now LG_Buddy_update_check.timer - echo "LG_Buddy_update_check.timer enabled and started." + systemctl --user enable LG_Buddy_update_check.timer + if systemctl --user is-active --quiet graphical-session.target; then + systemctl --user start LG_Buddy_update_check.timer + echo "LG_Buddy_update_check.timer enabled and started." + else + echo "LG_Buddy_update_check.timer enabled; it will start with the graphical session." + fi else systemctl --user disable --now LG_Buddy_update_check.timer 2>/dev/null || true echo "LG_Buddy_update_check.timer installed but disabled by config." diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index 1da705f..9261ae4 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -188,6 +188,8 @@ assert_file "$USER_SCREEN_SERVICE" assert_file "$USER_UPDATE_CHECK_SERVICE" assert_file "$USER_UPDATE_CHECK_TIMER" assert_file "$USER_UPDATE_CHECK_OVERRIDE" +grep -q '^OnCalendar=weekly$' "$USER_UPDATE_CHECK_TIMER" +grep -q '^WantedBy=graphical-session.target$' "$USER_UPDATE_CHECK_TIMER" assert_file "$DESKTOP_ENTRY" [ ! -e "$LEGACY_SLEEP_SERVICE" ] || { echo "Legacy sleep service installed unexpectedly: $LEGACY_SLEEP_SERVICE" @@ -353,6 +355,8 @@ assert_file "$USER_SCREEN_SERVICE" assert_file "$USER_UPDATE_CHECK_SERVICE" assert_file "$USER_UPDATE_CHECK_TIMER" assert_file "$USER_UPDATE_CHECK_OVERRIDE" +grep -q '^OnCalendar=weekly$' "$USER_UPDATE_CHECK_TIMER" +grep -q '^WantedBy=graphical-session.target$' "$USER_UPDATE_CHECK_TIMER" [ ! -e "$LEGACY_SLEEP_SERVICE" ] || { echo "Legacy sleep service installed unexpectedly: $LEGACY_SLEEP_SERVICE" exit 1 diff --git a/systemd/LG_Buddy_update_check.timer b/systemd/LG_Buddy_update_check.timer index 27d97d7..eccd4a2 100644 --- a/systemd/LG_Buddy_update_check.timer +++ b/systemd/LG_Buddy_update_check.timer @@ -1,10 +1,12 @@ [Unit] Description=LG Buddy background update check timer +After=graphical-session.target +PartOf=graphical-session.target [Timer] -OnCalendar=daily -RandomizedDelaySec=6h +OnCalendar=weekly +RandomizedDelaySec=12h Persistent=true [Install] -WantedBy=timers.target +WantedBy=graphical-session.target From 3e1f6d412a8e83b53b7e835b96a96a1aced01832 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sat, 9 May 2026 05:01:51 +0300 Subject: [PATCH 4/4] Make background update channel explicit --- README.md | 6 +- bin/LG_Buddy_Common | 4 +- crates/lg-buddy/src/settings.rs | 16 +-- crates/lg-buddy/src/updates.rs | 136 ++++++++++-------- .../lg-buddy/tests/features/settings.feature | 2 +- docs/defaults-and-configuration.md | 6 +- docs/user-guide.md | 9 +- 7 files changed, 97 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 26d4f99..21bc011 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ screen_idle_timeout=300 screen_restore_policy=conservative system_sleep_wake_policy=enabled updates_auto_check=enabled -updates_channel=auto +updates_channel=stable ``` `tv_ip`, `tv_mac`, and `input` are still accepted as legacy single-TV keys, but @@ -159,8 +159,8 @@ pre-down hook stay installed and no-op while the policy is disabled. `updates_auto_check=disabled` if you do not want the installed user timer to check for updates and notify you when a release is available. Manual `lg-buddy updates check` commands still work when automatic checks are disabled. -`updates_channel=auto` follows the installed build channel; set it to `stable` -or `prerelease` to control the scheduled check channel. +`updates_channel=stable` is the default for scheduled checks. Set it to +`prerelease` to opt in to prerelease update notifications. ## More Help diff --git a/bin/LG_Buddy_Common b/bin/LG_Buddy_Common index 6559b63..e3d794a 100644 --- a/bin/LG_Buddy_Common +++ b/bin/LG_Buddy_Common @@ -15,7 +15,7 @@ LG_BUDDY_MAX_IDLE_TIMEOUT=86400 LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY="conservative" LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY="enabled" LG_BUDDY_DEFAULT_UPDATE_AUTO_CHECK="enabled" -LG_BUDDY_DEFAULT_UPDATE_CHANNEL="auto" +LG_BUDDY_DEFAULT_UPDATE_CHANNEL="stable" lg_buddy_user_config_path() { if [ -n "${LG_BUDDY_CONFIG:-}" ]; then @@ -178,7 +178,7 @@ lg_buddy_load_config() { screen_restore_policy="$(lg_buddy_config_get_valid "screen_restore_policy" '^(marker_only|conservative|aggressive)$' "$LG_BUDDY_DEFAULT_SCREEN_RESTORE_POLICY" "$LG_BUDDY_CONFIG_FILE")" system_sleep_wake_policy="$(lg_buddy_config_get_valid "system_sleep_wake_policy" '^(enabled|disabled)$' "$LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY" "$LG_BUDDY_CONFIG_FILE")" updates_auto_check="$(lg_buddy_config_get_valid "updates_auto_check" '^(enabled|disabled)$' "$LG_BUDDY_DEFAULT_UPDATE_AUTO_CHECK" "$LG_BUDDY_CONFIG_FILE")" - updates_channel="$(lg_buddy_config_get_valid "updates_channel" '^(auto|stable|prerelease)$' "$LG_BUDDY_DEFAULT_UPDATE_CHANNEL" "$LG_BUDDY_CONFIG_FILE")" + updates_channel="$(lg_buddy_config_get_valid "updates_channel" '^(stable|prerelease)$' "$LG_BUDDY_DEFAULT_UPDATE_CHANNEL" "$LG_BUDDY_CONFIG_FILE")" if [ -z "$tv_ip" ] || [ -z "$tv_mac" ] || [ -z "$input" ]; then echo "LG Buddy: config file is missing required TV settings." diff --git a/crates/lg-buddy/src/settings.rs b/crates/lg-buddy/src/settings.rs index dc28062..3a11db0 100644 --- a/crates/lg-buddy/src/settings.rs +++ b/crates/lg-buddy/src/settings.rs @@ -43,7 +43,7 @@ const SCREEN_IDLE_BLANK_VALUES: &[&str] = &["enabled", "disabled"]; const SCREEN_RESTORE_POLICY_VALUES: &[&str] = &["conservative", "aggressive"]; const SYSTEM_SLEEP_WAKE_POLICY_VALUES: &[&str] = &["enabled", "disabled"]; const UPDATE_AUTO_CHECK_VALUES: &[&str] = &["enabled", "disabled"]; -const UPDATE_CHANNEL_VALUES: &[&str] = &["auto", "stable", "prerelease"]; +const UPDATE_CHANNEL_VALUES: &[&str] = &["stable", "prerelease"]; const SETTING_DEFINITIONS: &[SettingDefinition] = &[ SettingDefinition { @@ -174,7 +174,7 @@ const SETTING_DEFINITIONS: &[SettingDefinition] = &[ values: UPDATE_CHANNEL_VALUES, aliases: EMPTY_ALIASES, }), - default_value: Some(SettingValue::Enum("auto")), + default_value: Some(SettingValue::Enum("stable")), mutability: SettingMutability::ReadWrite, operations: READ_WRITE_OPERATIONS, apply_strategy: ApplyStrategy::RuntimePolicyOnly, @@ -2311,7 +2311,7 @@ mod tests { "screen.restore_policy | storage=screen_restore_policy | fallbacks=(none) | type=enum values=conservative,aggressive aliases=marker_only->conservative | default=conservative | mutability=read-write | ops=get,describe,set,unset | apply=restart-user-screen-service | description=Screen restore policy after LG Buddy blanks the configured screen.", "system.sleep_wake_policy | storage=system_sleep_wake_policy | fallbacks=(none) | type=enum values=enabled,disabled aliases=(none) | default=enabled | mutability=read-write | ops=get,describe,set,unset | apply=runtime-policy-only | description=System sleep and wake policy for lifecycle hooks.", "updates.auto_check | storage=updates_auto_check | fallbacks=(none) | type=enum values=enabled,disabled aliases=(none) | default=enabled | mutability=read-write | ops=get,describe,set,unset | apply=manage-update-check-timer | description=Automatic background update checks and update notifications.", - "updates.channel | storage=updates_channel | fallbacks=(none) | type=enum values=auto,stable,prerelease aliases=(none) | default=auto | mutability=read-write | ops=get,describe,set,unset | apply=runtime-policy-only | description=Release channel used by automatic background update checks.", + "updates.channel | storage=updates_channel | fallbacks=(none) | type=enum values=stable,prerelease aliases=(none) | default=stable | mutability=read-write | ops=get,describe,set,unset | apply=runtime-policy-only | description=Release channel used by automatic background update checks.", ] ); } @@ -2401,7 +2401,7 @@ screen.idle_timeout=300 (default, read-write, ops: get,describe,set,unset) screen.restore_policy=conservative (default, read-write, ops: get,describe,set,unset) system.sleep_wake_policy=disabled (config.env, read-write, ops: get,describe,set,unset) updates.auto_check=enabled (default, read-write, ops: get,describe,set,unset) -updates.channel=auto (default, read-write, ops: get,describe,set,unset) +updates.channel=stable (default, read-write, ops: get,describe,set,unset) " ); } @@ -2576,12 +2576,12 @@ updates.auto_check updates.channel storage key: updates_channel type: enum - current: auto + current: stable source: default - default: auto + default: stable mutability: read-write supported operations: get, describe, set, unset - allowed values: auto, stable, prerelease + allowed values: stable, prerelease apply: runtime-policy-only description: Release channel used by automatic background update checks. " @@ -3449,7 +3449,7 @@ tvs_primary_ip=192.0.2.43 "conservative", "disabled", "enabled", - "auto", + "stable", ] ); assert_eq!( diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 2d424f9..992c591 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -210,74 +210,63 @@ impl UpdateChannel { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BackgroundUpdateAutoCheck { - Enabled, - Disabled, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BackgroundUpdateChannel { - Auto, Stable, Prerelease, } impl BackgroundUpdateChannel { - fn check_channel(self) -> Option { + fn update_channel(self) -> UpdateChannel { match self { - Self::Auto => None, - Self::Stable => Some(UpdateChannel::Stable), - Self::Prerelease => Some(UpdateChannel::Prerelease), + Self::Stable => UpdateChannel::Stable, + Self::Prerelease => UpdateChannel::Prerelease, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct BackgroundUpdateCheckPolicy { - auto_check: BackgroundUpdateAutoCheck, - channel: BackgroundUpdateChannel, +enum BackgroundUpdateCheckPolicy { + Disabled, + Enabled { channel: BackgroundUpdateChannel }, } impl BackgroundUpdateCheckPolicy { fn from_settings(store: &SettingsStore) -> Result { - let auto_check = match required_enum_setting(store, "updates.auto_check")? { - "enabled" => BackgroundUpdateAutoCheck::Enabled, - "disabled" => BackgroundUpdateAutoCheck::Disabled, - _ => { - return Err(UpdatesError::SettingsInvariant( - "updates.auto_check resolved to an unsupported value".to_string(), - )); - } - }; - let channel = match required_enum_setting(store, "updates.channel")? { - "auto" => BackgroundUpdateChannel::Auto, - "stable" => BackgroundUpdateChannel::Stable, - "prerelease" => BackgroundUpdateChannel::Prerelease, - _ => { - return Err(UpdatesError::SettingsInvariant( - "updates.channel resolved to an unsupported value".to_string(), - )); - } - }; - - Ok(Self { - auto_check, - channel, - }) + match required_enum_setting(store, "updates.auto_check")? { + "disabled" => Ok(Self::Disabled), + "enabled" => Ok(Self::Enabled { + channel: parse_background_update_channel(store)?, + }), + _ => Err(UpdatesError::SettingsInvariant( + "updates.auto_check resolved to an unsupported value".to_string(), + )), + } } fn check_command(self) -> Option { - match self.auto_check { - BackgroundUpdateAutoCheck::Disabled => None, - BackgroundUpdateAutoCheck::Enabled => Some(UpdatesCommand::Check { - channel: self.channel.check_channel(), + match self { + Self::Disabled => None, + Self::Enabled { channel } => Some(UpdatesCommand::Check { + channel: Some(channel.update_channel()), notify: true, }), } } } +fn parse_background_update_channel( + store: &SettingsStore, +) -> Result { + match required_enum_setting(store, "updates.channel")? { + "stable" => Ok(BackgroundUpdateChannel::Stable), + "prerelease" => Ok(BackgroundUpdateChannel::Prerelease), + _ => Err(UpdatesError::SettingsInvariant( + "updates.channel resolved to an unsupported value".to_string(), + )), + } +} + fn required_enum_setting( store: &SettingsStore, key: &'static str, @@ -1337,19 +1326,20 @@ mod tests { atomic_write_file, check_updates, check_updates_with_cache, evaluate_update_notification_policy, parse_release_version, resolve_update_cache_path, run_updates_command_with, run_updates_command_with_background_settings, - BackgroundUpdateAutoCheck, BackgroundUpdateChannel, BackgroundUpdateCheckPolicy, - BackgroundUpdateSettings, CachedReleaseInfo, CachedUpdateCheck, CachedUpdateNotification, - DefaultUpdateCacheStore, FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, - ReleaseEndpoint, ReleaseInfo, UpdateCachePathError, UpdateCachePathSources, - UpdateCacheStore, UpdateChannel, UpdateCheckCache, UpdateNotificationDecision, - UpdateNotificationPolicyInput, UpdateNotificationReason, UpdateNotificationSkipReason, - UpdatesCommand, UpdatesDeferredFailure, UpdatesError, UpdatesRunContext, - UreqGitHubReleasesClient, PRERELEASE_PAGE_SIZE, + BackgroundUpdateChannel, BackgroundUpdateCheckPolicy, BackgroundUpdateSettings, + CachedReleaseInfo, CachedUpdateCheck, CachedUpdateNotification, DefaultUpdateCacheStore, + FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, ReleaseEndpoint, + ReleaseInfo, UpdateCachePathError, UpdateCachePathSources, UpdateCacheStore, UpdateChannel, + UpdateCheckCache, UpdateNotificationDecision, UpdateNotificationPolicyInput, + UpdateNotificationReason, UpdateNotificationSkipReason, UpdatesCommand, + UpdatesDeferredFailure, UpdatesError, UpdatesRunContext, UreqGitHubReleasesClient, + PRERELEASE_PAGE_SIZE, }; use crate::session_notifications::{ UpdateNotificationError, UpdateNotificationHandoff, UpdateNotificationOutcome, UpdateNotificationRequest, }; + use crate::settings::{ConfigEnvReader, SettingsStore}; use crate::version::{ReleaseChannel, VersionInfo}; use semver::Version; use std::cell::RefCell; @@ -1516,19 +1506,13 @@ mod tests { impl StaticBackgroundUpdateSettings { fn enabled(channel: BackgroundUpdateChannel) -> Self { Self { - policy: BackgroundUpdateCheckPolicy { - auto_check: BackgroundUpdateAutoCheck::Enabled, - channel, - }, + policy: BackgroundUpdateCheckPolicy::Enabled { channel }, } } fn disabled() -> Self { Self { - policy: BackgroundUpdateCheckPolicy { - auto_check: BackgroundUpdateAutoCheck::Disabled, - channel: BackgroundUpdateChannel::Auto, - }, + policy: BackgroundUpdateCheckPolicy::Disabled, } } } @@ -2260,12 +2244,12 @@ mod tests { } #[test] - fn background_update_check_uses_auto_channel_and_notifies() { + fn background_update_check_uses_default_stable_channel_and_notifies() { let client = MockGitHubReleasesClient::new(vec![Ok(stable_release("v1.1.1"))]); let notifier = RecordingNotifier::default(); let cache_store = MemoryUpdateCacheStore::default(); let background_settings = - StaticBackgroundUpdateSettings::enabled(BackgroundUpdateChannel::Auto); + StaticBackgroundUpdateSettings::enabled(BackgroundUpdateChannel::Stable); let mut output = Vec::new(); run_updates_command_with_background_settings( @@ -2294,6 +2278,38 @@ mod tests { ); } + #[test] + fn disabled_background_update_check_ignores_invalid_channel_setting() { + let store = SettingsStore::from_reader(ConfigEnvReader::parse( + "/tmp/config.env", + "updates_auto_check=disabled\nupdates_channel=bogus\n", + )); + + let policy = BackgroundUpdateCheckPolicy::from_settings(&store) + .expect("disabled background checks should not parse the channel setting"); + + assert_eq!(policy.check_command(), None); + } + + #[test] + fn enabled_background_update_check_defaults_to_stable_channel() { + let store = SettingsStore::from_reader(ConfigEnvReader::parse( + "/tmp/config.env", + "updates_auto_check=enabled\n", + )); + + let policy = BackgroundUpdateCheckPolicy::from_settings(&store) + .expect("enabled background checks should use the settings default channel"); + + assert_eq!( + policy.check_command(), + Some(UpdatesCommand::Check { + channel: Some(UpdateChannel::Stable), + notify: true, + }) + ); + } + #[test] fn background_update_check_uses_configured_prerelease_channel() { let client = MockGitHubReleasesClient::new(vec![Ok(format!( diff --git a/crates/lg-buddy/tests/features/settings.feature b/crates/lg-buddy/tests/features/settings.feature index ebc1dc0..ca9a980 100644 --- a/crates/lg-buddy/tests/features/settings.feature +++ b/crates/lg-buddy/tests/features/settings.feature @@ -13,7 +13,7 @@ Feature: Settings CLI And stdout contains "screen.restore_policy=conservative (default, read-write, ops: get,describe,set,unset)" And stdout contains "system.sleep_wake_policy=enabled (default, read-write, ops: get,describe,set,unset)" And stdout contains "updates.auto_check=enabled (default, read-write, ops: get,describe,set,unset)" - And stdout contains "updates.channel=auto (default, read-write, ops: get,describe,set,unset)" + And stdout contains "updates.channel=stable (default, read-write, ops: get,describe,set,unset)" Scenario: settings describe shows required TV operations Given a temporary LG Buddy config using input HDMI_2 diff --git a/docs/defaults-and-configuration.md b/docs/defaults-and-configuration.md index 27ad0dc..b094fc5 100644 --- a/docs/defaults-and-configuration.md +++ b/docs/defaults-and-configuration.md @@ -80,7 +80,7 @@ screen_restore_policy=conservative screen_idle_blank=enabled system_sleep_wake_policy=enabled updates_auto_check=enabled -updates_channel=auto +updates_channel=stable ``` Avoid adding installer-only state for product behavior. Environment variables @@ -148,7 +148,7 @@ choice. `updates_channel` keeps scheduled update checks configurable without changing manual diagnostics: -- `auto` is the default and follows the installed build channel -- `stable` and `prerelease` are explicit scheduled-check choices +- `stable` is the default scheduled-check channel +- `prerelease` is an explicit opt-in scheduled-check channel - manual `lg-buddy updates check --channel ...` still overrides channel for that invocation only diff --git a/docs/user-guide.md b/docs/user-guide.md index d30397c..be489e5 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -296,10 +296,9 @@ disabled. `updates_channel` controls the channel used by automatic background checks: -- `auto`: default behavior, stable/dev builds check stable releases and - prerelease builds check the prerelease channel -- `stable`: only consider stable releases -- `prerelease`: consider prereleases and stable releases +- `stable`: default behavior, only consider stable releases +- `prerelease`: opt in to prerelease update notifications; consider + prereleases and stable releases Example: @@ -313,7 +312,7 @@ screen_idle_timeout=300 screen_restore_policy=aggressive system_sleep_wake_policy=enabled updates_auto_check=enabled -updates_channel=auto +updates_channel=stable ``` Installed services receive the resolved config path through `LG_BUDDY_CONFIG`.