diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 9c953ca0..fed07a93 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -332,7 +332,8 @@ public static class LibationScaffolding config.ImportEpisodes, config.ImportPlusTitles, config.DownloadEpisodes, - config.BetaOptIn, + // Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check + config.CheckForUpgradesAtStartup, config.UseCoverAsFolderIcon, config.LibationFiles, AudibleFileStorage.BooksDirectory, diff --git a/Source/LibationAvalonia/Controls/Settings/Important.axaml b/Source/LibationAvalonia/Controls/Settings/Important.axaml index 8a67612e..49f5efe2 100644 --- a/Source/LibationAvalonia/Controls/Settings/Important.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Important.axaml @@ -69,21 +69,30 @@ - - - - - - + Spacing="5"> + + + + + + + + + + + Close(DialogResult.OK); + // When Libation cannot install the upgrade itself, this button reads "OK" and the dialog is a + // notice with a download link. Reporting OK there would be read as "yes, install it", starting a + // download and an install that was never on offer, so acknowledging a notice closes and no more. + public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => Close(CanUpgrade ? DialogResult.OK : DialogResult.Cancel); public void DontRemind_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.Ignore); public void Download_Tapped(object sender, Avalonia.Input.TappedEventArgs e) => Go.To.Url(PackageUrl); diff --git a/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs index 6af4c336..28a86135 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs @@ -35,6 +35,7 @@ public class ImportantSettingsVM : ViewModelBase CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0]; LastWriteTime = DateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? DateTimeSources[0]; UseWebView = config.UseWebView; + CheckForUpgradesAtStartup = config.CheckForUpgradesAtStartup; LoggingLevel = config.LogLevel; GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor); GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor); @@ -77,6 +78,7 @@ public class ImportantSettingsVM : ViewModelBase config.CreationTime = CreationTime.Value; config.LastWriteTime = LastWriteTime.Value; config.UseWebView = UseWebView; + config.CheckForUpgradesAtStartup = CheckForUpgradesAtStartup; config.LogLevel = LoggingLevel; config.TokenStorageMethod = SelectedTokenStorageMethod; initialTokenStorageMethod = SelectedTokenStorageMethod; @@ -145,10 +147,11 @@ public class ImportantSettingsVM : ViewModelBase /// When true, the Use WebView setting is disabled (e.g. when running in Linux Snap to avoid portal/sandbox crashes). public bool UseWebViewSettingDisabled => Configuration.IsRunningUnderSnap; public string UseWebViewSnapMessage { get; } = Configuration.IsRunningUnderSnap ? "Disabled when running in Linux Snap (avoids login crash). Use external browser instead." : ""; + public string CheckForUpgradesAtStartupText { get; } = Configuration.GetDescription(nameof(Configuration.CheckForUpgradesAtStartup)); + public string CheckForUpgradesAtStartupTip { get; } = Configuration.GetHelpText(nameof(Configuration.CheckForUpgradesAtStartup)); public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues(); public string GridScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridScaleFactor)); public string GridFontScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridFontScaleFactor)); - public string BetaOptInText { get; } = Configuration.GetDescription(nameof(Configuration.BetaOptIn)); public EnumDisplay[] Themes { get; } = Enum.GetValues() .Select(v => new EnumDisplay(v)) @@ -171,6 +174,7 @@ public class ImportantSettingsVM : ViewModelBase public EnumDisplay CreationTime { get; set; } public EnumDisplay LastWriteTime { get; set; } public bool UseWebView { get; set; } + public bool CheckForUpgradesAtStartup { get; set; } public Serilog.Events.LogEventLevel LoggingLevel { get; set; } public bool EncryptTokens diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index d7e12e06..4a6e4e1c 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -278,7 +278,7 @@ public partial class MainWindow : ReactiveWindow upgrader.UpgradeFailed += async (_, message) => await Dispatcher.UIThread.InvokeAsync(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); }); #if !DEBUG - Opened += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable); + Opened += async (_, _) => await upgrader.CheckForUpgradeAtStartupAsync(upgradeAvailable); #endif } diff --git a/Source/LibationFileManager/Configuration.HelpText.cs b/Source/LibationFileManager/Configuration.HelpText.cs index 863d4bd6..9de8dac4 100644 --- a/Source/LibationFileManager/Configuration.HelpText.cs +++ b/Source/LibationFileManager/Configuration.HelpText.cs @@ -168,6 +168,17 @@ public partial class Configuration When enabled, books from the Audible Plus catalog (titles you stream or borrow under your membership, not purchased) are imported into Libation. Downloading or liberating many Plus titles in a short time can cause Audible to temporarily deny content licenses ("license denied") for a day or two. That limit is enforced by Audible, not Libation — waiting and retrying usually fixes it. If problems persist after several days, report on Libation's GitHub with logs. + """ }, + {nameof(CheckForUpgradesAtStartup), """ + When enabled, Libation asks GitHub whether a newer + release exists each time it starts, and offers it to + you if there is one. + + Turn this off if something else keeps Libation up to + date, such as a package manager or an AppImage + updater. You can still check whenever you like: + Settings > About has a "Check for Upgrade" button + that works either way. """ } }.AsReadOnly(); diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index ebb48f9b..cab2bd1c 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -117,8 +117,8 @@ public partial class Configuration [Description("Book display font size")] public float GridFontScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); } - [Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")] - public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); } + [Description("Check for new Libation versions at startup")] + public bool CheckForUpgradesAtStartup { get => GetNonString(defaultValue: true); set => SetNonString(value); } [Description("Location for book storage. Includes destination of newly liberated books")] public LongPath? Books diff --git a/Source/LibationUiBase/Upgrader.cs b/Source/LibationUiBase/Upgrader.cs index 20f2380a..b204a8bc 100644 --- a/Source/LibationUiBase/Upgrader.cs +++ b/Source/LibationUiBase/Upgrader.cs @@ -226,6 +226,32 @@ public abstract class UpgraderBase ? new(false, ApplicationControlUpgradeMessage, ApplicationControlUpgradeSummary) : new(platformCanUpgrade, null, null); + /// + /// Whether the flow may go on to download and install. The dialog's answer is not enough on its + /// own: when Libation cannot install the upgrade itself, the prompt is a notice with a download + /// link, so a UI that reports acceptance anyway must not be able to start an install that was + /// never on offer - or, under Application Control, one that leaves Libation unable to start. + /// + internal static bool MayInstallUpgrade(bool userAccepted, bool capUpgrade) + => userAccepted && capUpgrade; + + /// + /// The check both GUIs run when their main window opens, skipped when the user has turned off + /// . Only this automatic check is optional: + /// the About window's "Check for Upgrade" button asks for a check outright and calls + /// regardless of the setting. + /// + public async Task CheckForUpgradeAtStartupAsync(Func upgradeAvailableHandler) + { + if (!Configuration.Instance.CheckForUpgradesAtStartup) + { + Serilog.Log.Logger.Information("Skipping the startup upgrade check: {Setting} is off.", nameof(Configuration.CheckForUpgradesAtStartup)); + return; + } + + await CheckForUpgradeAsync(upgradeAvailableHandler); + } + /// Check for upgrade and invoke if an update is available. Returns the check outcome so the UI can show "up to date", "update available", or "unable to determine". public async Task CheckForUpgradeAsync(Func upgradeAvailableHandler) { @@ -266,13 +292,14 @@ public abstract class UpgraderBase if (upgradeEventArgs.Ignore) config.SetString(upgradeProperties.LatestRelease.ToString(), ignoreUpgrade); - if (!upgradeEventArgs.InstallUpgrade) return result; - - // A second stop, because a UI that ignores CapUpgrade must not be able to start an - // upgrade that ends with Libation unable to start. - if (applicationControlBlocksUpgrade) + if (!MayInstallUpgrade(upgradeEventArgs.InstallUpgrade, capability.CapUpgrade)) { - Serilog.Log.Logger.Information("Skipped the in-app upgrade to {LatestRelease}: Windows Application Control is enforcing.", upgradeProperties.LatestRelease); + if (upgradeEventArgs.InstallUpgrade) + Serilog.Log.Logger.Information( + "Skipped the in-app upgrade to {LatestRelease}: {Reason}.", + upgradeProperties.LatestRelease, + applicationControlBlocksUpgrade ? "Windows Application Control is enforcing" : "this install cannot upgrade itself"); + return result; } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs index a63f1095..619026a2 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs @@ -69,6 +69,7 @@ absFolderLbl = new System.Windows.Forms.Label(); absFolderCb = new System.Windows.Forms.ComboBox(); tab1ImportantSettings = new System.Windows.Forms.TabPage(); + checkForUpgradesCbox = new System.Windows.Forms.CheckBox(); themeLbl = new System.Windows.Forms.Label(); themeCb = new System.Windows.Forms.ComboBox(); label22 = new System.Windows.Forms.Label(); @@ -405,6 +406,16 @@ loggingLevelCb.Size = new System.Drawing.Size(129, 23); loggingLevelCb.TabIndex = 4; // + // checkForUpgradesCbox + // + checkForUpgradesCbox.AutoSize = true; + checkForUpgradesCbox.Location = new System.Drawing.Point(6, 569); + checkForUpgradesCbox.Name = "checkForUpgradesCbox"; + checkForUpgradesCbox.Size = new System.Drawing.Size(300, 19); + checkForUpgradesCbox.TabIndex = 13; + checkForUpgradesCbox.Text = "[Check for upgrades at startup]"; + checkForUpgradesCbox.UseVisualStyleBackColor = true; + // // tabControl // tabControl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; @@ -423,6 +434,7 @@ // tab1ImportantSettings.AutoScroll = true; tab1ImportantSettings.BackColor = System.Drawing.SystemColors.Window; + tab1ImportantSettings.Controls.Add(checkForUpgradesCbox); tab1ImportantSettings.Controls.Add(themeLbl); tab1ImportantSettings.Controls.Add(themeCb); tab1ImportantSettings.Controls.Add(label22); @@ -1926,6 +1938,7 @@ private System.Windows.Forms.CheckBox createCueSheetCbox; private System.Windows.Forms.CheckBox autoScanCb; private System.Windows.Forms.CheckBox useWebViewCb; + private System.Windows.Forms.CheckBox checkForUpgradesCbox; private System.Windows.Forms.CheckBox downloadCoverArtCbox; private System.Windows.Forms.CheckBox autoDownloadEpisodesCb; private System.Windows.Forms.CheckBox saveEpisodesToSeriesFolderCbox; diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs index 78457031..d52df52e 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs @@ -42,6 +42,8 @@ public partial class SettingsDialog lastWriteTimeLbl.Text = desc(nameof(config.LastWriteTime)); gridScaleFactorLbl.Text = desc(nameof(config.GridScaleFactor)); gridFontScaleFactorLbl.Text = desc(nameof(config.GridFontScaleFactor)); + checkForUpgradesCbox.Text = desc(nameof(config.CheckForUpgradesAtStartup)); + toolTip.SetToolTip(checkForUpgradesCbox, Configuration.GetHelpText(nameof(config.CheckForUpgradesAtStartup))); var dateTimeSources = Enum.GetValues().Select(v => new EnumDisplay(v)).ToArray(); creationTimeCb.Items.AddRange(dateTimeSources); @@ -70,6 +72,7 @@ public partial class SettingsDialog saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder; overwriteExistingCbox.Checked = config.OverwriteExisting; + checkForUpgradesCbox.Checked = config.CheckForUpgradesAtStartup; gridScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridScaleFactor); gridFontScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridFontScaleFactor); @@ -195,6 +198,7 @@ public partial class SettingsDialog config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked; config.OverwriteExisting = overwriteExistingCbox.Checked; + config.CheckForUpgradesAtStartup = checkForUpgradesCbox.Checked; config.CreationTime = (creationTimeCb.SelectedItem as EnumDisplay)?.Value ?? Configuration.DateTimeSource.File; config.LastWriteTime = (lastWriteTimeCb.SelectedItem as EnumDisplay)?.Value ?? Configuration.DateTimeSource.File; diff --git a/Source/LibationWinForms/Form1.Upgrade.cs b/Source/LibationWinForms/Form1.Upgrade.cs index d06d59b8..1b63f8da 100644 --- a/Source/LibationWinForms/Form1.Upgrade.cs +++ b/Source/LibationWinForms/Form1.Upgrade.cs @@ -27,7 +27,7 @@ public partial class Form1 upgrader.UpgradeFailed += (_, message) => Invoke(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); }); #if !DEBUG - Shown += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable); + Shown += async (_, _) => await upgrader.CheckForUpgradeAtStartupAsync(upgradeAvailable); #endif } diff --git a/Source/_Tests/LibationFileManager.Tests/UpgradeCheckSettingTests.cs b/Source/_Tests/LibationFileManager.Tests/UpgradeCheckSettingTests.cs new file mode 100644 index 00000000..9f7feedf --- /dev/null +++ b/Source/_Tests/LibationFileManager.Tests/UpgradeCheckSettingTests.cs @@ -0,0 +1,52 @@ +using AssertionHelper; +using LibationFileManager; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace UpgradeCheckSettingTests; + +[TestClass] +[DoNotParallelize] +public class UpgradeCheckSettingTests +{ + [TestCleanup] + public void Cleanup() + { + Configuration.RestoreSingletonInstance(); + } + + // The update check is opt-out, so every install that predates the setting has to keep checking + // without anything having written the key. + [TestMethod] + public void Default_when_missing_is_enabled() + { + var config = Configuration.CreateMockInstance(); + + config.Exists(nameof(Configuration.CheckForUpgradesAtStartup)).Should().BeFalse(); + Assert.IsTrue(config.CheckForUpgradesAtStartup); + } + + [TestMethod] + public void Round_trips_off_and_on() + { + var config = Configuration.CreateMockInstance(); + + config.CheckForUpgradesAtStartup = false; + Assert.IsFalse(config.CheckForUpgradesAtStartup); + Assert.IsFalse(config.CreateEphemeralCopy().CheckForUpgradesAtStartup); + + config.CheckForUpgradesAtStartup = true; + Assert.IsTrue(config.CheckForUpgradesAtStartup); + Assert.IsTrue(config.CreateEphemeralCopy().CheckForUpgradesAtStartup); + } + + // get-setting and the -o override both reflect over Configuration properties carrying + // [Description], so a missing attribute would silently drop the setting from the CLI. + [TestMethod] + public void Has_a_description_so_the_cli_can_see_it() + { + var description = Configuration.GetDescription(nameof(Configuration.CheckForUpgradesAtStartup)); + + Assert.AreNotEqual($"[{nameof(Configuration.CheckForUpgradesAtStartup)}]", description); + StringAssert.Contains(description, "startup"); + } +} diff --git a/Source/_Tests/LibationUiBase.Tests/StartupUpgradeCheckTests.cs b/Source/_Tests/LibationUiBase.Tests/StartupUpgradeCheckTests.cs new file mode 100644 index 00000000..e7504944 --- /dev/null +++ b/Source/_Tests/LibationUiBase.Tests/StartupUpgradeCheckTests.cs @@ -0,0 +1,81 @@ +using LibationFileManager; + +namespace LibationUiBase.Tests; + +/// +/// Covers the one thing the CheckForUpgradesAtStartup setting has to do: keep the startup check off +/// the network when it is off, and leave it alone when it is on. +/// +/// is the seam. With +/// false, the check reports its own distinctive failure and the flow returns before reaching +/// InteropFactory, whose CanUpgrade throws off-platform. That failure message is +/// therefore proof that the check ran, and its absence proof that it did not. +/// +/// +[TestClass] +[DoNotParallelize] +public class StartupUpgradeCheckTests +{ + private const string CheckRanMessage = "Mock Check For Upgrade Failed"; + + [TestCleanup] + public void Cleanup() + { + Configuration.RestoreSingletonInstance(); + } + + [TestMethod] + public async Task Startup_checks_when_the_setting_is_on() + { + var config = Configuration.CreateMockInstance(); + config.CheckForUpgradesAtStartup = true; + + var (upgrader, failures) = BuildUpgraderThatFailsItsCheck(); + await upgrader.CheckForUpgradeAtStartupAsync(ShouldNotBeCalled); + + Assert.AreEqual(1, failures.Count); + StringAssert.Contains(failures[0], CheckRanMessage); + } + + [TestMethod] + public async Task Startup_does_not_check_when_the_setting_is_off() + { + var config = Configuration.CreateMockInstance(); + config.CheckForUpgradesAtStartup = false; + + var (upgrader, failures) = BuildUpgraderThatFailsItsCheck(); + await upgrader.CheckForUpgradeAtStartupAsync(ShouldNotBeCalled); + + Assert.AreEqual(0, failures.Count); + } + + // Turning the automatic check off must not disable the About window's "Check for Upgrade" + // button, which calls CheckForUpgradeAsync directly. + [TestMethod] + public async Task A_requested_check_still_runs_when_the_setting_is_off() + { + var config = Configuration.CreateMockInstance(); + config.CheckForUpgradesAtStartup = false; + + var (upgrader, failures) = BuildUpgraderThatFailsItsCheck(); + var result = await upgrader.CheckForUpgradeAsync(ShouldNotBeCalled); + + Assert.AreEqual(AppScaffolding.VersionCheckOutcome.UnableToDetermine, result.Outcome); + Assert.AreEqual(1, failures.Count); + StringAssert.Contains(failures[0], CheckRanMessage); + } + + private static (MockUpgrader Upgrader, List Failures) BuildUpgraderThatFailsItsCheck() + { + var upgrader = new MockUpgrader { CheckForUpgradeSucceeds = false }; + List failures = []; + upgrader.UpgradeFailed += (_, message) => failures.Add(message); + return (upgrader, failures); + } + + private static Task ShouldNotBeCalled(UpgradeEventArgs e) + { + Assert.Fail("The upgrade-available handler ran, but the check was supposed to fail before an update could be offered."); + return Task.CompletedTask; + } +} diff --git a/Source/_Tests/LibationUiBase.Tests/UpgradeCapabilityTests.cs b/Source/_Tests/LibationUiBase.Tests/UpgradeCapabilityTests.cs index 70a5b727..13ee83cd 100644 --- a/Source/_Tests/LibationUiBase.Tests/UpgradeCapabilityTests.cs +++ b/Source/_Tests/LibationUiBase.Tests/UpgradeCapabilityTests.cs @@ -51,4 +51,24 @@ public class UpgradeCapabilityTests Assert.IsNull(capability.Reason); Assert.IsNull(capability.Summary); } + + [TestMethod] + public void An_accepted_upgrade_installs_when_the_platform_can() + => Assert.IsTrue(UpgraderBase.MayInstallUpgrade(userAccepted: true, capUpgrade: true)); + + // Chardonnay's prompt relabels its button to "OK" when Libation cannot install the upgrade + // itself, but still reported acceptance, so acknowledging a download-link notice began a + // download and an install that was never on offer and could only fail. + [TestMethod] + public void Acceptance_is_not_enough_when_libation_cannot_install_the_upgrade() + => Assert.IsFalse(UpgraderBase.MayInstallUpgrade(userAccepted: true, capUpgrade: false)); + + // InstallUpgrade defaults to true, so a UI that never answers must not be taken as a yes on a + // platform that cannot install. + [TestMethod] + public void Declining_never_installs() + { + Assert.IsFalse(UpgraderBase.MayInstallUpgrade(userAccepted: false, capUpgrade: true)); + Assert.IsFalse(UpgraderBase.MayInstallUpgrade(userAccepted: false, capUpgrade: false)); + } } diff --git a/docs/advanced/advanced.md b/docs/advanced/advanced.md index 03f07678..6eb622f1 100644 --- a/docs/advanced/advanced.md +++ b/docs/advanced/advanced.md @@ -12,6 +12,8 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi ## Settings +- Check for new Libation versions at startup. Enabled by default: each time Libation starts it asks GitHub whether a newer release exists, and offers it to you if there is one. Turn it off if something else keeps Libation up to date, such as a package manager or an AppImage updater. Turning it off only stops the automatic check - Settings > About still has a "Check for Upgrade" button that works either way. + - Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all. In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following: diff --git a/docs/installation/linux.md b/docs/installation/linux.md index 5a97c53d..1e162839 100644 --- a/docs/installation/linux.md +++ b/docs/installation/linux.md @@ -62,7 +62,9 @@ sudo dnf5 install ./libation.rpm am -i libation ``` Thanks to Package Forge dev [Samuel](https://github.com/Samueru-sama) for [AppImage](https://github.com/pkgforge-dev/Libation-AppImage) maintenence. - + +When your package manager updates Libation for you, the startup update check has nothing useful to tell you. Turn off "Check for new Libation versions at startup" on the Important settings tab to stop it. Settings > About still has a "Check for Upgrade" button whenever you want to look. + ### Arch Linux ```bash