Merge pull request #489 from Mbucari/master

Mac App Bundle and added mp3 conversion support on mac
This commit is contained in:
rmcrackan 2023-02-15 08:33:06 -05:00 committed by GitHub
commit bb508c0718
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1082 additions and 316 deletions

View File

@ -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 }}"

43
.github/workflows/bundle-linux.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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: <Put a body here>
draft: true
prerelease: false

View File

@ -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"
}

View File

@ -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

10
Scripts/targz2deb.sh → Scripts/targz2linuxbundle.sh Executable file → Normal file
View File

@ -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!"

View File

@ -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!"

View File

@ -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
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> 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

View File

@ -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)

View File

@ -4,6 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AssemblyName>Hangover</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>hangover.ico</ApplicationIcon>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -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);

View File

@ -116,20 +116,6 @@
<ProjectReference Include="..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="glass-with-glow_256.svg">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.desktop">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="ZipExtractor.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />

View File

@ -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 //

View File

@ -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)

View File

@ -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");
}
}
}
}

View File

@ -14,7 +14,7 @@ namespace LibationAvalonia.Views
Opened += async (_, _) => await checkForUpdates();
}
private async Task checkForUpdates()
private async Task checkForUpdates()
{
async Task<string> 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<DialogResult>(this);
var interop = InteropFactory.Create();
var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, interop.CanUpdate).ShowDialog<DialogResult>(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)
{

View File

@ -131,6 +131,8 @@
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
<Separator />
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
<Separator />
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
</MenuItem>
</Menu>

View File

@ -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

View File

@ -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()
/// <summary>
/// Try to find appsettings.json in the following locations:
/// <list type="number">
/// <item>
/// <description>[App Directory]</description>
/// </item>
/// <item>
/// <description>%LocalAppData%\Libation</description>
/// </item>
/// <item>
/// <description>%AppData%\Libation</description>
/// </item>
/// <item>
/// <description>%Temp%\Libation</description>
/// </item>
/// </list>
///
/// If not found, try to create it in each of the same locations in-order until successful.
///
/// <para>This method must complete successfully for Libation to continue.</para>
/// </summary>
/// <returns>appsettings.json file path</returns>
/// <exception cref="ApplicationException">appsettings.json could not be found or created.</exception>
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<string>();
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<string>();
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 });
}
}
}
}

View File

@ -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)
{

View File

@ -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; }
}
}

View File

@ -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<string> 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<HumanName> Sort(IEnumerable<HumanName> 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();
}
/// <summary> Sort must have exactly one of the characters F, M, or L </summary>
[GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")]
private static partial Regex SortRegex();
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")]
private static partial Regex FormatRegex();
/// <summary> Separator can be anything </summary>
[GeneratedRegex(@"[Ss]eparator\((.*?)\)")]
private static partial Regex SeparatorRegex();
/// <summary> Max must have a 1 or 2-digit number </summary>
[GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")]
private static partial Regex MaxRegex();
}
}

View File

@ -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();
}
}

View File

@ -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<TagCollection> 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: <ch#> or <ch# 0>";
@ -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
/// <summary> Sort must have exactly one of the characters F, M, or L </summary>
[GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")]
private static partial Regex NamesSortRegex();
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")]
private static partial Regex NamesFormatRegex();
/// <summary> Separator can be anything </summary>
[GeneratedRegex(@"[Ss]eparator\((.*?)\)")]
private static partial Regex NamesSeparatorRegex();
/// <summary> Max must have a 1 or 2-digit number </summary>
[GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")]
private static partial Regex NamesMaxRegex();
private static string NameListFormatter(ITemplateTag templateTag, IEnumerable<string> 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<HumanName> sort(IEnumerable<HumanName> 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;

View File

@ -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;

View File

@ -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;

View File

@ -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");
}
}
}
}

View File

@ -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 += (_, __) =>

View File

@ -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;

View File

@ -34,4 +34,28 @@
<ProjectReference Include="..\..\AppScaffolding\AppScaffolding.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<None Update="glass-with-glow_256.svg">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.desktop">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -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;
}
}
}

View File

@ -0,0 +1,73 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 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.
// </auto-generated>
//------------------------------------------------------------------------------
namespace LinuxConfigApp.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// 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() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[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;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized resource of type System.Byte[].
/// </summary>
internal static byte[] runasroot {
get {
object obj = ResourceManager.GetObject("runasroot", resourceCulture);
return ((byte[])(obj));
}
}
}
}

View File

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="runasroot" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\runasroot.sh;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
</root>

View File

@ -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 <https://www.gnu.org/licenses>.
#
# 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

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Libation</string>
<key>CFBundleName</key>
<string>Libation</string>
<key>CFBundleIdentifier</key>
<string>org.libation.macos</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleIconFile</key>
<string>libation.icns</string>
<key>CFBundleVersion</key>
<string>VERSION_STRING</string>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>

View File

@ -34,4 +34,13 @@
<ProjectReference Include="..\..\AppScaffolding\AppScaffolding.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Info.plist">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="libation.icns">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -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);
}
}
}

Binary file not shown.

View File

@ -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);
}
}
}

View File

@ -40,4 +40,10 @@
<ProjectReference Include="..\..\AppScaffolding\AppScaffolding.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="ZipExtractor.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>