diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 93f235f8..ec8b315d 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -66,7 +66,7 @@ jobs: id: zip working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} run: | - delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll" "ZipExtractor.exe") + delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll") for n in "${delfiles[@]}"; do rm "$n"; done osbuild="$(echo '${{ matrix.os }}' | tr '[:upper:]' '[:lower:]')" artifact="Libation.${{ steps.get_version.outputs.version }}-${osbuild}-${{ matrix.release_name }}" diff --git a/.github/workflows/bundle-linux.yml b/.github/workflows/bundle-linux.yml new file mode 100644 index 00000000..1a187276 --- /dev/null +++ b/.github/workflows/bundle-linux.yml @@ -0,0 +1,43 @@ +# build-linux.yml +# Reusable workflow that builds the Libation installation bundles for Linux and MacOS. +--- +name: bundle-linux + +on: + workflow_call: + inputs: + version: + type: string + description: 'Version number' + required: true + +jobs: + bundle: + runs-on: ubuntu-latest + strategy: + matrix: + os: [linux, macos] + release_name: [chardonnay] + steps: + - uses: actions/checkout@v3 + + - name: Download Artifact + uses: actions/download-artifact@v3 + with: + name: "Libation.${{ inputs.version }}-${{ matrix.os }}-${{ matrix.release_name }}.tar.gz" + + - name: Build bundle + id: build + run: | + SCRIPT=targz2${{ matrix.os }}bundle.sh + chmod +rwx ./Scripts/${SCRIPT} + ./Scripts/${SCRIPT} "Libation.${{ inputs.version }}-${{ matrix.os }}-${{ matrix.release_name }}.tar.gz" ${{ inputs.version }} + artifact=$(ls ./bundle) + echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}" + + - name: Publish bundle + uses: actions/upload-artifact@v3 + with: + name: ${{ steps.build.outputs.artifact }} + path: ./bundle/${{ steps.build.outputs.artifact }} + if-no-files-found: error diff --git a/.github/workflows/deb.yml b/.github/workflows/deb.yml deleted file mode 100644 index 3557b629..00000000 --- a/.github/workflows/deb.yml +++ /dev/null @@ -1,38 +0,0 @@ -# deb.yml -# Reusable workflow that builds the Linux Debian package. ---- -name: deb - -on: - workflow_call: - inputs: - version: - type: string - description: 'Version number' - required: true - -env: - FILE_NAME: "Libation.${{ inputs.version }}-linux-chardonnay" - -jobs: - build_deb: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Download Artifact - uses: actions/download-artifact@v3 - with: - name: "${{ env.FILE_NAME }}.tar.gz" - - - name: Build .deb - id: deb - run: | - ./Scripts/targz2deb.sh "${{ env.FILE_NAME }}.tar.gz" ${{ inputs.version }} - - - name: Publish .deb - uses: actions/upload-artifact@v3 - with: - name: ${{ env.FILE_NAME }}.deb - path: ${{ env.FILE_NAME }}.deb - if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29888cd9..3d9e5fca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,14 +34,14 @@ jobs: version_override: ${{ needs.prerelease.outputs.version }} run_unit_tests: false - deb: + bundle: needs: [prerelease,build] - uses: ./.github/workflows/deb.yml + uses: ./.github/workflows/bundle-linux.yml with: version: ${{ needs.prerelease.outputs.version }} release: - needs: [prerelease,build,deb] + needs: [prerelease,build,bundle] runs-on: ubuntu-latest steps: - name: Download artifacts @@ -53,7 +53,7 @@ jobs: id: release uses: softprops/action-gh-release@v1 with: - name: Libation ${{ needs.prerelease.outputs.version }} + name: Libation v${{ needs.prerelease.outputs.version }} body: draft: true prerelease: false diff --git a/.releaseindex.json b/.releaseindex.json index 6acac9e8..870c942e 100644 --- a/.releaseindex.json +++ b/.releaseindex.json @@ -1,6 +1,6 @@ { "WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip", "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip", - "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay", - "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay" + "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay\\.deb", + "MacOSAvalonia": "Libation\\.app-macOS-x64-\\d+\\.\\d+\\.\\d+\\.tgz" } diff --git a/Documentation/InstallOnMac.md b/Documentation/InstallOnMac.md index 0f8a805f..b57447ba 100644 --- a/Documentation/InstallOnMac.md +++ b/Documentation/InstallOnMac.md @@ -4,40 +4,36 @@ ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. - # Run Libation on MacOS This walkthrough should get you up and running with Libation on your Mac. ## Install Libation -- Download latest MacOS zip to downloads folder -- Extract and rename folder to Libation -- in terminal type cd and then drag your folder of libation to terminal so it looks like `cd/users/YourName/Downloads/Libation` -- Type following commands +- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it. +- Move the extracted Libation app bundle to your applications folder. +- Open a terminal (Go > Utilities > Terminal) +- Copy/paste/run the following command (you'll be prompted to enter your password) + ```Console + sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable + ``` +- Close the terminal and use Libation! -```console -chmod +x ./Libation -sudo spctl --add --label "Libation" ./Libation -./Libation +## Running Hangover + +Libation comes with a recovery app called Hangover. You can start it by running this command: +```Console +open /Applications/Libation.app --args hangover ``` -## Trouble with Gatekeeper? +## Runnign LibationCli -If Gatekeeper is giving you trouble with Libation: +Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command: +```Console +open /Applications/Libation.app --args cli +``` +To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli` -Disable the block - -`sudo spctl --master-disable` - -Launch Libation and login, etc. and allow the rules to update then re-enable the block. - -`sudo spctl --master-enable` - -Once Gatekeeper reenabled, you can open Libation again without it being blocked. - -Thanks [joseph-holland](https://github.com/rmcrackan/Libation/issues/327#issuecomment-1268993349)! - -Report bugs to https://github.com/rmcrackan/Libation/issues +Then use `./LibationCli` to execute a command. ## Get Libation running on Mac diff --git a/Scripts/targz2deb.sh b/Scripts/targz2linuxbundle.sh old mode 100755 new mode 100644 similarity index 92% rename from Scripts/targz2deb.sh rename to Scripts/targz2linuxbundle.sh index 0c0f337d..b4806ad8 --- a/Scripts/targz2deb.sh +++ b/Scripts/targz2linuxbundle.sh @@ -106,7 +106,10 @@ ln -s /usr/lib/libation/Hangover /usr/bin/hangover ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli # Increase the maximum number of inotify instances -echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p + +if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then + echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p +fi # workaround until this file is moved to the user's home directory touch /usr/lib/libation/appsettings.json @@ -130,7 +133,10 @@ chmod +x "$FOLDER_DEBIAN/postinst" echo "Creating .deb file..." dpkg-deb -Zxz --build $FOLDER_MAIN +mkdir bundle +echo "moving to ./bundle/$FOLDER_MAIN.deb" +mv "$FOLDER_MAIN.deb" "./bundle/$FOLDER_MAIN.deb" + rm -r "$FOLDER_MAIN" echo "Done!" - diff --git a/Scripts/targz2macosbundle.sh b/Scripts/targz2macosbundle.sh new file mode 100644 index 00000000..e772ed31 --- /dev/null +++ b/Scripts/targz2macosbundle.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +FILE=$1; shift +VERSION=$1; shift + +if [ -z "$FILE" ] +then + echo "This script must be called with a the Libation macos bin zip file as an argument." + exit +fi + +if [ ! -f "$FILE" ] +then + echo "The file \"$FILE\" does not exist." + exit +fi + +if [ -z "$VERSION" ] +then + echo "This script must be called with the Libation version number as an argument." + exit +fi + +contains() { case "$1" in *"$2"*) true ;; *) false ;; esac } + +if ! contains "$FILE" "$VERSION" +then + echo "This script must be called with a Libation version number that is present in the filename passed." + exit +fi + +BUNDLE="Libation.app" +echo "Bundle dir: $BUNDLE" + +if [[ -d "$BUNDLE" ]] +then + echo "$BUNDLE directory already exists, aborting." + exit +fi + +BUNDLE_CONTENTS="$BUNDLE/Contents" +echo "Bundle Contents dir: $BUNDLE_CONTENTS" + +BUNDLE_RESOURCES="$BUNDLE_CONTENTS/Resources" +echo "Resources dir: $BUNDLE_RESOURCES" + +BUNDLE_MACOS="$BUNDLE_CONTENTS/MacOS" +echo "MacOS dir: $BUNDLE_MACOS" + +mkdir -p "$BUNDLE_CONTENTS" +mkdir -p "$BUNDLE_RESOURCES" +mkdir -p "$BUNDLE_MACOS" + +echo "Extracting $FILE to $BUNDLE_MACOS..." +tar -xzf ${FILE} -C ${BUNDLE_MACOS} + +if [ $? -ne 0 ] + then echo "Error extracting ${FILE}" + exit +fi + +echo "Copying icon..." +cp "$BUNDLE_MACOS/libation.icns" "$BUNDLE_RESOURCES/libation.icns" + +echo "Copying Info.plist file..." +cp "$BUNDLE_MACOS/Info.plist" "$BUNDLE_CONTENTS/Info.plist" + +echo "Set Libation version number..." +sed -i -e "s/VERSION_STRING/$VERSION/" "$BUNDLE_CONTENTS/Info.plist" + +echo "deleting unneeded files.." +delfiles=("libmp3lame.x64.so" "ffmpegaac.x64.so" "libation.icns" "Info.plist") +for n in "${delfiles[@]}"; do rm "$BUNDLE_MACOS/$n"; done + +echo "Creating app bundle: $BUNDLE-$VERSION.tar.gz" +tar -czvf "$BUNDLE-$VERSION.tar.gz" "$BUNDLE" + +mkdir bundle +echo "moving to ./bundle/$BUNDLE-$VERSION.tar.gz" +mv "$BUNDLE-$VERSION.tar.gz" "./bundle/$BUNDLE-macOS-x64-$VERSION.tgz" + +rm -r "$BUNDLE" + +echo "Done!" diff --git a/Source/AppScaffolding/UNSAFE_MigrationHelper.cs b/Source/AppScaffolding/UNSAFE_MigrationHelper.cs index 4a63c1ff..63327778 100644 --- a/Source/AppScaffolding/UNSAFE_MigrationHelper.cs +++ b/Source/AppScaffolding/UNSAFE_MigrationHelper.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using Dinah.Core; +using LibationFileManager; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -25,9 +26,6 @@ namespace AppScaffolding : value; #region appsettings.json - private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json"); - - public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON); public static bool APPSETTINGS_TryGet(string key, out string value) { @@ -61,11 +59,7 @@ namespace AppScaffolding /// True: save if contents changed. False: no not attempt save private static void process_APPSETTINGS_Json(Action action, bool save = true) { - // only insert if not exists - if (!APPSETTINGS_Json_Exists) - return; - - var startingContents = File.ReadAllText(APPSETTINGS_JSON); + var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile); JObject jObj; try @@ -88,7 +82,7 @@ namespace AppScaffolding if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact)) return; - File.WriteAllText(APPSETTINGS_JSON, endingContents_indented); + File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented); System.Threading.Thread.Sleep(100); } #endregion diff --git a/Source/HangoverAvalonia/ViewModels/MainVM.Database.cs b/Source/HangoverAvalonia/ViewModels/MainVM.Database.cs index 0e11d5b4..ee6ec3dd 100644 --- a/Source/HangoverAvalonia/ViewModels/MainVM.Database.cs +++ b/Source/HangoverAvalonia/ViewModels/MainVM.Database.cs @@ -17,7 +17,7 @@ namespace HangoverAvalonia.ViewModels private void Load_databaseVM() { - _tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s)); + _tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s)); _tab.LoadDatabaseFile(); if (_tab.DbFile is null) diff --git a/Source/HangoverWinForms/HangoverWinForms.csproj b/Source/HangoverWinForms/HangoverWinForms.csproj index 95ad37a2..dd37f2e3 100644 --- a/Source/HangoverWinForms/HangoverWinForms.csproj +++ b/Source/HangoverWinForms/HangoverWinForms.csproj @@ -4,6 +4,7 @@ WinExe net7.0-windows true + Hangover true hangover.ico enable diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index e4b5d3db..8ae51baf 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -11,7 +11,6 @@ using System.Threading.Tasks; using System.Collections.Generic; using System.IO; using ApplicationServices; -using Dinah.Core; namespace LibationAvalonia { @@ -53,7 +52,7 @@ namespace LibationAvalonia // check for existing settings in default location var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - config.SetLibationFiles(defaultLibationFilesDir); + Configuration.SetLibationFiles(defaultLibationFilesDir); if (config.LibationSettingsAreValid) { @@ -86,7 +85,7 @@ namespace LibationAvalonia // - error message, Exit() if (setupDialog.IsNewUser) { - setupDialog.Config.SetLibationFiles(Configuration.UserProfile); + Configuration.SetLibationFiles(Configuration.UserProfile); ShowSettingsWindow(desktop, setupDialog.Config, OnSettingsCompleted); } else if (setupDialog.IsReturningUser) @@ -178,7 +177,7 @@ namespace LibationAvalonia private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config) { - config.SetLibationFiles(libationFilesDialog.SelectedDirectory); + Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); if (config.LibationSettingsAreValid) { await RunMigrationsAsync(config); diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index 0d324e4b..40399412 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -116,20 +116,6 @@ - - - - Always - - - Always - - - Always - - - - diff --git a/Source/LibationAvalonia/Program.cs b/Source/LibationAvalonia/Program.cs index d820eb28..4c5f861b 100644 --- a/Source/LibationAvalonia/Program.cs +++ b/Source/LibationAvalonia/Program.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics; using System.IO; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using ApplicationServices; using Avalonia; @@ -13,8 +14,30 @@ namespace LibationAvalonia { static class Program { - static void Main() + static void Main(string[] args) { + + if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "hangover") + { + //Launch the Hangover app within the sandbox + //We can do this because we're already executing inside the sandbox. + //Any process created in the sandbox executes in the same sandbox. + //Unfortunately, all sandbox files are read/execute, so no writing! + + Assembly asm = Assembly.GetExecutingAssembly(); + string path = Path.GetDirectoryName(asm.Location); + Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : "")); + return; + } + if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli") + { + //Open a new Terminal in the sandbox + Assembly asm2 = Assembly.GetExecutingAssembly(); + string libationProgramFiles = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + Process.Start("/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal", $"\"{libationProgramFiles}\""); + return; + } + //***********************************************// // // // do not use Configuration before this line // diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index 0b2f23fb..c1dda16e 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -44,11 +44,11 @@ namespace LibationAvalonia.ViewModels private bool _progressBarVisible; private decimal _speedLimit; - public int CompletedCount { get => _completedCount; private set { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); } } - public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); } } - public int ErrorCount { get => _errorCount; private set { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); } } - public string RunningTime { get => _runningTime; set { this.RaiseAndSetIfChanged(ref _runningTime, value); } } - public bool ProgressBarVisible { get => _progressBarVisible; set { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); } } + public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); } + public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); } + public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); } + public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); } + public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); } public bool AnyCompleted => CompletedCount > 0; public bool AnyQueued => QueuedCount > 0; public bool AnyErrors => ErrorCount > 0; @@ -78,8 +78,11 @@ namespace LibationAvalonia.ViewModels : _speedLimit > 1 ? 0.1m : 0.01m; - this.RaisePropertyChanged(nameof(SpeedLimitIncrement)); - this.RaisePropertyChanged(); + Dispatcher.UIThread.Post(() => + { + this.RaisePropertyChanged(nameof(SpeedLimitIncrement)); + this.RaisePropertyChanged(); + }); } } @@ -92,12 +95,12 @@ namespace LibationAvalonia.ViewModels ErrorCount = errCount; CompletedCount = completeCount; - this.RaisePropertyChanged(nameof(Progress)); + Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress))); } private void Queue_QueuededCountChanged(object sender, int cueCount) { QueuedCount = cueCount; - this.RaisePropertyChanged(nameof(Progress)); + Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress))); } public void WriteLine(string text) diff --git a/Source/LibationAvalonia/Views/MainWindow.Settings.cs b/Source/LibationAvalonia/Views/MainWindow.Settings.cs index 296def23..9cb02447 100644 --- a/Source/LibationAvalonia/Views/MainWindow.Settings.cs +++ b/Source/LibationAvalonia/Views/MainWindow.Settings.cs @@ -1,5 +1,5 @@ -using System; -using System.Linq; +using LibationFileManager; +using System; namespace LibationAvalonia.Views { @@ -16,5 +16,17 @@ namespace LibationAvalonia.Views public async void aboutToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => await MessageBox.Show($"Libation {AppScaffolding.LibationScaffolding.Variety}{Environment.NewLine}Version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}"); + + public void launchHangoverToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + try + { + System.Diagnostics.Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : "")); + } + catch(Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to launch Hangover"); + } + } } } diff --git a/Source/LibationAvalonia/Views/MainWindow.Update.cs b/Source/LibationAvalonia/Views/MainWindow.Update.cs index 99d2bfa8..a6858d94 100644 --- a/Source/LibationAvalonia/Views/MainWindow.Update.cs +++ b/Source/LibationAvalonia/Views/MainWindow.Update.cs @@ -14,7 +14,7 @@ namespace LibationAvalonia.Views Opened += async (_, _) => await checkForUpdates(); } - private async Task checkForUpdates() + private async Task checkForUpdates() { async Task downloadUpdate(UpgradeProperties upgradeProperties) { @@ -26,7 +26,7 @@ namespace LibationAvalonia.Views //Silently download the update in the background, save it to a temp file. - var zipFile = Path.GetTempFileName(); + var zipFile = Path.Combine(Path.GetTempPath(), Path.GetFileName(upgradeProperties.ZipUrl)); try { System.Net.Http.HttpClient cli = new(); @@ -42,36 +42,6 @@ namespace LibationAvalonia.Views return zipFile; } - void runWindowsUpgrader(string zipFile) - { - var thisExe = Environment.ProcessPath; - var thisDir = Path.GetDirectoryName(thisExe); - - var zipExtractor = Path.Combine(Path.GetTempPath(), "ZipExtractor.exe"); - - File.Copy("ZipExtractor.exe", zipExtractor, overwrite: true); - - var psi = new System.Diagnostics.ProcessStartInfo() - { - FileName = zipExtractor, - UseShellExecute = true, - Verb = "runas", - WindowStyle = System.Diagnostics.ProcessWindowStyle.Normal, - CreateNoWindow = true, - ArgumentList = - { - "--input", - zipFile, - "--output", - thisDir, - "--executable", - thisExe - } - }; - - System.Diagnostics.Process.Start(psi); - } - try { var upgradeProperties = await Task.Run(LibationScaffolding.GetLatestRelease); @@ -83,26 +53,22 @@ namespace LibationAvalonia.Views if (config.GetString(propertyName: ignoreUpdate) == upgradeProperties.LatestRelease.ToString()) return; - var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, Configuration.IsWindows).ShowDialog(this); + var interop = InteropFactory.Create(); + + var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, interop.CanUpdate).ShowDialog(this); if (notificationResult == DialogResult.Ignore) config.SetString(upgradeProperties.LatestRelease.ToString(), ignoreUpdate); - if (notificationResult != DialogResult.OK || !Configuration.IsWindows) return; + if (notificationResult != DialogResult.OK) return; //Download the update file in the background, - //then wire up installaion on window close. + string updateBundle = await downloadUpdate(upgradeProperties); - string zipFile = await downloadUpdate(upgradeProperties); + if (string.IsNullOrEmpty(updateBundle) || !File.Exists(updateBundle)) return; - if (string.IsNullOrEmpty(zipFile) || !File.Exists(zipFile)) - return; - - Closed += (_, _) => - { - if (File.Exists(zipFile)) - runWindowsUpgrader(zipFile); - }; + //Install the update + interop.InstallUpdate(updateBundle); } catch (Exception ex) { diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index 9fac32e2..6241135c 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -131,6 +131,8 @@ + + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index d407af76..3bdd2d6d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using ApplicationServices; using Avalonia; using Avalonia.Controls; @@ -100,9 +101,9 @@ namespace LibationAvalonia.Views setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); var removeMenuItem = new MenuItem() { Header = "_Remove from library" }; - removeMenuItem.Click += (_, __) => LibraryCommands.RemoveBook(entry.AudibleProductId); + removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId)); - var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." }; + var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." }; locateFileMenuItem.Click += async (_, __) => { try diff --git a/Source/LibationFileManager/Configuration.LibationFiles.cs b/Source/LibationFileManager/Configuration.LibationFiles.cs index 1a068770..7608f6c8 100644 --- a/Source/LibationFileManager/Configuration.LibationFiles.cs +++ b/Source/LibationFileManager/Configuration.LibationFiles.cs @@ -5,20 +5,22 @@ using FileManager; using Newtonsoft.Json.Linq; using Newtonsoft.Json; using Serilog; +using Dinah.Core.Logging; namespace LibationFileManager { public partial class Configuration { - private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), "appsettings.json"); - private const string LIBATION_FILES_KEY = "LibationFiles"; + public static string AppsettingsJsonFile { get; } = getOrCreateAppsettingsFile(); + + private const string LIBATION_FILES_KEY = "LibationFiles"; [Description("Location for storage of program-created files")] public string LibationFiles { get { - if (libationFilesPathCache is not null) + if (libationFilesPathCache is not null) return libationFilesPathCache; // FIRST: must write here before SettingsFilePath in next step reads cache @@ -44,54 +46,93 @@ namespace LibationFileManager private static string libationFilesPathCache { get; set; } - private string getLibationFilesSettingFromJson() + /// + /// Try to find appsettings.json in the following locations: + /// + /// + /// [App Directory] + /// + /// + /// %LocalAppData%\Libation + /// + /// + /// %AppData%\Libation + /// + /// + /// %Temp%\Libation + /// + /// + /// + /// If not found, try to create it in each of the same locations in-order until successful. + /// + /// This method must complete successfully for Libation to continue. + /// + /// appsettings.json file path + /// appsettings.json could not be found or created. + private static string getOrCreateAppsettingsFile() + { + const string appsettings_filename = "appsettings.json"; + + //Possible appsettings.json locations, in order of preference. + string[] possibleAppsettingsFiles = new[] + { + Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), appsettings_filename), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation", appsettings_filename), + Path.Combine(UserProfile, appsettings_filename), + Path.Combine(Path.GetTempPath(), "Libation", appsettings_filename) + }; + + //Try to find and validate appsettings.json in each folder + foreach (var appsettingsFile in possibleAppsettingsFiles) + { + if (File.Exists(appsettingsFile)) + { + try + { + var appSettings = JObject.Parse(File.ReadAllText(appsettingsFile)); + + if (appSettings.ContainsKey(LIBATION_FILES_KEY) + && appSettings[LIBATION_FILES_KEY] is JValue jval + && jval.Value is string settingsPath + && !string.IsNullOrWhiteSpace(settingsPath)) + return appsettingsFile; + } + catch { } + } + } + + //Valid appsettings.json not found. Try to create it in each folder. + var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented); + foreach (var appsettingsFile in possibleAppsettingsFiles) + { + try + { + File.WriteAllText(appsettingsFile, endingContents); + return appsettingsFile; + } + catch(Exception ex) + { + Log.Logger.TryLogError(ex, $"Failed to create {appsettingsFile}"); + } + } + + throw new ApplicationException($"Could not locate or create {appsettings_filename}"); + } + + private static string getLibationFilesSettingFromJson() { - string startingContents = null; - try - { - if (File.Exists(APPSETTINGS_JSON)) - { - startingContents = File.ReadAllText(APPSETTINGS_JSON); - var startingJObj = JObject.Parse(startingContents); - - if (startingJObj.ContainsKey(LIBATION_FILES_KEY)) - { - var startingValue = startingJObj[LIBATION_FILES_KEY].Value(); - if (!string.IsNullOrWhiteSpace(startingValue)) - return startingValue; - } - } - } - catch { } - - // not found. write to file. read from file - var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile.ToString() } }.ToString(Formatting.Indented); - if (startingContents != endingContents) - { - File.WriteAllText(APPSETTINGS_JSON, endingContents); - System.Threading.Thread.Sleep(100); - } - // do not check whether directory exists. special/meta directory (eg: AppDir) is valid // verify from live file. no try/catch. want failures to be visible - var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON)); + var jObjFinal = JObject.Parse(File.ReadAllText(AppsettingsJsonFile)); var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value(); return valueFinal; } - public void SetLibationFiles(string directory) + public static void SetLibationFiles(string directory) { - // ensure exists - if (!File.Exists(APPSETTINGS_JSON)) - { - // getter creates new file, loads PersistentDictionary - var _ = LibationFiles; - System.Threading.Thread.Sleep(100); - } - libationFilesPathCache = null; - var startingContents = File.ReadAllText(APPSETTINGS_JSON); + var startingContents = File.ReadAllText(AppsettingsJsonFile); var jObj = JObject.Parse(startingContents); jObj[LIBATION_FILES_KEY] = directory; @@ -100,14 +141,17 @@ namespace LibationFileManager if (startingContents == endingContents) return; - // now it's set in the file again but no settings have moved yet - File.WriteAllText(APPSETTINGS_JSON, endingContents); - try { - Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory }); - } - catch { } - } + // now it's set in the file again but no settings have moved yet + File.WriteAllText(AppsettingsJsonFile, endingContents); + + Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, directory }); + } + catch (IOException ex) + { + Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, directory }); + } + } } } diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index 4747331c..3c71abe9 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -9,9 +9,7 @@ namespace LibationFileManager { public partial class Configuration : PropertyChangeFilter { - public bool LibationSettingsAreValid - => File.Exists(APPSETTINGS_JSON) - && SettingsFileIsValid(SettingsFilePath); + public bool LibationSettingsAreValid => SettingsFileIsValid(SettingsFilePath); public static bool SettingsFileIsValid(string settingsFile) { diff --git a/Source/LibationFileManager/IInteropFunctions.cs b/Source/LibationFileManager/IInteropFunctions.cs index 423d438d..fd1cca37 100644 --- a/Source/LibationFileManager/IInteropFunctions.cs +++ b/Source/LibationFileManager/IInteropFunctions.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; namespace LibationFileManager { @@ -6,6 +7,8 @@ namespace LibationFileManager { void SetFolderIcon(string image, string directory); void DeleteFolderIcon(string directory); - void CopyTextToClipboard(string text); + Process RunAsRoot(string exe, string args); + void InstallUpdate(string updateBundle); + bool CanUpdate { get; } } } diff --git a/Source/LibationFileManager/NameListFormat.cs b/Source/LibationFileManager/NameListFormat.cs new file mode 100644 index 00000000..2ca5f94b --- /dev/null +++ b/Source/LibationFileManager/NameListFormat.cs @@ -0,0 +1,95 @@ +using FileManager.NamingTemplate; +using NameParser; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace LibationFileManager +{ + internal partial class NameListFormat + { + public static string Formatter(ITemplateTag _, IEnumerable names, string formatString) + { + var humanNames = names.Select(n => new HumanName(RemoveSuffix(n), Prefer.FirstOverPrefix)); + + var sortedNames = Sort(humanNames, formatString); + var nameFormatString = Format(formatString, defaultValue: "{T} {F} {M} {L} {S}"); + var separatorString = Separator(formatString, defaultValue: ", "); + var maxNames = Max(formatString, defaultValue: humanNames.Count()); + + var formattedNames = string.Join(separatorString, sortedNames.Take(maxNames).Select(n => FormatName(n, nameFormatString))); + + while (formattedNames.Contains(" ")) + formattedNames = formattedNames.Replace(" ", " "); + + return formattedNames; + } + + private static string RemoveSuffix(string namesString) + { + namesString = namesString.Replace('’', '\'').Replace(" - Ret.", ", Ret."); + int dashIndex = namesString.IndexOf(" - "); + return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim(); + } + + private static IEnumerable Sort(IEnumerable humanNames, string formatString) + { + var sortMatch = SortRegex().Match(formatString); + return + sortMatch.Success + ? sortMatch.Groups[1].Value == "F" ? humanNames.OrderBy(n => n.First) + : sortMatch.Groups[1].Value == "M" ? humanNames.OrderBy(n => n.Middle) + : sortMatch.Groups[1].Value == "L" ? humanNames.OrderBy(n => n.Last) + : humanNames + : humanNames; + } + + private static string Format(string formatString, string defaultValue) + { + var formatMatch = FormatRegex().Match(formatString); + return formatMatch.Success ? formatMatch.Groups[1].Value : defaultValue; + } + + private static string Separator(string formatString, string defaultValue) + { + var separatorMatch = SeparatorRegex().Match(formatString); + return separatorMatch.Success ? separatorMatch.Groups[1].Value : defaultValue; + } + + private static int Max(string formatString, int defaultValue) + { + var maxMatch = MaxRegex().Match(formatString); + return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : defaultValue; + } + + private static string FormatName(HumanName humanName, string nameFormatString) + { + //Single-word names parse as first names. Use it as last name. + var lastName = string.IsNullOrWhiteSpace(humanName.Last) ? humanName.First : humanName.Last; + + nameFormatString + = nameFormatString + .Replace("{T}", "{0}") + .Replace("{F}", "{1}") + .Replace("{M}", "{2}") + .Replace("{L}", "{3}") + .Replace("{S}", "{4}"); + + return string.Format(nameFormatString, humanName.Title, humanName.First, humanName.Middle, lastName, humanName.Suffix).Trim(); + } + + /// Sort must have exactly one of the characters F, M, or L + [GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")] + private static partial Regex SortRegex(); + /// Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} + [GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")] + private static partial Regex FormatRegex(); + /// Separator can be anything + [GeneratedRegex(@"[Ss]eparator\((.*?)\)")] + private static partial Regex SeparatorRegex(); + /// Max must have a 1 or 2-digit number + [GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")] + private static partial Regex MaxRegex(); + } +} diff --git a/Source/LibationFileManager/NullInteropFunctions.cs b/Source/LibationFileManager/NullInteropFunctions.cs index 7f0e6908..bf995d11 100644 --- a/Source/LibationFileManager/NullInteropFunctions.cs +++ b/Source/LibationFileManager/NullInteropFunctions.cs @@ -1,14 +1,18 @@ using System; +using System.Diagnostics; namespace LibationFileManager { public class NullInteropFunctions : IInteropFunctions { - public NullInteropFunctions() { } + + public NullInteropFunctions() { } public NullInteropFunctions(params object[] values) { } public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); - public void CopyTextToClipboard(string text) => throw new PlatformNotSupportedException(); - } + public bool CanUpdate => throw new PlatformNotSupportedException(); + public Process RunAsRoot(string exe, string args) => throw new PlatformNotSupportedException(); + public void InstallUpdate(string updateBundle) => throw new PlatformNotSupportedException(); + } } diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index f714637e..f68abeb5 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using AaxDecrypter; using Dinah.Core; using FileManager; @@ -19,7 +18,7 @@ namespace LibationFileManager static abstract IEnumerable TagCollections { get; } } - public abstract partial class Templates + public abstract class Templates { public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or "; @@ -203,9 +202,9 @@ namespace LibationFileManager { TemplateTags.Id, lb => lb.AudibleProductId, v => v }, { TemplateTags.Title, lb => lb.Title }, { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, - { TemplateTags.Author, lb => lb.Authors, NameListFormatter }, + { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter }, { TemplateTags.FirstAuthor, lb => lb.FirstAuthor }, - { TemplateTags.Narrator, lb => lb.Narrators, NameListFormatter }, + { TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter }, { TemplateTags.FirstNarrator, lb => lb.FirstNarrator }, { TemplateTags.Series, lb => lb.SeriesName }, { TemplateTags.SeriesNumber, lb => lb.SeriesNumber }, @@ -252,89 +251,6 @@ namespace LibationFileManager #region Tag Formatters - /// Sort must have exactly one of the characters F, M, or L - [GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")] - private static partial Regex NamesSortRegex(); - /// Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} - [GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")] - private static partial Regex NamesFormatRegex(); - /// Separator can be anything - [GeneratedRegex(@"[Ss]eparator\((.*?)\)")] - private static partial Regex NamesSeparatorRegex(); - /// Max must have a 1 or 2-digit number - [GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")] - private static partial Regex NamesMaxRegex(); - - private static string NameListFormatter(ITemplateTag templateTag, IEnumerable names, string formatString) - { - var humanNames = names.Select(n => new HumanName(removeSuffix(n), Prefer.FirstOverPrefix)); - - var sortedNames = sort(humanNames, formatString); - var nameFormatString = format(formatString, defaultValue: "{T} {F} {M} {L} {S}"); - var separatorString = separator(formatString, defaultValue: ", "); - var maxNames = max(formatString, defaultValue: humanNames.Count()); - - var formattedNames = string.Join(separatorString, sortedNames.Take(maxNames).Select(n => formatName(n, nameFormatString))); - - while (formattedNames.Contains(" ")) - formattedNames = formattedNames.Replace(" ", " "); - - return formattedNames; - - static string removeSuffix(string namesString) - { - namesString = namesString.Replace('’', '\'').Replace(" - Ret.", ", Ret."); - int dashIndex = namesString.IndexOf(" - "); - return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim(); - } - - static IEnumerable sort(IEnumerable humanNames, string formatString) - { - var sortMatch = NamesSortRegex().Match(formatString); - return - sortMatch.Success - ? sortMatch.Groups[1].Value == "F" ? humanNames.OrderBy(n => n.First) - : sortMatch.Groups[1].Value == "M" ? humanNames.OrderBy(n => n.Middle) - : sortMatch.Groups[1].Value == "L" ? humanNames.OrderBy(n => n.Last) - : humanNames - : humanNames; - } - - static string format(string formatString, string defaultValue) - { - var formatMatch = NamesFormatRegex().Match(formatString); - return formatMatch.Success ? formatMatch.Groups[1].Value : defaultValue; - } - - static string separator(string formatString, string defaultValue) - { - var separatorMatch = NamesSeparatorRegex().Match(formatString); - return separatorMatch.Success ? separatorMatch.Groups[1].Value : defaultValue; - } - - static int max(string formatString, int defaultValue) - { - var maxMatch = NamesMaxRegex().Match(formatString); - return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : defaultValue; - } - - static string formatName(HumanName humanName, string nameFormatString) - { - //Single-word names parse as first names. Use it as last name. - var lastName = string.IsNullOrWhiteSpace(humanName.Last) ? humanName.First : humanName.Last; - - nameFormatString - = nameFormatString - .Replace("{T}", "{0}") - .Replace("{F}", "{1}") - .Replace("{M}", "{2}") - .Replace("{L}", "{3}") - .Replace("{S}", "{4}"); - - return string.Format(nameFormatString, humanName.Title, humanName.First, humanName.Middle, lastName, humanName.Suffix).Trim(); - } - } - private static string getTitleShort(string title) => title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title; diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs index 1718ad34..73aea846 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs @@ -35,7 +35,7 @@ namespace LibationWinForms.Dialogs }, Configuration.KnownDirectories.UserProfile, "Books"); - booksSelectControl.SelectDirectory(config.Books); + booksSelectControl.SelectDirectory(config.Books.PathWithoutPrefix); saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder; betaOptInCbox.Checked = config.BetaOptIn; diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index 56e99a98..fc51a930 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -62,6 +62,8 @@ this.setDownloadedAutoToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); + this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.accountsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -378,6 +380,8 @@ this.settingsToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { this.accountsToolStripMenuItem, this.basicSettingsToolStripMenuItem, + this.toolStripSeparator4, + this.launchHangoverToolStripMenuItem, this.toolStripSeparator2, this.aboutToolStripMenuItem}); this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem"; @@ -572,6 +576,13 @@ this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks"; this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click); // + // launchHangoverToolStripMenuItem + // + this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem"; + this.launchHangoverToolStripMenuItem.Size = new System.Drawing.Size(247, 22); + this.launchHangoverToolStripMenuItem.Text = "Launch &Hangover"; + this.launchHangoverToolStripMenuItem.Click += new System.EventHandler(this.launchHangoverToolStripMenuItem_Click); + // // toolStripSeparator3 // this.toolStripSeparator3.Name = "toolStripSeparator3"; @@ -648,6 +659,8 @@ private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem; private System.Windows.Forms.ToolStripSeparator toolStripSeparator3; private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator4; + private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem; private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu; private System.Windows.Forms.SplitContainer splitContainer1; private LibationWinForms.ProcessQueue.ProcessQueueControl processBookQueue1; diff --git a/Source/LibationWinForms/Form1.Settings.cs b/Source/LibationWinForms/Form1.Settings.cs index 7b453008..5f6240d3 100644 --- a/Source/LibationWinForms/Form1.Settings.cs +++ b/Source/LibationWinForms/Form1.Settings.cs @@ -14,5 +14,17 @@ namespace LibationWinForms private void aboutToolStripMenuItem_Click(object sender, EventArgs e) => MessageBox.Show($"Libation {AppScaffolding.LibationScaffolding.Variety}{Environment.NewLine}Version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}"); - } + + private void launchHangoverToolStripMenuItem_Click(object sender, EventArgs e) + { + try + { + System.Diagnostics.Process.Start("Hangover.exe"); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to launch Hangover"); + } + } + } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 7096f2e7..7766a73c 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Data; using System.Drawing; using System.Linq; +using System.Threading.Tasks; using System.Windows.Forms; using ApplicationServices; using DataLayer; @@ -122,7 +123,7 @@ namespace LibationWinForms.GridView { var dgv = (DataGridView)sender; var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); - InteropFactory.Create().CopyTextToClipboard(text); + Clipboard.SetDataObject(text, false, 5, 150); } catch { } }); @@ -152,7 +153,7 @@ namespace LibationWinForms.GridView setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" }; - removeMenuItem.Click += (_, __) => LibraryCommands.RemoveBook(entry.AudibleProductId); + removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId)); var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." }; locateFileMenuItem.Click += (_, __) => diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 66ab6fc9..333114e3 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -92,7 +92,7 @@ namespace LibationWinForms // check for existing settings in default location var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - config.SetLibationFiles(defaultLibationFilesDir); + Configuration.SetLibationFiles(defaultLibationFilesDir); if (config.LibationSettingsAreValid) return; @@ -112,7 +112,7 @@ namespace LibationWinForms } if (setupDialog.IsNewUser) - config.SetLibationFiles(defaultLibationFilesDir); + Configuration.SetLibationFiles(defaultLibationFilesDir); else if (setupDialog.IsReturningUser) { var libationFilesDialog = new LibationFilesDialog(); @@ -123,7 +123,7 @@ namespace LibationWinForms return; } - config.SetLibationFiles(libationFilesDialog.SelectedDirectory); + Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); if (config.LibationSettingsAreValid) return; diff --git a/Source/LibationAvalonia/Libation.desktop b/Source/LoadByOS/LinuxConfigApp/Libation.desktop similarity index 100% rename from Source/LibationAvalonia/Libation.desktop rename to Source/LoadByOS/LinuxConfigApp/Libation.desktop diff --git a/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj b/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj index 9a7e5ba2..9d9a1b3e 100644 --- a/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj +++ b/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj @@ -34,4 +34,28 @@ + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + Always + + + Always + + + \ No newline at end of file diff --git a/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs b/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs index 08324141..bf488806 100644 --- a/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs +++ b/Source/LoadByOS/LinuxConfigApp/LinuxInterop.cs @@ -1,14 +1,73 @@ using LibationFileManager; +using System.Diagnostics; namespace LinuxConfigApp { internal class LinuxInterop : IInteropFunctions { - public LinuxInterop() { } + //Different terminal apps possibly installed on a linux system + // [0] console executable + // [1] argument to set the concole's title + // [2] argument to pass a command to be executed to the terminal + static readonly string[][] consoleCommands = + { + new[] {"konsole", "--title", "-e"}, + new[] {"gnome-terminal", "--title", "--"}, + new[] {"mate-terminal", "--title", "-x"}, + new[] {"xterm", "-T", "-e"}, + }; + + public LinuxInterop() { } public LinuxInterop(params object[] values) { } public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); - public void CopyTextToClipboard(string text) => throw new PlatformNotSupportedException(); - } + + //only run the audo updater is the current app was installed from the + //.deb package. Try to detect this by checking if the symlink exists. + public bool CanUpdate => Directory.Exists("/usr/bin/libation"); + public void InstallUpdate(string updateBundle) + { + RunAsRoot("apt", $"install '{updateBundle}'"); + } + + public Process RunAsRoot(string exe, string args) + { + //cribbed this script from VirtualBox's guest additions installer. + //It's designed to launch the system's gui superuser password + //prompt across multiple distributions and desktop environments. + const string runasroot = "/tmp/runasroot.sh"; + File.WriteAllBytes(runasroot, Properties.Resources.runasroot); + + string command = $"{exe ?? ""} {args ?? ""}".Trim(); + + foreach (var console in consoleCommands) + { + ProcessStartInfo psi = new() + { + FileName = console[0], + UseShellExecute = false, + ArgumentList = + { + console[1], + $"Running '{exe}' as root", + console[2], + "/bin/sh", + runasroot, + "Installing libation.deb", + command, + $"Please run '{command}' manually" + } + }; + + + try + { + return Process.Start(psi); + } + catch { } + } + return null; + } + } } diff --git a/Source/LoadByOS/LinuxConfigApp/Properties/Resources.Designer.cs b/Source/LoadByOS/LinuxConfigApp/Properties/Resources.Designer.cs new file mode 100644 index 00000000..b48060ab --- /dev/null +++ b/Source/LoadByOS/LinuxConfigApp/Properties/Resources.Designer.cs @@ -0,0 +1,73 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace LinuxConfigApp.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LinuxConfigApp.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] runasroot { + get { + object obj = ResourceManager.GetObject("runasroot", resourceCulture); + return ((byte[])(obj)); + } + } + } +} diff --git a/Source/LoadByOS/LinuxConfigApp/Properties/Resources.resx b/Source/LoadByOS/LinuxConfigApp/Properties/Resources.resx new file mode 100644 index 00000000..ef269a0f --- /dev/null +++ b/Source/LoadByOS/LinuxConfigApp/Properties/Resources.resx @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\runasroot.sh;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Source/LoadByOS/LinuxConfigApp/Resources/runasroot.sh b/Source/LoadByOS/LinuxConfigApp/Resources/runasroot.sh new file mode 100644 index 00000000..5c64d517 --- /dev/null +++ b/Source/LoadByOS/LinuxConfigApp/Resources/runasroot.sh @@ -0,0 +1,188 @@ +#!/bin/sh +# $Id: runasroot.sh 153224 2022-08-22 17:43:14Z klaus $ +## @file +# VirtualBox privileged execution helper script for Linux and Solaris +# + +# +# Copyright (C) 2009-2022 Oracle and/or its affiliates. +# +# This file is part of VirtualBox base platform packages, as +# available from https://www.virtualbox.org. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation, in version 3 of the +# License. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . +# +# SPDX-License-Identifier: GPL-3.0-only +# + +# Deal with differing "which" semantics +mywhich() { + which "$1" 2>/dev/null | grep -v "no $1" +} + +# Get the name and execute switch for a useful terminal emulator +# +# Sets $gxtpath to the emulator path or empty +# Sets $gxttitle to the "title" switch for that emulator +# Sets $gxtexec to the "execute" switch for that emulator +# May clobber $gtx* +# Calls mywhich +getxterm() { + # gnome-terminal uses -e differently to other emulators + for gxti in "konsole --title -e" "gnome-terminal --title -x" "xterm -T -e"; do + set $gxti + gxtpath="`mywhich $1`" + case "$gxtpath" in ?*) + gxttitle=$2 + gxtexec=$3 + return + ;; + esac + done +} + +# Quotes its argument by inserting '\' in front of every character save +# for 'A-Za-z0-9/'. Prints the result to stdout. +quotify() { + echo "$1" | sed -e 's/\([^a-zA-Z0-9/]\)/\\\1/g' +} + +ostype=`uname -s` +if test "$ostype" != "Linux" && test "$ostype" != "SunOS" ; then + echo "Linux/Solaris not detected." + exit 1 +fi + +HAS_TERMINAL="" +case "$1" in "--has-terminal") + shift + HAS_TERMINAL="yes" + ;; +esac + +case "$#" in "2"|"3") + ;; + *) + echo "Usage: `basename $0` DESCRIPTION COMMAND [ADVICE]" >&2 + echo >&2 + echo "Attempt to execute COMMAND with root privileges, displaying DESCRIPTION if" >&2 + echo "possible and displaying ADVICE if possible if no su(1)-like tool is available." >&2 + exit 1 + ;; +esac + +DESCRIPTION=$1 +COMMAND=$2 +ADVICE=$3 +PATH=$PATH:/bin:/usr/bin:/usr/local/bin:/sbin:/usr/sbin:/usr/local/sbin:/usr/X11/bin + +case "$ostype" in SunOS) + PATH=$PATH:/usr/sfw/bin:/usr/gnu/bin:/usr/xpg4/bin:/usr/xpg6/bin:/usr/openwin/bin:/usr/ucb + GKSU_SWITCHES="-au root" + ;; + *) + GKSU_SWITCHES="" + ;; +esac + +case "$HAS_TERMINAL" in "") + case "$DISPLAY" in ?*) + KDESUDO="`mywhich kdesudo`" + case "$KDESUDO" in ?*) + eval "`quotify "$KDESUDO"` --comment `quotify "$DESCRIPTION"` -- $COMMAND" + exit + ;; + esac + + KDESU="`mywhich kdesu`" + case "$KDESU" in ?*) + "$KDESU" -c "$COMMAND" + exit + ;; + esac + + GKSU="`mywhich gksu`" + case "$GKSU" in ?*) + # Older gksu does not grok --description nor '--' and multiple args. + # @todo which versions do? + # "$GKSU" --description "$DESCRIPTION" -- "$@" + # Note that $GKSU_SWITCHES is NOT quoted in the following + "$GKSU" $GKSU_SWITCHES "$COMMAND" + exit + ;; + esac + ;; + esac # $DISPLAY + ;; +esac # ! $HAS_TERMINAL + +# pkexec may work for ssh console sessions as well if the right agents +# are installed. However it is very generic and does not allow for any +# custom messages. Thus it comes after gksu. +## @todo should we insist on either a display or a terminal? +# case "$DISPLAY$HAS_TERMINAL" in ?*) +PKEXEC="`mywhich pkexec`" +case "$PKEXEC" in ?*) + eval "\"$PKEXEC\" $COMMAND" + exit + ;; +esac +# ;;S +#esac + +case "$HAS_TERMINAL" in ?*) + USE_SUDO= + grep -q Ubuntu /etc/lsb-release 2>/dev/null && USE_SUDO=true + # On Ubuntu we need sudo instead of su. Assume this works, and is only + # needed for Ubuntu until proven wrong. + case $USE_SUDO in true) + SUDO_COMMAND="`quotify "$SUDO"` -- $COMMAND" + eval "$SUDO_COMMAND" + exit + ;; + esac + + SU="`mywhich su`" + case "$SU" in ?*) + "$SU" - root -c "$COMMAND" + exit + ;; + esac + ;; +esac + +# The ultimate fallback is running 'su -' within an xterm. We use the +# title of the xterm to tell what is going on. +case "$DISPLAY" in ?*) + SU="`mywhich su`" + case "$SU" in ?*) + getxterm + case "$gxtpath" in ?*) + "$gxtpath" "$gxttitle" "$DESCRIPTION - su" "$gxtexec" su - root -c "$COMMAND" + exit + ;; + esac + esac +esac # $DISPLAY + +# Failure... +case "$DISPLAY" in ?*) + echo "Unable to locate 'pkexec', 'gksu' or 'su+xterm'. $ADVICE" >&2 + ;; + *) + echo "Unable to locate 'pkexec'. $ADVICE" >&2 + ;; +esac + +exit 1 diff --git a/Source/LibationAvalonia/glass-with-glow_256.svg b/Source/LoadByOS/LinuxConfigApp/glass-with-glow_256.svg similarity index 100% rename from Source/LibationAvalonia/glass-with-glow_256.svg rename to Source/LoadByOS/LinuxConfigApp/glass-with-glow_256.svg diff --git a/Source/LoadByOS/MacOSConfigApp/Info.plist b/Source/LoadByOS/MacOSConfigApp/Info.plist new file mode 100644 index 00000000..90cbcaf1 --- /dev/null +++ b/Source/LoadByOS/MacOSConfigApp/Info.plist @@ -0,0 +1,31 @@ + + + + + + CFBundleExecutable + Libation + CFBundleName + Libation + CFBundleIdentifier + org.libation.macos + NSHighResolutionCapable + + CFBundleIconFile + libation.icns + CFBundleVersion + VERSION_STRING + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.disable-executable-page-protection + + com.apple.security.automation.apple-events + + + \ No newline at end of file diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj index 55bb69bf..865067f1 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj +++ b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj @@ -34,4 +34,13 @@ + + + Always + + + Always + + + \ No newline at end of file diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs index 378e7323..8bf6f9a3 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs +++ b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs @@ -1,14 +1,78 @@ using LibationFileManager; +using System.Diagnostics; namespace MacOSConfigApp { internal class MacOSInterop : IInteropFunctions - { - public MacOSInterop() { } + { + private const string AppPath = "/Applications/Libation.app"; + public MacOSInterop() { } public MacOSInterop(params object[] values) { } - public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); + public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); - public void CopyTextToClipboard(string text) => throw new PlatformNotSupportedException(); - } + + //I haven't figured out how to find the app bundle's directory from within + //the running process, so don't update unless it's "installed" in /Applications + public bool CanUpdate => Directory.Exists(AppPath); + + public void InstallUpdate(string updateBundle) + { + Serilog.Log.Information($"Extracting update bundle to {AppPath}"); + + //tar wil overwrite existing without elevated privileges + Process.Start("tar", $"-xzf \"{updateBundle}\" -C \"/Applications\"").WaitForExit(); + + //For now, it seems like this step is unnecessary. We can overwrite and + //run Libation without needing to re-add the exception. This is insurance. + RunAsRoot(null, $""" +sudo spctl --master-disable +sudo spctl --add --label 'Libation' {AppPath} +open {AppPath} +sudo spctl --master-enable +"""); + } + + //Using osascript -e '[script]' works from the terminal, but I haven't figured + //out the syntax for it to work from create_process, so write to stdin instead. + public Process RunAsRoot(string _, string command) + { + const string osascript = "osascript"; + var fullCommand = $"do shell script \"{command}\" with administrator privileges"; + + var psi = new ProcessStartInfo() + { + FileName = osascript, + UseShellExecute = false, + Arguments = "-", + RedirectStandardError= true, + RedirectStandardOutput= true, + RedirectStandardInput= true, + }; + + Serilog.Log.Logger.Information($"running {osascript} as root: {{script}}", fullCommand); + + var proc = Process.Start(psi); + proc.ErrorDataReceived += Proc_ErrorDataReceived; + proc.OutputDataReceived += Proc_OutputDataReceived; + proc.BeginErrorReadLine(); + proc.BeginOutputReadLine(); + proc.StandardInput.WriteLine(fullCommand); + proc.StandardInput.Close(); + + return proc; + } + + private void Proc_OutputDataReceived(object sender, DataReceivedEventArgs e) + { + if (e.Data != null) + Serilog.Log.Logger.Information("stderr: {data}", e.Data); + } + + private void Proc_ErrorDataReceived(object sender, DataReceivedEventArgs e) + { + if (e.Data!= null) + Serilog.Log.Logger.Information("stderr: {data}", e.Data); + } + } } diff --git a/Source/LoadByOS/MacOSConfigApp/libation.icns b/Source/LoadByOS/MacOSConfigApp/libation.icns new file mode 100644 index 00000000..d2810d35 Binary files /dev/null and b/Source/LoadByOS/MacOSConfigApp/libation.icns differ diff --git a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs index ff4ef910..42cded34 100644 --- a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs +++ b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -35,8 +36,31 @@ namespace WindowsConfigApp public void DeleteFolderIcon(string directory) => new DirectoryInfo(directory)?.DeleteIcon(); + public bool CanUpdate => true; + public void InstallUpdate(string updateBundle) + { + var thisExe = Environment.ProcessPath; + var thisDir = Path.GetDirectoryName(thisExe); + var zipExtractor = Path.Combine(Path.GetTempPath(), "ZipExtractor.exe"); - public void CopyTextToClipboard(string text) - => Clipboard.SetText(text); + File.Copy("ZipExtractor.exe", zipExtractor, overwrite: true); + + RunAsRoot(zipExtractor, $"--input \"{updateBundle}\" --output \"{thisDir}\" --executable \"{thisExe}\""); + } + + public Process RunAsRoot(string exe, string args) + { + var psi = new ProcessStartInfo() + { + FileName = exe, + UseShellExecute = true, + Verb = "runas", + WindowStyle = ProcessWindowStyle.Normal, + CreateNoWindow = true, + Arguments = args + }; + + return Process.Start(psi); + } } } diff --git a/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj b/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj index 5e37bedb..0dba0cbf 100644 --- a/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj +++ b/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj @@ -40,4 +40,10 @@ + + + Always + + + \ No newline at end of file diff --git a/Source/LibationAvalonia/ZipExtractor.exe b/Source/LoadByOS/WindowsConfigApp/ZipExtractor.exe similarity index 100% rename from Source/LibationAvalonia/ZipExtractor.exe rename to Source/LoadByOS/WindowsConfigApp/ZipExtractor.exe