diff --git a/README.md b/README.md index 697840c..21bc011 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: @@ -92,8 +92,10 @@ 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 +- 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 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 @@ -102,10 +104,13 @@ 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 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 ``` @@ -115,9 +120,13 @@ 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 +updates_auto_check=enabled +updates_channel=stable ``` `tv_ip`, `tv_mac`, and `input` are still accepted as legacy single-TV keys, but @@ -136,11 +145,23 @@ 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 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=stable` is the default for scheduled checks. Set it to +`prerelease` to opt in to prerelease update notifications. + ## More Help - [User guide](docs/user-guide.md) diff --git a/bin/LG_Buddy_Common b/bin/LG_Buddy_Common index de3398c..e3d794a 100644 --- a/bin/LG_Buddy_Common +++ b/bin/LG_Buddy_Common @@ -9,10 +9,13 @@ 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" LG_BUDDY_DEFAULT_SYSTEM_SLEEP_WAKE_POLICY="enabled" +LG_BUDDY_DEFAULT_UPDATE_AUTO_CHECK="enabled" +LG_BUDDY_DEFAULT_UPDATE_CHANNEL="stable" lg_buddy_user_config_path() { if [ -n "${LG_BUDDY_CONFIG:-}" ]; then @@ -169,10 +172,13 @@ 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")" 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" '^(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..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,18 +87,24 @@ 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" +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" 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" + current_update_auto_check="$updates_auto_check" + current_update_channel="$updates_channel" echo "Loaded existing configuration from $LG_BUDDY_CONFIG_FILE" fi @@ -100,9 +113,12 @@ 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}" + 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" ;; @@ -126,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 @@ -229,57 +249,80 @@ 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" + update_channel="$current_update_channel" fi echo "" @@ -287,10 +330,13 @@ 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" 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 "" @@ -312,10 +358,13 @@ 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/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/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 18fd5f8..3a11db0 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, @@ -38,8 +39,11 @@ 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"]; +const UPDATE_CHANNEL_VALUES: &[&str] = &["stable", "prerelease"]; const SETTING_DEFINITIONS: &[SettingDefinition] = &[ SettingDefinition { @@ -92,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", @@ -134,6 +152,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("stable")), + 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 +703,9 @@ 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 }, InactiveDisabled { service: &'static str }, Skipped { reason: String }, @@ -667,6 +716,9 @@ 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 } => { write!( f, @@ -690,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 @@ -698,6 +756,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; + + fn disable_stop_user_unit(&self, unit: &str) -> Result<(), SettingsError>; } #[derive(Debug, Clone)] @@ -731,6 +793,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,27 +845,25 @@ 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(()) + 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 { - Err(SettingsError::Apply { - message: format_command_failure( - output.status.code(), - &output.stdout, - &output.stderr, - ), - }) + Ok(UserUnitEnableOutcome::Enabled) } } + + fn disable_stop_user_unit(&self, unit: &str) -> Result<(), SettingsError> { + self.run_user_systemctl(&["disable", "--now", unit]) + } } #[derive(Debug, Clone)] @@ -805,6 +887,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 +920,74 @@ 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 { + let outcome = self + .service_controller + .enable_start_user_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)?; + Ok(SettingsApplyOutcome::DisabledStopped { + unit: UPDATE_CHECK_TIMER_NAME, + }) + } + } + UserServiceState::ActiveOrEnabled => { + if enabled { + let outcome = self + .service_controller + .enable_start_user_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)?; + Ok(SettingsApplyOutcome::DisabledStopped { + unit: UPDATE_CHECK_TIMER_NAME, + }) + } + } + } + } +} + +fn settings_enable_outcome( + unit: &'static str, + outcome: UserUnitEnableOutcome, +) -> SettingsApplyOutcome { + match outcome { + UserUnitEnableOutcome::Enabled => SettingsApplyOutcome::Enabled { unit }, + UserUnitEnableOutcome::EnabledStarted => SettingsApplyOutcome::EnabledStarted { unit }, + } } #[derive(Debug)] @@ -1783,6 +1934,7 @@ impl fmt::Display for SettingOperation { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ApplyStrategy { RestartUserScreenService, + ManageUpdateCheckTimer, RuntimePolicyOnly, NoRuntimeApplyRequired, } @@ -1791,6 +1943,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", } @@ -2073,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; @@ -2102,9 +2256,12 @@ mod tests { "tv.mac", "tv.input", "screen.backend", + "screen.idle_blank", "screen.idle_timeout", "screen.restore_policy", "system.sleep_wake_policy", + "updates.auto_check", + "updates.channel", ] ); } @@ -2124,9 +2281,12 @@ 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"), + ("updates.auto_check", "updates_auto_check"), + ("updates.channel", "updates_channel"), ] ); } @@ -2146,9 +2306,12 @@ 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.", + "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=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.", ] ); } @@ -2233,9 +2396,12 @@ 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) +updates.auto_check=enabled (default, read-write, ops: get,describe,set,unset) +updates.channel=stable (default, read-write, ops: get,describe,set,unset) " ); } @@ -2346,6 +2512,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 @@ -2382,6 +2560,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: stable + source: default + default: stable + mutability: read-write + supported operations: get, describe, set, unset + allowed values: stable, prerelease + apply: runtime-policy-only + description: Release channel used by automatic background update checks. " ); } @@ -2692,6 +2894,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"); @@ -2833,6 +3196,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 @@ -2844,6 +3208,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); @@ -2923,6 +3291,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 ", @@ -2934,6 +3303,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); @@ -3055,9 +3429,12 @@ 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", + "updates.auto_check", + "updates.channel", ] ); assert_eq!( @@ -3067,9 +3444,12 @@ tvs_primary_ip=192.0.2.43 "", "", "gnome", + "enabled", "300", "conservative", "disabled", + "enabled", + "stable", ] ); assert_eq!( @@ -3081,7 +3461,10 @@ tvs_primary_ip=192.0.2.43 SettingSource::ConfigEnv, SettingSource::Default, SettingSource::Default, + SettingSource::Default, SettingSource::ConfigEnv, + SettingSource::Default, + SettingSource::Default, ] ); } @@ -3090,8 +3473,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); } @@ -3292,6 +3679,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 +3773,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 +3785,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 +3797,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 +3809,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 +3821,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 +3862,32 @@ tvs_primary_ip=192.0.2.43 Ok(()) } } + + fn enable_start_user_unit( + &self, + _unit: &str, + ) -> Result { + self.enables.set(self.enables.get() + 1); + + if let Some(message) = self.unit_action_error { + Err(SettingsError::Apply { + message: message.to_string(), + }) + } else { + Ok(UserUnitEnableOutcome::EnabledStarted) + } + } + + 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/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/src/updates.rs b/crates/lg-buddy/src/updates.rs index 4facb2d..992c591 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,14 +56,42 @@ 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 check_channel(&self) -> Option { + match self { + Self::Check { channel, .. } => *channel, + Self::BackgroundCheck => None, + } + } +} + +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 @@ -122,7 +153,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 +210,90 @@ impl UpdateChannel { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackgroundUpdateChannel { + Stable, + Prerelease, +} + +impl BackgroundUpdateChannel { + fn update_channel(self) -> UpdateChannel { + match self { + Self::Stable => UpdateChannel::Stable, + Self::Prerelease => UpdateChannel::Prerelease, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackgroundUpdateCheckPolicy { + Disabled, + Enabled { channel: BackgroundUpdateChannel }, +} + +impl BackgroundUpdateCheckPolicy { + fn from_settings(store: &SettingsStore) -> Result { + 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 { + 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, +) -> 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 +382,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 +465,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 +499,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 +508,8 @@ impl Error for UpdatesError { Self::Http { .. } | Self::ApiStatus { .. } | Self::NoMatchingRelease { .. } - | Self::NotModifiedWithoutCache { .. } => None, + | Self::NotModifiedWithoutCache { .. } + | Self::SettingsInvariant(_) => None, } } } @@ -397,6 +520,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 +1043,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.check_channel(), + context.version, + context.client, + &mut cache, + context.now_unix_seconds, + )?; writer.write_all(result.render().as_bytes())?; let notification_decision = @@ -939,13 +1119,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 +1143,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))); } @@ -981,35 +1161,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, - }) - } - } + 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( @@ -1144,18 +1325,21 @@ 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, - DefaultUpdateCacheStore, FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, - ReleaseEndpoint, ReleaseInfo, UpdateCachePathError, UpdateCachePathSources, - UpdateCacheStore, UpdateChannel, UpdateCheckCache, UpdateNotificationDecision, - UpdateNotificationPolicyInput, UpdateNotificationReason, UpdateNotificationSkipReason, - UpdatesCommand, UpdatesDeferredFailure, UpdatesError, UreqGitHubReleasesClient, + run_updates_command_with, run_updates_command_with_background_settings, + 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; @@ -1314,6 +1498,51 @@ mod tests { } } + #[derive(Debug, Clone, Copy)] + struct StaticBackgroundUpdateSettings { + policy: BackgroundUpdateCheckPolicy, + } + + impl StaticBackgroundUpdateSettings { + fn enabled(channel: BackgroundUpdateChannel) -> Self { + Self { + policy: BackgroundUpdateCheckPolicy::Enabled { channel }, + } + } + + fn disabled() -> Self { + Self { + policy: BackgroundUpdateCheckPolicy::Disabled, + } + } + } + + 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 +1651,10 @@ mod tests { } } + fn background_check() -> UpdatesCommand { + UpdatesCommand::BackgroundCheck + } + #[test] fn notification_policy_skips_when_notification_was_not_requested() { let latest = release_info( @@ -1746,7 +1979,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, @@ -1829,7 +2062,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, @@ -1874,7 +2107,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, @@ -1901,7 +2134,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, @@ -1979,6 +2212,192 @@ 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_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::Stable); + 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 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!( + "[{},{}]", + 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/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 dc47534..ca9a980 100644 --- a/crates/lg-buddy/tests/features/settings.feature +++ b/crates/lg-buddy/tests/features/settings.feature @@ -9,8 +9,11 @@ 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)" + 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 @@ -29,6 +32,24 @@ 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" + 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 +119,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..4f9048d 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"] @@ -201,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 @@ -260,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 @@ -296,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` @@ -324,20 +331,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..b094fc5 100644 --- a/docs/defaults-and-configuration.md +++ b/docs/defaults-and-configuration.md @@ -77,7 +77,10 @@ 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=stable ``` Avoid adding installer-only state for product behavior. Environment variables @@ -111,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 @@ -121,3 +133,22 @@ 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 +- 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` +- 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: + +- `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/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..be489e5 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,12 @@ 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`. 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 @@ -75,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: @@ -165,16 +180,21 @@ 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 +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 +runtime apply step is needed. User-session screen 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: @@ -195,10 +215,13 @@ Current config keys: - `tvs_primary_ip` - `tvs_primary_mac` - `tvs_primary_input` +- `screen_idle_blank` - `screen_backend` - `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. @@ -218,15 +241,26 @@ 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` | +| `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; 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. @@ -250,15 +284,35 @@ 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 about weekly 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: + +- `stable`: default behavior, only consider stable releases +- `prerelease`: opt in to prerelease update notifications; consider + prereleases and stable releases + Example: ```ini 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 +updates_auto_check=enabled +updates_channel=stable ``` Installed services receive the resolved config path through `LG_BUDDY_CONFIG`. diff --git a/install.sh b/install.sh index 4ab02ec..04c81ff 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="" @@ -108,6 +109,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" @@ -280,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)" @@ -287,50 +296,61 @@ 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." 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..." @@ -411,9 +431,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,44 +448,34 @@ if [ "$SKIP_SYSTEMD_ACTIONS" != "1" ]; then systemctl --user daemon-reload 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 - 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 +if [ "$SKIP_SYSTEMD_ACTIONS" = "1" ]; then + echo "Skipping user service enable/start because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1." 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 + 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 [ "$UPDATE_AUTO_CHECK" = "enabled" ]; then + 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." + fi fi if [ "$SYSTEM_SLEEP_WAKE_POLICY" = "enabled" ]; then @@ -468,6 +485,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/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..9261ae4 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 " @@ -141,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" @@ -165,6 +167,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 +185,11 @@ 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" +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" @@ -203,6 +213,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" @@ -216,6 +227,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 " @@ -229,15 +241,22 @@ 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 +"$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_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" 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 @@ -255,9 +274,12 @@ 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" +grep -q '^updates_auto_check=disabled$' "$CONFIG_FILE" +grep -q '^updates_channel=prerelease$' "$CONFIG_FILE" export LG_BUDDY_REMOVE_CONFIG="1" ( @@ -289,6 +311,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 +352,11 @@ 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" +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 @@ -332,6 +371,7 @@ assert_file "$USER_SCREEN_SERVICE" } 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" ( @@ -347,6 +387,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..eccd4a2 --- /dev/null +++ b/systemd/LG_Buddy_update_check.timer @@ -0,0 +1,12 @@ +[Unit] +Description=LG Buddy background update check timer +After=graphical-session.target +PartOf=graphical-session.target + +[Timer] +OnCalendar=weekly +RandomizedDelaySec=12h +Persistent=true + +[Install] +WantedBy=graphical-session.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