Merge pull request #1429 from Mbucari/master

Minor Bugfixes, new Workflows, and signed macOS bundles, refactor LibationFiles, improve libationCLI
This commit is contained in:
rmcrackan
2025-11-20 13:45:25 -05:00
committed by GitHub
103 changed files with 2474 additions and 1094 deletions

View File

@@ -6,63 +6,46 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
runs_on:
publish-r2r:
type: boolean
retention-days:
type: number
architecture:
type: string
description: "The GitHub hosted runner to use"
description: "CPU architecture targeted by the build."
required: true
OS:
type: string
description: >
The operating system targeted by the build.
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
required: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
RELEASE_NAME: "chardonnay"
jobs:
build:
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
runs-on: ${{ inputs.runs_on }}
runs-on: ubuntu-latest
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
inputVersion="${{ inputs.version_override }}"
if [[ "${#inputVersion}" -gt 0 ]]
then
version="${inputVersion}"
else
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
@@ -70,63 +53,31 @@ jobs:
id: publish
working-directory: ./Source
run: |
if [[ "${{ inputs.OS }}" == "MacOS" ]]
then
display_os="macOS"
RUNTIME_ID="osx-${{ inputs.architecture }}"
else
display_os="Linux"
RUNTIME_ID="linux-${{ inputs.architecture }}"
fi
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
echo "Runtime Identifier: $RUNTIME_ID"
echo "Output Directory: $OUTPUT"
dotnet publish \
LibationAvalonia/LibationAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LibationCli/LibationCli.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
HangoverAvalonia/HangoverAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
run: |
BUNDLE_DIR=$(pwd)
echo "Bundle dir: ${BUNDLE_DIR}"
cd ..
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.retention-days }}

104
.github/workflows/build-mac.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
# build-mac.yml
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
sign-app:
type: boolean
description: "Wheather to sign an notorize the app bundle and dmg."
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
jobs:
build:
name: "macOS-${{ inputs.architecture }}"
runs-on: macos-latest
env:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- uses: apple-actions/import-codesign-certs@v3
if: ${{ inputs.sign-app }}
with:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
run: |
SCRIPT=./Scripts/Bundle_MacOS.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Notarize bundle
if: ${{ inputs.sign-app }}
run: |
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
WAIT="--wait"
fi
echo "::debug::Submitting the disk image for notarization"
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
echo "$RESPONSE"
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
echo "::debug::Stapling the notarization ticket to the disk image"
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
fi
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,113 +6,77 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
jobs:
build:
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
name: "Windows-${{ matrix.release_name }}-x64"
runs-on: windows-latest
env:
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
RUNTIME_ID: "win-${{ inputs.architecture }}"
strategy:
matrix:
os: [Windows]
ui: [Avalonia]
release_name: [chardonnay]
include:
- os: Windows
ui: WinForms
- ui: WinForms
release_name: classic
prefix: Classic-
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
if ("${{ inputs.version_override }}".length -gt 0) {
$version = "${{ inputs.version_override }}"
} else {
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
}
"version=$version" >> $env:GITHUB_OUTPUT
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LibationCli/LibationCli.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
$PUBLISH_ARGS=@(
"--runtime", "win-x64",
"--configuration", "Release",
"--output", "../bin",
"-p:PublishProtocol=FileSystem",
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
"-p:SelfContained=true")
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish
working-directory: ./bin
run: |
$bin_dir = "${{ env.OUTPUT_NAME }}\"
$delfiles = @(
"WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json"
)
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
"WindowsConfigApp.deps.json")
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-x64.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
Compress-Archive -Path * -DestinationPath "$artifact"
- name: Publish artifact
uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
name: ${{ steps.zip.outputs.artifact }}
path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,26 +6,51 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
description: "Libation version number"
required: true
dotnet-version:
type: string
default: "9.x"
description: ".NET version to target"
run-unit-tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
description: "Whether to run unit tests prior to publishing."
publish-r2r:
type: boolean
description: "Whether to publish assemblies as ReadyToRun."
release:
type: boolean
description: "Whether this workflow is being called as a release"
retention-days:
type: number
description: "Number of days the artifacts are to be retained."
jobs:
jobs:
windows:
strategy:
matrix:
architecture: [x64]
uses: ./.github/workflows/build-windows.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
macOS:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-mac.yml
with:
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
secrets: inherit
linux:
strategy:
@@ -34,20 +59,11 @@ jobs:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: ubuntu-latest
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }}
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}
macos:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: macos-latest
OS: MacOS
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}

View File

@@ -8,7 +8,7 @@ on:
- "v*"
jobs:
prerelease:
runs-on: ubuntu-latest
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
@@ -31,9 +31,11 @@ jobs:
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
libation-version: ${{ needs.prerelease.outputs.version }}
publish-r2r: true
release: true
release:
needs: [prerelease, build]

View File

@@ -10,12 +10,31 @@ on:
branches: [master]
jobs:
get_version:
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get version
id: get_version
run: |
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
build:
needs: [get_version]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.get_version.outputs.version }}
retention-days: 14
run-unit-tests: true
docker:
needs: [get_version]
uses: ./.github/workflows/docker.yml
with:
version: ${GITHUB_SHA}
version: ${{ needs.get_version.outputs.version }}
release: false
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}

8
.gitignore vendored
View File

@@ -370,4 +370,10 @@ FodyWeavers.xsd
/__TODO.txt
/DataLayer/LibationContext.db
*/bin-Avalonia
*/bin-Avalonia
# macOS Directory Info
.DS_Store
# JetBrains Rider Settings
**/.idea/

View File

@@ -3,8 +3,8 @@
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
}

View File

@@ -10,12 +10,10 @@
- [Files and folders](#files-and-folders)
- [Settings](#settings)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
- [Command Line Interface](#command-line-interface)
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
@@ -39,59 +37,6 @@ In addition to the options that are enabled if you allow Libation to "fix up" th
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
```
help
libationcli --help
verb-specific help
libationcli scan --help
scan all libraries
libationcli scan
scan only libraries for specific accounts
libationcli scan nickname1 nickname2
convert all m4b files to mp3
libationcli convert
liberate all books and pdfs
libationcli liberate
liberate pdfs only
libationcli liberate --pdf
libationcli liberate -p
Copy the local sqlite database to postgres
libationcli copydb --connectionString "my postgres connection string"
libationcli copydb -c "my postgres connection string"
export library to file
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
@@ -113,4 +58,102 @@ The below video demonstrates using the theme editor to make changes to the Dark
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
#### Help
```console
libationcli --help
```
#### Verb-Specific Help
```console
libationcli scan --help
```
#### Scan All Libraries
```console
libationcli scan
```
#### Scan Only Libraries for Specific Accounts
```console
libationcli scan nickname1 nickname2
```
#### Convert All m4b Files to mp3
```console
libationcli convert
```
#### Liberate All Books and Pdfs
```console
libationcli liberate
```
#### Liberate Pdfs Only
```console
libationcli liberate --pdf
libationcli liberate -p
```
#### Force Book(s) to Re-Liberate
```console
libationcli liberate --force
libationcli liberate -f
```
#### List Libation Settings
```console
libationcli get-setting
libationcli get-setting -b
libationcli get-setting FileDownloadQuality
```
#### Override Libation Settings for the Command
```console
libationcli liberate B017V4IM1G -override FileDownloadQuality=Normal
libationcli liberate B017V4IM1G -o FileDownloadQuality=normal -o UseWidevine=true Request_xHE_AAC=true -f
```
#### Copy the Local SQLite Database to Postgres
```console
libationcli copydb --connectionString "my postgres connection string"
libationcli copydb -c "my postgres connection string"
```
#### Export Library to File
```console
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
```
#### Set Download Status
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
```console
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
#### Get a Content License Without Downloading
```console
libationcli get-license B017V4IM1G
```
#### Example Powershell Script to Download Four Differenf Versions f the Same Book
```powershell
$asin="B017V4IM1G"
$xHE_AAC=@('true', 'false')
$Qualities=@('Normal', 'High')
foreach($q in $Qualities){
foreach($x in $xHE_AAC){
$license = ./libationcli get-license $asin --override FileDownloadQuality=$q --override Request_xHE_AAC=$x
echo $($license | ConvertFrom-Json).ContentMetadata.content_reference
echo $license | ./libationcli liberate --force
}
}
```

View File

@@ -28,14 +28,6 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb

View File

@@ -3,6 +3,7 @@
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
SIGN_WITH_KEY=$1; shift
if [ -z "$BIN_DIR" ]
then
@@ -28,12 +29,9 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" $ARCH
if [ "$SIGN_WITH_KEY" != "true" ]
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
fi
BUNDLE=./Libation.app
@@ -74,6 +72,15 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
echo "Moving Libation_DS_Store file..."
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
echo "Moving background.png file..."
mv $BUNDLE_MACOS/background.png ./background.png
echo "Moving background.png file..."
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
@@ -81,27 +88,45 @@ sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
echo "Signing executables in: $BUNDLE"
codesign --force --deep -s - $BUNDLE
all_identities=$(security find-identity -v -p codesigning)
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
echo "Creating app bundle: $APP_FILE"
tar -czvf $APP_FILE $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing executables in: $BUNDLE"
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
codesign --verify --verbose "$BUNDLE"
else
echo "Signing with empty key: $BUNDLE"
codesign --force --deep -s - $BUNDLE
fi
mkdir bundle
echo "moving to ./bundle/$APP_FILE"
mv $APP_FILE ./bundle/$APP_FILE
echo "Creating app disk image: $DMG_FILE"
mkdir Libation
mv $BUNDLE ./Libation/$BUNDLE
mv Libation_DS_Store Libation/.DS_Store
mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
# Create a .DS_Store by:
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
# - Open the folder and edit it to your liking.
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
rm -r $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing $DMG_FILE"
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
fi
echo "Done!"

View File

@@ -28,14 +28,6 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
BASEDIR=$(pwd)
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')

View File

@@ -15,6 +15,7 @@ namespace AaxDecrypter
KeyPart2 = keyPart2;
}
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));

View File

@@ -306,7 +306,7 @@ namespace AaxDecrypter
if (WritePosition > endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (TaskCanceledException)
catch (OperationCanceledException)
{
Serilog.Log.Information("Download was cancelled");
}
@@ -402,7 +402,7 @@ namespace AaxDecrypter
*/
protected override void Dispose(bool disposing)
{
if (disposing && !disposed)
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
{
_cancellationSource.Cancel();
DownloadTask?.GetAwaiter().GetResult();
@@ -413,7 +413,6 @@ namespace AaxDecrypter
OnUpdate(waitForWrite: true);
}
disposed = true;
base.Dispose(disposing);
}

View File

@@ -79,8 +79,17 @@ namespace AppScaffolding
}
/// <summary>most migrations go in here</summary>
public static void RunPostConfigMigrations(Configuration config)
public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
{
if (ephemeralSettings)
{
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
config.LoadEphemeralSettings(settings);
}
else
{
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
}
AudibleApiStorage.EnsureAccountsSettingsFileExists();
//
@@ -150,7 +159,7 @@ namespace AppScaffolding
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "Log.log") },
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -433,8 +442,8 @@ namespace AppScaffolding
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{

View File

@@ -7,6 +7,7 @@ using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#nullable enable
namespace AppScaffolding
{
/// <summary>
@@ -20,21 +21,21 @@ namespace AppScaffolding
/// </summary>
public static class UNSAFE_MigrationHelper
{
public static string SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
public static string? SettingsDirectory
=> !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
#region appsettings.json
public static bool APPSETTINGS_TryGet(string key, out string value)
public static bool APPSETTINGS_TryGet(string key, out string? value)
{
bool success = false;
JToken val = null;
JToken? val = null;
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
value = success ? val?.Value<string>() : null;
return success;
}
@@ -59,7 +60,10 @@ 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)
{
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
return;
var startingContents = File.ReadAllText(appSettingsFile);
JObject jObj;
try
@@ -82,40 +86,37 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
public static bool Settings_TryGet(string key, out string value)
public static bool Settings_TryGet(string key, out string? value)
{
bool success = false;
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
value = success ? val?.Value<string>() : null;
return success;
}
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
{
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
@@ -157,10 +158,10 @@ namespace AppScaffolding
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
JArray? array = null;
process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
length = array.Count;
length = array?.Count ?? 0;
return true;
}
@@ -171,8 +172,7 @@ namespace AppScaffolding
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
array.Add(newValue);
(jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
});
}
@@ -200,8 +200,7 @@ namespace AppScaffolding
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
if (position < array.Count)
if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
array.RemoveAt(position);
});
}
@@ -228,7 +227,7 @@ namespace AppScaffolding
private static void process_SettingsJson(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!SettingsJson_Exists)
if (!File.Exists(SettingsJsonPath))
return;
var startingContents = File.ReadAllText(SettingsJsonPath);
@@ -260,7 +259,7 @@ namespace AppScaffolding
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}

View File

@@ -70,7 +70,7 @@ namespace ApplicationServices
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -150,7 +150,7 @@ namespace ApplicationServices
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -268,7 +268,7 @@ namespace ApplicationServices
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);

View File

@@ -25,7 +25,7 @@ namespace AudibleUtilities
public static class AudibleApiStorage
{
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;

View File

@@ -25,16 +25,17 @@ namespace DataLayer
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId, bool caseSensative = true)
{
var libraryQuery
= context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId);
.GetLibrary();
public static LibraryBook? GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
}
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)

View File

@@ -23,6 +23,11 @@ namespace FileLiberator
private CancellationTokenSource? cancellationTokenSource;
private AudiobookDownloadBase? abDownloader;
/// <summary>
/// Optional override to supply license info directly instead of querying the api based on Configuration options
/// </summary>
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override async Task CancelAsync()
{
@@ -44,7 +49,9 @@ namespace FileLiberator
DownloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
LicenseInfo ??= await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration.Instance, cancellationToken);
using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration.Instance, LicenseInfo);
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile)

View File

@@ -4,9 +4,9 @@ using AudibleApi.Common;
using AudibleUtilities.Widevine;
using DataLayer;
using Dinah.Core;
using DocumentFormat.OpenXml.Wordprocessing;
using LibationFileManager;
using NAudio.Lame;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
@@ -21,9 +21,9 @@ namespace FileLiberator;
public partial class DownloadOptions
{
/// <summary>
/// Initiate an audiobook download from the audible api.
/// Requests a download license from the Api using the Configuration settings to choose the appropriate content.
/// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{
var license = await ChooseContent(api, libraryBook, config, token);
Serilog.Log.Logger.Debug("Content License {@License}", new
@@ -65,14 +65,20 @@ public partial class DownloadOptions
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
token.ThrowIfCancellationRequested();
return BuildDownloadOptions(libraryBook, config, license);
return license;
}
private class LicenseInfo
public class LicenseInfo
{
public DrmType DrmType { get; }
public ContentMetadata ContentMetadata { get; }
public KeyData[]? DecryptionKeys { get; }
public DrmType DrmType { get; set; }
public ContentMetadata ContentMetadata { get; set; }
public KeyData[]? DecryptionKeys { get; set; }
[JsonConstructor]
private LicenseInfo()
{
ContentMetadata = null!;
}
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
{
DrmType = license.DrmType;
@@ -159,7 +165,10 @@ public partial class DownloadOptions
}
}
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
/// <summary>
/// Builds DownloadOptions from the given LibraryBook, Configuration, and LicenseInfo.
/// </summary>
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
= config.StripAudibleBrandAudio

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
#nullable enable
namespace FileManager
{
public static class FileSystemTest
@@ -15,8 +16,10 @@ namespace FileManager
/// <summary>
/// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, &lt;, &gt;, |).
/// </summary>
public static bool CanWriteWindowsInvalidChars(LongPath directoryName)
public static bool CanWriteWindowsInvalidChars(LongPath? directoryName)
{
if (directoryName is null)
return false;
var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString());
return CanWriteFile(testFile);
}
@@ -24,8 +27,10 @@ namespace FileManager
/// <summary>
/// Test if the directory supports filenames with 255 unicode characters.
/// </summary>
public static bool CanWrite255UnicodeChars(LongPath directoryName)
public static bool CanWrite255UnicodeChars(LongPath? directoryName)
{
if (directoryName is null)
return false;
const char unicodeChar = 'ü';
var testFileName = new string(unicodeChar, 255);
var testFile = Path.Combine(directoryName, testFileName);

View File

@@ -263,5 +263,27 @@ namespace FileManager
return foundFiles;
}
/// <summary>
/// Creates a subdirectory or subdirectories on the specified path.
/// The specified path can be relative to this instance of the <see cref="DirectoryInfo"/> class.
/// <para/>
/// Fixes an issue with <see cref="DirectoryInfo.CreateSubdirectory(string)"/> where it fails when the parent <see cref="DirectoryInfo"/> is a drive root.
/// </summary>
/// <param name="path">The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name.</param>
/// <returns>The last directory specified in <paramref name="path"/></returns>
public static DirectoryInfo CreateSubdirectoryEx(this DirectoryInfo parent, string path)
{
if (parent.Root.FullName != parent.FullName || Path.IsPathRooted(path))
return parent.CreateSubdirectory(path);
// parent is a drive root and subDirectory is relative
//Solves a problem with DirectoryInfo.CreateSubdirectory where it fails
//If the parent DirectoryInfo is a drive root.
var fullPath = Path.GetFullPath(Path.Combine(parent.FullName, path));
var directoryInfo = new DirectoryInfo(fullPath);
directoryInfo.Create();
return directoryInfo;
}
}
}

View File

@@ -5,7 +5,7 @@ using System.Linq;
#nullable enable
namespace FileManager;
public interface IPersistentDictionary
public interface IJsonBackedDictionary
{
bool Exists(string propertyName);
string? GetString(string propertyName, string? defaultValue = null);

View File

@@ -8,7 +8,7 @@ using Newtonsoft.Json.Linq;
#nullable enable
namespace FileManager
{
public class PersistentDictionary : IPersistentDictionary
public class PersistentDictionary : IJsonBackedDictionary
{
public string Filepath { get; }
public bool IsReadOnly { get; }
@@ -59,7 +59,7 @@ namespace FileManager
objectCache[propertyName] = defaultValue;
return defaultValue;
}
return IPersistentDictionary.UpCast<T>(obj);
return IJsonBackedDictionary.UpCast<T>(obj);
}
public object? GetObject(string propertyName)

View File

@@ -109,6 +109,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLayer.Postgres", "DataL
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLayer.Sqlite", "DataLayer.Sqlite\DataLayer.Sqlite.csproj", "{1E689E85-279E-39D4-7D97-3E993FB6D95B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibationUiBase.Tests", "_Tests\LibationUiBase.Tests\LibationUiBase.Tests.csproj", "{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -239,6 +241,10 @@ Global
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Release|Any CPU.Build.0 = Release|Any CPU
{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -280,6 +286,7 @@ Global
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
{1E689E85-279E-39D4-7D97-3E993FB6D95B} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}

View File

@@ -84,6 +84,9 @@
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style>
<Style Selector="Button.SaveButton">
<Setter Property="Padding" Value="30,6" />
</Style>
<Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
@@ -97,7 +100,11 @@
<ContentPresenter Background="{DynamicResource SystemRegionColor}" Content="{TemplateBinding Content}" />
</ControlTemplate>
</Setter>
</Style>
<Style Selector="^ Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Style>
</Application.Styles>
<NativeMenu.Menu>

View File

@@ -1,4 +1,5 @@
using ApplicationServices;
using AppScaffolding;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -12,6 +13,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.Themes;
using LibationAvalonia.Views;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
@@ -41,49 +43,70 @@ public class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Chardonnay uses the OnLastWindowClose shutdown mode. As long as the application lifetime
// has one active window, the application will stay alive. Setup windows must be daisy chained,
// each closing windows opens the next window before closing itself to prevent the app from exiting.
// Chardonnay uses the OnExplicitShutdown shutdown mode. The application will stay alive until
// Shutdown() is called on App.Current.ApplicationLifetime.
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
Configuration config = Configuration.Instance;
if (!config.LibationSettingsAreValid)
{
string defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
// check for existing settings in default location
string defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
Configuration.SetLibationFiles(defaultLibationFilesDir);
if (config.LibationSettingsAreValid)
{
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
ShowMainWindow(desktop);
}
else
{
SetupDialog setupDialog = new() { Config = config };
setupDialog.Closing += (_, e) => SetupClosing(setupDialog, desktop, e);
desktop.MainWindow = setupDialog;
}
}
else
{
ShowMainWindow(desktop);
}
RunSetupIfNeededAsync(desktop, Configuration.Instance);
}
base.OnFrameworkInitializationCompleted();
}
private static async void RunSetupIfNeededAsync(IClassicDesktopStyleApplicationLifetime desktop, Configuration config)
{
var setup = new LibationSetup(config.LibationFiles)
{
SetupPromptAsync =() => ShowSetupAsync(desktop),
SelectFolderPromptAsync = () => SelectInstallLocation(desktop, config.LibationFiles)
};
if (await setup.RunSetupIfNeededAsync())
{
// setup succeeded or wasn't needed and LibationFiles are valid
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
ShowMainWindow(desktop);
}
else
{
await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
desktop.Shutdown(-1);
}
}
static async Task<ILibationSetup> ShowSetupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
var tcs = new TaskCompletionSource<ILibationSetup>();
var setupDialog = new SetupDialog();
desktop.MainWindow = setupDialog;
setupDialog.Closed += (_, _) => tcs.SetResult(setupDialog);
setupDialog.Show();
return await tcs.Task;
}
static async Task<ILibationInstallLocation?> SelectInstallLocation(IClassicDesktopStyleApplicationLifetime desktop, LibationFiles libationFiles)
{
var tcs = new TaskCompletionSource<ILibationInstallLocation>();
var libationFilesDialog = new LibationFilesDialog(libationFiles.Location.PathWithoutPrefix);
desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Closed += (_, _) => tcs.SetResult(libationFilesDialog);
libationFilesDialog.Show();
return await tcs.Task;
}
private static async Task RunMigrationsAsync(Configuration config)
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
// logging is init'd here
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -97,134 +120,6 @@ public class App : Application
}
}
private async void SetupClosing(SetupDialog setupDialog, IClassicDesktopStyleApplicationLifetime desktop, System.ComponentModel.CancelEventArgs e)
{
try
{
if (setupDialog.IsNewUser)
{
Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory);
setupDialog.Config.Books = Configuration.DefaultBooksDirectory;
if (setupDialog.Config.LibationSettingsAreValid)
{
string? theme = setupDialog.SelectedTheme.Content as string;
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
await RunMigrationsAsync(setupDialog.Config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
e.Cancel = true;
await CancelInstallation(setupDialog);
}
}
else if (setupDialog.IsReturningUser)
{
ShowLibationFilesDialog(desktop, setupDialog.Config);
}
else
{
e.Cancel = true;
await CancelInstallation(setupDialog);
}
}
catch (Exception ex)
{
string title = "Fatal error, pre-logging";
string body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file.";
MessageBoxAlertAdminDialog alert = new(body, title, ex);
desktop.MainWindow = alert;
alert.Show();
}
}
private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config)
{
LibationFilesDialog libationFilesDialog = new();
desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Show();
async void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
libationFilesDialog.Closing -= WindowClosing;
e.Cancel = true;
if (libationFilesDialog.DialogResult == DialogResult.OK)
OnLibationFilesCompleted(desktop, libationFilesDialog, config);
else
await CancelInstallation(libationFilesDialog);
}
libationFilesDialog.Closing += WindowClosing;
}
private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config)
{
Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
// path did not result in valid settings
DialogResult continueResult = await MessageBox.Show(
libationFilesDialog,
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
"New install?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (continueResult == DialogResult.Yes)
{
config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books));
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
await CancelInstallation(libationFilesDialog);
}
}
else
{
await CancelInstallation(libationFilesDialog);
}
}
libationFilesDialog.Close();
}
private static async Task CancelInstallation(Window window)
{
await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Environment.Exit(-1);
}
private async Task RunMigrationsAsync(Configuration config)
{
// most migrations go in here
AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config);
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
// logging is init'd here
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config);
Program.LoggingEnabled = true;
}
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
{
Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged;
@@ -234,6 +129,7 @@ public class App : Application
MainWindow mainWindow = new();
desktop.MainWindow = MainWindow = mainWindow;
mainWindow.Loaded += MainWindow_Loaded;
mainWindow.Closed += (_, _) => desktop.Shutdown();
mainWindow.RestoreSizeAndLocation(Configuration.Instance);
mainWindow.Show();
}

View File

@@ -412,7 +412,7 @@
<Button
Grid.Column="1"
Content="Edit"
Padding="30,0"
Classes="SaveButton"
Margin="10,0,0,0"
VerticalAlignment="Stretch"
Click="EditChapterTitleTemplateButton_Click" />

View File

@@ -7,14 +7,6 @@
x:Class="LibationAvalonia.Dialogs.AccountsDialog"
Title="Audible Accounts">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="False"
@@ -107,13 +99,12 @@
<Button
Grid.Column="0"
Padding="5,5"
Content="Import from audible-cli"
Click="ImportButton_Clicked" />
<Button
Grid.Column="1"
Padding="30,5"
Classes="SaveButton"
Content="Save"
Click="SaveButton_Clicked" />
</Grid>

View File

@@ -13,12 +13,6 @@
Title="Book Details" Name="BookDetails">
<Grid RowDefinitions="*,Auto,Auto,40">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,*,Auto" Margin="10">
<Image Source="{Binding Cover}" />
@@ -146,7 +140,7 @@
<Button
Grid.Column="1"
Content="Save"
Padding="30,3,30,3"
Classes="SaveButton"
Click="SaveButton_Clicked" />
</Grid>

View File

@@ -8,14 +8,6 @@
Title="BookRecordsDialog">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="True"

View File

@@ -1,13 +1,15 @@
using Avalonia;
using Avalonia.Controls;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class DescriptionDisplayDialog : Window
{
public Point SpawnLocation { get; set; }
public string DescriptionText { get; init; }
public string? DescriptionText { get; init; }
public DescriptionDisplayDialog()
{
InitializeComponent();
@@ -17,15 +19,15 @@ namespace LibationAvalonia.Dialogs
Opened += DescriptionDisplay_Opened;
}
private void DescriptionDisplay_Opened(object sender, EventArgs e)
private void DescriptionDisplay_Opened(object? sender, EventArgs e)
{
DescriptionTextBox.Focus();
}
private void DescriptionDisplay_Activated(object sender, EventArgs e)
private void DescriptionDisplay_Activated(object? sender, EventArgs e)
{
DataContext = this;
var workingHeight = this.Screens.Primary.WorkingArea.Height;
var workingHeight = (Screens.ScreenFromTopLevel(this) ?? Screens.Primary ?? Screens.All.FirstOrDefault())?.WorkingArea.Height ?? 1080;
DescriptionTextBox.Measure(new Size(DescriptionTextBox.MinWidth, workingHeight * 0.8));
this.Width = DescriptionTextBox.DesiredSize.Width;

View File

@@ -9,14 +9,6 @@
Title="Edit Quick Filters"
x:DataType="dialogs:EditQuickFilters">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="False"
@@ -108,7 +100,7 @@
<Button
Grid.Column="1"
Padding="30,5"
Classes="SaveButton"
Name="saveBtn"
Content="Save"
Command="{Binding SaveAndClose}" />

View File

@@ -66,8 +66,11 @@ namespace LibationAvalonia.Dialogs
ControlToFocusOnShow = this.FindControl<Button>(nameof(saveBtn));
var allFilters = QuickFilters.Filters.Select(f => new Filter { FilterString = f.Filter, Name = f.Name }).ToList();
allFilters[0].IsTop = true;
allFilters[^1].IsBottom = true;
if (allFilters.Count > 0)
{
allFilters[0].IsTop = true;
allFilters[^1].IsBottom = true;
}
allFilters.Add(new Filter());
foreach (var f in allFilters)

View File

@@ -75,7 +75,7 @@
<Button Grid.Row="1" Grid.Column="3" IsVisible="{Binding !EnvironmentIsWindows}" Command="{Binding Barebones}" CommandParameter="True" Content="Barebones" />
<Button Grid.RowSpan="2" Grid.Column="4" Command="{Binding Close}" Content="Cancel" />
<Button Grid.RowSpan="2" Grid.Column="5" Padding="20,6" Command="{Binding SaveAndClose}" Content="Save" />
<Button Grid.RowSpan="2" Grid.Column="5" Classes="SaveButton" Command="{Binding SaveAndClose}" Content="Save" />
</Grid>
</Grid>
</Window>

View File

@@ -110,7 +110,7 @@
Command="{Binding GoToNamingTemplateWiki}" />
<Button
Grid.Row="2"
Padding="30,5,30,5"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Save"
Click="SaveButton_Click" />

View File

@@ -26,7 +26,7 @@ public partial class EditTemplateDialog : DialogWindow
if (Design.IsDesignMode)
{
var mockInstance = Configuration.CreateMockInstance();
mockInstance.Books = Configuration.DefaultBooksDirectory;
mockInstance.Books = "/Path/To/Books";
RequestedThemeVariant = ThemeVariant.Dark;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(mockInstance.Books, mockInstance.FileTemplate);
_viewModel = new(mockInstance, editor);

View File

@@ -25,7 +25,7 @@
Grid.Row="1"
HorizontalAlignment="Right"
Margin="5"
Padding="30,3,30,3"
Classes="SaveButton"
Content="Save"
Click="Save_Click" />
</Grid>

View File

@@ -1,10 +1,12 @@
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using System.Collections.Generic;
using System.IO;
namespace LibationAvalonia.Dialogs
{
public partial class LibationFilesDialog : DialogWindow
public partial class LibationFilesDialog : DialogWindow, ILibationInstallLocation
{
private class DirSelectOptions
{
@@ -15,7 +17,7 @@ namespace LibationAvalonia.Dialogs
Configuration.KnownDirectories.MyDocs
};
public string Directory { get; set; } = Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile);
public string Directory { get; set; }
}
private readonly DirSelectOptions dirSelectOptions;
@@ -28,12 +30,29 @@ namespace LibationAvalonia.Dialogs
DataContext = dirSelectOptions = new();
}
public LibationFilesDialog(string initialDir)
{
dirSelectOptions = new();
dirSelectOptions.Directory = Directory.Exists(initialDir) ? initialDir : Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile);
InitializeComponent();
DataContext = dirSelectOptions;
}
public async void Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (!System.IO.Directory.Exists(SelectedDirectory))
{
await MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + SelectedDirectory, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error, saveAndRestorePosition: false);
return;
try
{
Directory.CreateDirectory(SelectedDirectory);
}
catch
{
await MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + SelectedDirectory, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error, saveAndRestorePosition: false);
return;
}
}
await SaveAndCloseAsync();

View File

@@ -37,7 +37,8 @@
<Button
Grid.Row="1"
Padding="30,5"
Classes="SaveButton"
Name="SaveButton"
HorizontalAlignment="Right"
Content="Save"
Command="{Binding SaveAndClose}"/>

View File

@@ -9,6 +9,7 @@ namespace LibationAvalonia.Dialogs
{
InitializeComponent();
DataContext = this;
ControlToFocusOnShow = SaveButton;
}
}
}

View File

@@ -53,10 +53,11 @@
Grid.Row="1"
Grid.Column="1"
Margin="10,0"
Padding="30,5"
Classes="SaveButton"
VerticalAlignment="Stretch"
HorizontalAlignment="Right"
Content="Save"
Name="SaveButton"
Click="SaveButton_Clicked" />
</Grid>
</Window>

View File

@@ -44,6 +44,7 @@ namespace LibationAvalonia.Dialogs
InitializeComponent();
SelectedItem = BookStatuses[0] as liberatedComboBoxItem;
DataContext = this;
ControlToFocusOnShow = SaveButton;
}
public void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)

View File

@@ -53,7 +53,6 @@ namespace LibationAvalonia.Dialogs.Login
Source = new Uri(url)
};
dialog.AdapterCreated += Dialog_AdapterCreated;
dialog.NavigationCompleted += Dialog_NavigationCompleted;
dialog.Closing += (_, _) => tcs.TrySetResult(null);
dialog.NavigationStarted += (_, e) =>
@@ -81,15 +80,6 @@ namespace LibationAvalonia.Dialogs.Login
}
}
private void Dialog_AdapterCreated(object? sender, WebViewAdapterEventArgs e)
{
if ((sender as NativeWebDialog)?.TryGetWindow() is Window window)
{
window.Width = 450;
window.Height = 700;
}
}
private static string getScript(string accountID) => $$"""
(function() {
function populateForm(){

View File

@@ -102,7 +102,8 @@
<Button
Grid.Row="1"
Margin="0,5,0,0"
Padding="30,3,30,3" HorizontalAlignment="Right"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Submit"
Click="Submit_Click" />
</Grid>

View File

@@ -64,11 +64,10 @@
<Button
Grid.Row="3"
Height="30"
HorizontalAlignment="Center"
Margin="10,10,10,10"
Padding="30,3,30,3"
Margin="10"
Name="OkButton"
Classes="SaveButton"
Content="Ok"
Click="OkButton_Clicked" />
</Grid>

View File

@@ -52,7 +52,7 @@ namespace LibationAvalonia.Dialogs
LongPath dir = "";
try
{
dir = LibationFileManager.Configuration.Instance.LibationFiles;
dir = LibationFileManager.Configuration.Instance.LibationFiles.Location;
Go.To.Folder(dir.ShortPathName);
}
catch

View File

@@ -36,12 +36,6 @@
</DockPanel>
<DockPanel Height="45" Grid.Row="1" Background="{DynamicResource SystemChromeMediumLowColor}">
<DockPanel.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</DockPanel.Styles>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button1Text}"/>

View File

@@ -18,14 +18,6 @@
ColumnDefinitions="*,Auto"
RowDefinitions="Auto,*,Auto"
Margin="10">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<TextBlock
Grid.Row="0"
Grid.Column="0"
@@ -61,7 +53,7 @@
<Button
Grid.Row="2"
Grid.Column="1"
Padding="30,6"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Import"
Name="ImportButton"

View File

@@ -18,8 +18,7 @@
Grid.Row="1"
Margin="10"
HorizontalAlignment="Right"
Height="30"
Padding="30,3,30,3"
Classes="SaveButton"
Content="Save"
Name="saveBtn"
Click="SaveButton_Clicked" />

View File

@@ -1,15 +1,15 @@
using Avalonia.Controls;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
namespace LibationAvalonia.Dialogs
{
public partial class SetupDialog : Window
public partial class SetupDialog : Window, ILibationSetup
{
public bool IsNewUser { get; private set; }
public bool IsReturningUser { get; private set; }
public ComboBoxItem SelectedTheme { get; set; }
public Configuration Config { get; init; }
public SetupDialog()
{
InitializeComponent();

View File

@@ -24,7 +24,7 @@
Grid.Row="1"
Grid.Column="1"
Margin="10,0,0,0"
Padding="20,3"
Classes="SaveButton"
VerticalAlignment="Stretch"
Content="Save"
Command="{Binding SaveAndClose}"/>

View File

@@ -14,8 +14,7 @@
RowDefinitions="*,Auto,Auto">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="Height" Value="30" />
<Setter Property="Padding" Value="20,0" />
<Setter Property="Padding" Value="20,6" />
<Setter Property="Margin" Value="5" />
</Style>
</Grid.Styles>

View File

@@ -77,7 +77,8 @@
Grid.Column="1"
TabIndex="0"
FontSize="16"
Padding="30,0,30,0"
Name="btnYes"
Classes="SaveButton"
VerticalAlignment="Stretch"
HorizontalAlignment="Right"
VerticalContentAlignment="Center"

View File

@@ -27,6 +27,7 @@ namespace LibationAvalonia.Dialogs
}
InitializeComponent();
ControlToFocusOnShow = btnYes;
}
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade) : this()

View File

@@ -41,7 +41,7 @@ namespace LibationAvalonia
savedState.Width = (int)form.Width;
savedState.Height = (int)form.Height;
}
if (form.Screens.Primary is Screen primaryScreen)
if ((form.Screens.Primary ?? form.Screens.All.FirstOrDefault()) is Screen primaryScreen)
{
// Fit to the current screen size in case the screen resolution changed since the size was last persisted
if (savedState.Width > primaryScreen.WorkingArea.Width)

View File

@@ -152,13 +152,22 @@ namespace LibationAvalonia
dialog.Width = dialog.MinWidth;
return dialog;
}
private static async Task<DialogResult> DisplayWindow(Window toDisplay, Window owner)
private static async Task<DialogResult> DisplayWindow(DialogWindow toDisplay, Window owner)
{
if (owner is null)
{
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
return await toDisplay.ShowDialog<DialogResult>(desktop.MainWindow);
if (desktop.MainWindow.IsLoaded)
return await toDisplay.ShowDialog<DialogResult>(desktop.MainWindow);
else
{
var tcs = new TaskCompletionSource<DialogResult>();
desktop.MainWindow = toDisplay;
toDisplay.Closed += (_, _) => tcs.SetResult(toDisplay.DialogResult);
toDisplay.Show();
return await tcs.Task;
}
}
else
{

View File

@@ -12,6 +12,7 @@ using LibationAvalonia.Dialogs;
using Avalonia.Threading;
using FileManager;
using System.Linq;
using System.Reflection;
#nullable enable
namespace LibationAvalonia
@@ -19,7 +20,6 @@ namespace LibationAvalonia
static class Program
{
private static System.Threading.Lock SetupLock { get; } = new();
internal static bool LoggingEnabled { get; set; }
[STAThread]
static void Main(string[] args)
{
@@ -51,21 +51,20 @@ namespace LibationAvalonia
try
{
var config = LibationScaffolding.RunPreConfigMigrations();
if (config.LibationSettingsAreValid)
if (config.LibationFiles.SettingsAreValid)
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
LoggingEnabled = true;
//Start loading the library before loading the main form
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
}
BuildAvaloniaApp()?.StartWithClassicDesktopLifetime([]);
BuildAvaloniaApp()?.StartWithClassicDesktopLifetime([], ShutdownMode.OnExplicitShutdown);
}
catch (Exception ex)
{
if (new StackTrace(ex).GetFrames().Any(f => f.GetMethod()?.DeclaringType == typeof(NativeWebDialog)))
if (new StackTrace(ex).GetFrames().Any(f => f.GetMethod()?.DeclaringType?.Assembly == typeof(NativeWebDialog).Assembly))
{
//Many of the NativeWebDialog exceptions cannot be handled by user code,
//so a webview failure is a fatal error. Disable webview usage and rely
@@ -106,7 +105,7 @@ namespace LibationAvalonia
try
{
//Try to log the error message before displaying the crash dialog
if (LoggingEnabled)
if (Configuration.Instance.SerilogInitialized)
Serilog.Log.Logger.Error(exception, "CRASH");
else
LogErrorWithoutSerilog(exception);
@@ -150,7 +149,7 @@ namespace LibationAvalonia
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
LibationFiles {getConfigValue(c => c.LibationFiles.Location)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
@@ -162,7 +161,7 @@ namespace LibationAvalonia
//Try to add crash message to the newest existing Libation log file
//then to LibationFiles/LibationCrash.log
//then to %UserProfile%/LibationCrash.log
string logDir = Configuration.Instance.LibationFiles;
string logDir = Configuration.Instance.LibationFiles.Location;
var existingLogFiles = Directory.GetFiles(logDir, "Log*.log");
logFile = existingLogFiles.Length == 0 ? getFallbackLogFile()
@@ -194,7 +193,7 @@ namespace LibationAvalonia
try
{
string logDir = Configuration.Instance.LibationFiles;
string logDir = Configuration.Instance.LibationFiles.Location;
if (!Directory.Exists(logDir))
logDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);

View File

@@ -10,7 +10,7 @@ namespace LibationAvalonia.Themes;
public class ChardonnayThemePersister : JsonFilePersister<ChardonnayTheme>
{
public static string jsonPath = System.IO.Path.Combine(Configuration.Instance.LibationFiles, "ChardonnayTheme.json");
public static string jsonPath = System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "ChardonnayTheme.json");
public ChardonnayThemePersister(string path)
: base(path, null) { }

View File

@@ -16,10 +16,11 @@ namespace LibationAvalonia.ViewModels
{
try
{
var startFolder = Configuration.Instance.Books?.PathWithoutPrefix;
var options = new FilePickerSaveOptions
{
Title = "Where to export Library",
SuggestedStartLocation = await MainWindow.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? Configuration.DefaultBooksDirectory),
SuggestedStartLocation = startFolder is null ? null : await MainWindow.StorageProvider.TryGetFolderFromPathAsync(startFolder),
SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,

View File

@@ -28,7 +28,7 @@ namespace LibationAvalonia.ViewModels.Settings
public AvaloniaList<EnumDisplay<SampleRate>> SampleRates { get; }
= new(Enum.GetValues<SampleRate>()
.Where(r => r >= SampleRate.Hz_8000 && r <= SampleRate.Hz_48000)
.Select(v => new EnumDisplay<SampleRate>(v, $"{(int)v} Hz")));
.Select(v => new EnumDisplay<SampleRate>(v, $"{((int)v):N0} Hz")));
public AvaloniaList<NAudio.Lame.EncoderQuality> EncoderQualities { get; }
= new(

View File

@@ -20,7 +20,7 @@ namespace LibationAvalonia.ViewModels.Settings
{
this.config = config;
BooksDirectory = config.Books?.PathWithoutPrefix ?? Configuration.DefaultBooksDirectory;
BooksDirectory = config.Books?.PathWithoutPrefix ?? "";
SavePodcastsToParentFolder = config.SavePodcastsToParentFolder;
OverwriteExisting = config.OverwriteExisting;
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
@@ -64,7 +64,7 @@ namespace LibationAvalonia.ViewModels.Settings
if (System.IO.File.Exists(LogFileFilter.LogFilePath))
Go.To.File(LogFileFilter.LogFilePath);
else
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
Go.To.Folder(Configuration.Instance.LibationFiles.Location.ShortPathName);
}
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()

View File

@@ -173,9 +173,8 @@
<Setter Property="MinHeight" Value="10" />
</Style>
<Style Selector="Button">
<Setter Property="Padding" Value="15,0,15,0" />
<Setter Property="Padding" Value="15,6" />
<Setter Property="Margin" Value="10,0,0,0" />
<Setter Property="Height" Value="30" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
@@ -195,7 +194,7 @@
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
<Button Name="filterBtn" Command="{CompiledBinding FilterBtn}" CommandParameter="{CompiledBinding #filterSearchTb.Text}" VerticalAlignment="Stretch" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
<Button Padding="2,6,2,6" VerticalAlignment="Stretch" Command="{CompiledBinding ToggleQueueHideBtn}">
<Button Padding="2,6" VerticalAlignment="Stretch" Command="{CompiledBinding ToggleQueueHideBtn}">
<Path Stretch="Uniform" Fill="{DynamicResource IconFill}" Data="{StaticResource LeftArrows}">
<Path.RenderTransform>
<RotateTransform Angle="{CompiledBinding QueueButtonAngle}"/>

View File

@@ -55,7 +55,7 @@
<Grid Name="ButtonsGrid" Margin="3" Grid.Column="2" HorizontalAlignment="Right" ColumnDefinitions="Auto,Auto">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="Padding" Value="0,1,0,1" />
<Setter Property="Padding" Value="0,1" />
<Setter Property="Height" Value="20" />
<Setter Property="Width" Value="30" />
<Style Selector="^ > Path">

View File

@@ -6,15 +6,15 @@ using System;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli
{
[Verb("copydb", HelpText = "Copy the local sqlite database to postgres.")]
public class CopyDbOptions : OptionsBase
{
[Option(shortName: 'c', longName: "connectionString")]
public string PostgresConnectionString { get; set; }
protected override async Task ProcessAsync()
{
[Option(shortName: 'c', longName: "connectionString", HelpText = "Postgres Database connection string")]
public string? PostgresConnectionString { get; set; }
protected override async Task ProcessAsync()
{
var srcConnectionString = SqliteStorage.ConnectionString;
var destConnectionString = PostgresConnectionString ?? Configuration.Instance.PostgresqlConnectionString;

View File

@@ -0,0 +1,66 @@
using ApplicationServices;
using CommandLine;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli.Options;
[Verb("get-license", HelpText = "Get the license information for a book.")]
internal class GetLicenseOptions : OptionsBase
{
[Value(0, MetaName = "[asin]", HelpText = "Product ID of book to request license for.", Required = true)]
public string? Asin { get; set; }
protected override async Task ProcessAsync()
{
if (string.IsNullOrWhiteSpace(Asin))
{
Console.Error.WriteLine("ASIN is required.");
return;
}
using var dbContext = DbContexts.GetContext();
if (dbContext.GetLibraryBook_Flat_NoTracking(Asin) is not LibraryBook libraryBook)
{
Console.Error.WriteLine($"Book not found with asin={Asin}");
return;
}
var api = await libraryBook.GetApiAsync();
var license = await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration.Instance, default);
var jsonSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
Converters = [new StringEnumConverter(), new ByteArrayHexConverter()]
};
var licenseJson = JsonConvert.SerializeObject(license, Formatting.Indented, jsonSettings);
Console.WriteLine(licenseJson);
}
}
class ByteArrayHexConverter : JsonConverter
{
public override bool CanConvert(Type objectType) => objectType == typeof(byte[]);
public override bool CanRead => false;
public override bool CanWrite => true;
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
=> throw new NotSupportedException();
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value is byte[] array)
{
writer.WriteValue(Convert.ToHexStringLower(array));
}
}
}

View File

@@ -0,0 +1,140 @@
using CommandLine;
using Dinah.Core;
using FileManager;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli.Options;
[Verb("get-setting", HelpText = "List current settings files and their locations.")]
internal class GetSettingOptions : OptionsBase
{
[Option('l', "listEnumValues", HelpText = "List all value possibilities of enum types")]
public bool ListEnumValues { get; set; }
[Option('b', "bare", HelpText = "Print bare list without table decoration")]
public bool Bare { get; set; }
[Value(0, MetaName = "[setting names]", HelpText = "Optional names of settings to get.")]
public IEnumerable<string>? SettingNames { get; set; }
protected override Task ProcessAsync()
{
var configs = GetConfigOptions();
if (SettingNames?.Any() is true)
{
//Operate over listed settings
foreach (var item in SettingNames.ExceptBy(configs.Select(c => c.Name), c => c, StringComparer.OrdinalIgnoreCase))
{
Console.Error.WriteLine($"Unknown Setting Name: {item}");
}
var validSettings = configs.IntersectBy(SettingNames, a => a.Name, StringComparer.OrdinalIgnoreCase);
if (ListEnumValues)
{
foreach (var item in validSettings.Where(s => !s.SettingType.IsEnum))
{
Console.Error.WriteLine($"Setting '{item.Name}' is not an enum type");
}
PrintEnumValues(validSettings.Where(s => s.SettingType.IsEnum));
}
else
{
PrintConfigOption(validSettings);
}
}
else
{
//Operate over all settings
if (ListEnumValues)
{
PrintEnumValues(configs);
}
else
{
PrintConfigOption(configs);
}
}
return Task.CompletedTask;
}
private void PrintConfigOption(IEnumerable<ConfigOption> options)
{
if (Bare)
{
foreach (var option in options)
{
Console.WriteLine($"{option.Name}={option.Value}");
}
}
else
{
Console.Out.DrawTable(options, new(), o => o.Name, o => o.Value, o => o.Type);
}
}
private void PrintEnumValues(IEnumerable<ConfigOption> options)
{
foreach (var item in options.Where(s => s.SettingType.IsEnum))
{
var enumValues = Enum.GetNames(item.SettingType);
if (Bare)
{
Console.WriteLine(string.Join(Environment.NewLine, enumValues.Select(e => $"{item.Name}.{e}")));
}
else
{
Console.Out.DrawTable(enumValues, new TextTableOptions(), new ColumnDef<string>(item.Name, t => t));
}
}
}
private ConfigOption[] GetConfigOptions()
{
var configs = GetConfigurationProperties().Where(o=> o.PropertyType != typeof(ReplacementCharacters)).Select(p => new ConfigOption(p));
var replacements = GetConfigurationProperties().SingleOrDefault(o => o.PropertyType == typeof(ReplacementCharacters))?.GetValue(Configuration.Instance) as ReplacementCharacters;
if (replacements is not null)
{
//Don't reorder after concat to keep replacements grouped together at the bottom
configs = configs.Concat(replacements.Replacements.Select(r => new ConfigOption(r)));
}
return configs.ToArray();
}
private record EnumOption(string EnumOptionValue);
private record ConfigOption
{
public string Name { get; }
public string Type { get; }
public Type SettingType { get; }
public string Value { get; }
public ConfigOption(PropertyInfo propertyInfo)
{
Name = propertyInfo.Name;
SettingType = propertyInfo.PropertyType;
Type = GetTypeString(SettingType);
Value = propertyInfo.GetValue(Configuration.Instance)?.ToString() is not string value ? "[null]"
: SettingType == typeof(string) || SettingType == typeof(LongPath) ? value.SurroundWithQuotes()
: value;
}
public ConfigOption(Replacement replacement)
{
Name = GetReplacementName(replacement);
SettingType = typeof(string);
Type = GetTypeString(SettingType);
Value = replacement.ReplacementString.SurroundWithQuotes();
}
private static string GetTypeString(Type type)
=> type.IsEnum ? $"{type.Name} (enum)": type.Name;
}
}

View File

@@ -1,44 +1,108 @@
using CommandLine;
using ApplicationServices;
using CommandLine;
using DataLayer;
using FileLiberator;
using LibationCli.Options;
using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli
{
[Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs. "
+ "Optional: use 'pdf' flag to only download pdfs.")]
[Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs.\n"
+ "Optional: specify asin(s) of book(s) to liberate.\n"
+ "Optional: reads a license file from standard input.")]
public class LiberateOptions : ProcessableOptionsBase
{
[Option(shortName: 'p', longName: "pdf", Required = false, Default = false, HelpText = "Flag to only download pdfs")]
public bool PdfOnly { get; set; }
protected override Task ProcessAsync()
[Option(shortName: 'f', longName: "force", Required = false, Default = false, HelpText = "Force the book to re-download")]
public bool Force { get; set; }
protected override async Task ProcessAsync()
{
if (AudibleFileStorage.BooksDirectory is null)
{
Console.Error.WriteLine("Error: Books directory is not set. Please configure the 'Books' setting in Settings.json.");
return Task.CompletedTask;
return;
}
return PdfOnly
? RunAsync(CreateProcessable<DownloadPdf>())
: RunAsync(CreateBackupBook());
if (Console.IsInputRedirected)
{
Console.WriteLine("Reading license file from standard input.");
using var reader = new StreamReader(Console.OpenStandardInput());
var stdIn = await reader.ReadToEndAsync();
try
{
var jsonSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
Converters = [new StringEnumConverter(), new ByteArrayHexConverter()]
};
var licenseInfo = JsonConvert.DeserializeObject<DownloadOptions.LicenseInfo>(stdIn, jsonSettings);
if (licenseInfo?.ContentMetadata?.ContentReference?.Asin is not string asin)
{
Console.Error.WriteLine("Error: License file is missing ASIN information.");
return;
}
LibraryBook libraryBook;
using (var dbContext = DbContexts.GetContext())
{
if (dbContext.GetLibraryBook_Flat_NoTracking(asin) is not LibraryBook lb)
{
Console.Error.WriteLine($"Book not found with asin={asin}");
return;
}
libraryBook = lb;
}
SetDownloadedStatus(libraryBook);
await ProcessOneAsync(GetProcessable(licenseInfo), libraryBook, true);
}
catch
{
Console.Error.WriteLine("Error: Failed to read license file from standard input. Please ensure the input is a valid license file in JSON format.");
}
}
else
{
await RunAsync(GetProcessable(), SetDownloadedStatus);
}
}
private static Processable CreateBackupBook()
private Processable GetProcessable(DownloadOptions.LicenseInfo? licenseInfo = null)
=> PdfOnly ? CreateProcessable<DownloadPdf>() : CreateBackupBook(licenseInfo);
private void SetDownloadedStatus(LibraryBook lb)
{
if (Force)
{
lb.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated;
lb.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated);
}
}
private static Processable CreateBackupBook(DownloadOptions.LicenseInfo? licenseInfo)
{
var downloadPdf = CreateProcessable<DownloadPdf>();
//Chain pdf download on DownloadDecryptBook.Completed
void onDownloadDecryptBookCompleted(object sender, LibraryBook e)
void onDownloadDecryptBookCompleted(object? sender, LibraryBook e)
{
// this is fast anyway. run as sync for easy exception catching
downloadPdf.TryProcessAsync(e).GetAwaiter().GetResult();
}
var downloadDecryptBook = CreateProcessable<DownloadDecryptBook>(onDownloadDecryptBookCompleted);
downloadDecryptBook.LicenseInfo = licenseInfo;
return downloadDecryptBook;
}
}

View File

@@ -21,7 +21,7 @@ namespace LibationCli
[Option(shortName: 'n', longName: "not-downloaded", Group = "Download Status", HelpText = "set download status to 'Not Downloaded'")]
public bool SetNotDownloaded { get; set; }
[Option("force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")]
[Option('f', "force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")]
public bool Force { get; set; }
[Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books on which to set download status.")]

View File

@@ -1,15 +1,44 @@
using CommandLine;
using CsvHelper.TypeConversion;
using Dinah.Core;
using FileManager;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli
{
public abstract class OptionsBase
{
[Option(longName: "libationFiles", HelpText = "Path to Libation Files directory")]
public DirectoryInfo? LibationFiles { get; set; }
[Option('o', "override", HelpText = "Configuration setting override [SettingName]=\"Setting_Value\"")]
public IEnumerable<OptionOverride>? SettingOverrides { get; set; }
public async Task Run()
{
if (LibationFiles?.Exists is true)
{
Environment.SetEnvironmentVariable(LibationFileManager.LibationFiles.LIBATION_FILES_DIR, LibationFiles.FullName);
}
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
Setup.Initialize();
if (SettingOverrides is not null)
ProcessSettingsOverrides();
try
{
await ProcessAsync();
@@ -17,20 +46,39 @@ namespace LibationCli
catch (Exception ex)
{
Environment.ExitCode = (int)ExitCode.RunTimeError;
PrintVerbUsage(new string[]
{
PrintVerbUsage(
"ERROR",
"=====",
ex.Message,
"",
ex.StackTrace
});
ex.StackTrace);
}
}
protected void PrintVerbUsage(params string[] linesBeforeUsage)
private static bool TryParseEnum(Type enumType, string? value, out object? result)
{
var verb = GetType().GetCustomAttribute<VerbAttribute>().Name;
var values = Enum.GetNames(enumType);
if (values.Select(n => n.ToLowerInvariant()).Distinct().Count() != values.Length)
{
//Enum names must be case sensitive.
return Enum.TryParse(enumType, value, out result);
}
for (int i = 0; i < values.Length; i++)
{
if (values[i].Equals(value, StringComparison.OrdinalIgnoreCase))
{
return Enum.TryParse(enumType, values[i], out result);
}
}
result = null;
return false;
}
protected void PrintVerbUsage(params string?[] linesBeforeUsage)
{
var verb = GetType().GetCustomAttribute<VerbAttribute>()?.Name;
var helpText = new HelpVerb { HelpType = verb }.GetHelpText();
helpText.AddPreOptionsLines(linesBeforeUsage);
helpText.AddPreOptionsLine("");
@@ -46,5 +94,150 @@ namespace LibationCli
}
protected abstract Task ProcessAsync();
protected IOrderedEnumerable<PropertyInfo> GetConfigurationProperties()
=> typeof(Configuration).GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(p => p.CustomAttributes.Any(a => a.AttributeType == typeof(DescriptionAttribute)))
.Where(p => !p.Name.In(ExcludedSettings))
.OrderBy(p => p.PropertyType.IsEnum)
.ThenBy(p => p.PropertyType.Name)
.ThenBy(p => p.Name);
private readonly string[] ExcludedSettings = [
nameof(Configuration.LibationFiles),
nameof(Configuration.GridScaleFactor),
nameof(Configuration.GridFontScaleFactor),
nameof(Configuration.GridColumnsVisibilities),
nameof(Configuration.GridColumnsDisplayIndices),
nameof(Configuration.GridColumnsWidths)];
private void ProcessSettingsOverrides()
{
var configProperties = GetConfigurationProperties().ToArray();
foreach (var option in SettingOverrides?.Where(p => p.Property is not null && p.Value is not null) ?? [])
{
if (option.Property?.StartsWithInsensitive(ReplacePrefix) is true)
{
OverrideReplacement(option);
}
else if (configProperties.FirstOrDefault(p => p.Name.EqualsInsensitive(option.Property)) is not PropertyInfo property)
{
Console.Error.WriteLine($"Unknown configuration property '{option.Property}'");
}
else if (property.PropertyType == typeof(string))
{
property.SetValue(Configuration.Instance, option.Value?.Trim());
}
else if (property.PropertyType == typeof(bool) && bool.TryParse(option.Value?.Trim(), out var bVal))
{
property.SetValue(Configuration.Instance, bVal);
}
else if (property.PropertyType == typeof(int) && int.TryParse(option.Value?.Trim(), out var intVal))
{
property.SetValue(Configuration.Instance, intVal);
}
else if (property.PropertyType == typeof(long) && long.TryParse(option.Value?.Trim(), out var longVal))
{
property.SetValue(Configuration.Instance, longVal);
}
else if (property.PropertyType == typeof(LongPath))
{
var value = option.Value is null ? null : (LongPath)option.Value.Trim();
property.SetValue(Configuration.Instance, value);
}
else if (property.PropertyType.IsEnum && TryParseEnum(property.PropertyType, option.Value?.Trim(), out var enumVal))
{
property.SetValue(Configuration.Instance, enumVal);
}
else
{
Console.Error.WriteLine($"Cannot set configuration property '{property.Name}' of type '{property.PropertyType}' with value '{option.Value}'");
}
}
}
private static void OverrideReplacement(OptionOverride option)
{
List<Replacement> newReplacements = [];
bool addedToList = false;
foreach (var r in Configuration.Instance.ReplacementCharacters.Replacements)
{
if (GetReplacementName(r).EqualsInsensitive(option.Property))
{
var newReplacement = new Replacement(r.CharacterToReplace, option.Value ?? string.Empty, r.Description)
{
Mandatory = r.Mandatory
};
newReplacements.Add(newReplacement);
addedToList = true;
}
else
{
newReplacements.Add(r);
}
}
if (!addedToList)
{
var charToReplace = option.Property!.Substring(ReplacePrefix.Length);
if (charToReplace.Length != 1)
{
Console.Error.WriteLine($"Invalid character to replace: '{charToReplace}'");
}
else
{
newReplacements.Add(new(charToReplace[0], option.Value ?? string.Empty, ""));
}
}
Configuration.Instance.ReplacementCharacters = new ReplacementCharacters { Replacements = newReplacements };
}
const string ReplacePrefix = "Replace_";
protected static string GetReplacementName(Replacement r)
=> !r.Mandatory ? ReplacePrefix + r.CharacterToReplace
: r.CharacterToReplace == '\0' ? ReplacePrefix + "OtherInvalid"
: r.CharacterToReplace == '/' ? ReplacePrefix + "Slash"
: r.CharacterToReplace == '\\' ? ReplacePrefix + "BackSlash"
: r.Description == "Open Quote" ? ReplacePrefix + "OpenQuote"
: r.Description == "Close Quote" ? ReplacePrefix + "CloseQuote"
: r.Description == "Other Quote" ? ReplacePrefix + "OtherQuote"
: ReplacePrefix + r.Description.Replace(" ", "");
public class OptionOverride
{
public string? Property { get; }
public string? Value { get; }
public OptionOverride(string value)
{
if (value is null)
return;
//Special case of Replace_= settings
var start
= value.StartsWithInsensitive(ReplacePrefix + "=")
? value.IndexOf('=', ReplacePrefix.Length + 1)
: value.IndexOf('=');
if (start < 1)
return;
Property = value[..start];
//Don't trim here. Trim before parsing the value if needed, otherwise
//preserve for settings which utilize white space (e.g. Replacements)
Value = value[(start + 1)..];
if (Value.StartsWith('"') && Value.EndsWith('"'))
{
Value = Value[1..];
}
if (Value.EndsWith('"'))
{
Value = Value[..^1];
}
}
}
}
}

View File

@@ -1,28 +1,34 @@
using ApplicationServices;
using CommandLine;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli
{
public abstract class ProcessableOptionsBase : OptionsBase
{
[Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books to process.")]
public IEnumerable<string> Asins { get; set; }
public IEnumerable<string>? Asins { get; set; }
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook>? completedAction = null)
where TProcessable : Processable, new()
{
var progressBar = new ConsoleProgressBar(Console.Out);
var strProc = new TProcessable();
LibraryBook? currentLibraryBook = null;
strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
strProc.Begin += (o, e) =>
{
currentLibraryBook = e;
Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
};
strProc.Completed += (o, e) =>
{
@@ -46,24 +52,57 @@ namespace LibationCli
strProc.StreamingTimeRemaining += (_, e) => progressBar.RemainingTime = e;
strProc.StreamingProgressChanged += (_, e) => progressBar.Progress = e.ProgressPercentage;
if (strProc is AudioDecodable audDec)
{
audDec.RequestCoverArt += (_,_) =>
{
if (currentLibraryBook is null)
return null;
var quality
= Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && currentLibraryBook.Book.PictureLarge is not null
? new PictureDefinition(currentLibraryBook.Book.PictureLarge, PictureSize.Native)
: new PictureDefinition(currentLibraryBook.Book.PictureId, PictureSize._500x500);
return PictureStorage.GetPictureSynchronously(quality);
};
}
return strProc;
}
protected async Task RunAsync(Processable Processable)
protected async Task RunAsync(Processable Processable, Action<LibraryBook>? config = null)
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
if (Asins.Any())
if (Asins?.Any() is true)
{
var asinsLower = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray();
foreach (var lb in libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asinsLower)))
await ProcessOneAsync(Processable, lb, true);
foreach (var asin in Asins.Select(a => a.TrimStart('[').TrimEnd(']')))
{
LibraryBook? lb = null;
using (var dbContext = DbContexts.GetContext())
{
lb = dbContext.GetLibraryBook_Flat_NoTracking(asin, caseSensative: false);
}
if (lb is not null)
{
config?.Invoke(lb);
await ProcessOneAsync(Processable, lb, true);
}
else
{
var msg = $"Book with ASIN '{asin}' not found in library. Skipping.";
Console.Error.WriteLine(msg);
Serilog.Log.Logger.Error(msg);
}
}
}
else
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
foreach (var lb in Processable.GetValidLibraryBooks(libraryBooks))
{
config?.Invoke(lb);
await ProcessOneAsync(Processable, lb, false);
}
}
var done = "Done. All books have been processed";
@@ -71,7 +110,7 @@ namespace LibationCli
Serilog.Log.Logger.Information(done);
}
private static async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate)
protected async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate)
{
try
{

View File

@@ -19,7 +19,7 @@ namespace LibationCli
public readonly static Type[] VerbTypes = Setup.LoadVerbs();
static async Task Main(string[] args)
{
Console.OutputEncoding = Console.InputEncoding = System.Text.Encoding.UTF8;
#if DEBUG
string input = "";
@@ -32,6 +32,9 @@ namespace LibationCli
//input = " scan rmcrackan";
//input = " help set-status";
//input = " liberate ";
//input = "get-setting -o Replace_OpenQuote=[ ";
//input = "get-setting ";
//input = "liberate B017V4NOZ0 --force -o Books=\"./Books\"";
// note: this hack will fail for quoted file paths with spaces because it will break on those spaces
if (!string.IsNullOrWhiteSpace(input))
@@ -56,15 +59,6 @@ namespace LibationCli
else
{
//Everything parsed correctly, so execute the command
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
Setup.Initialize();
// if successfully parsed
// async: run parsed options
await result.WithParsedAsync<OptionsBase>(opt => opt.Run());
}
@@ -108,6 +102,7 @@ namespace LibationCli
private static void ConfigureParser(ParserSettings settings)
{
settings.AllowMultiInstance = true;
settings.AutoVersion = false;
settings.AutoHelp = false;
}

View File

@@ -1,6 +1,8 @@
using AppScaffolding;
using CommandLine;
using LibationFileManager;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -17,7 +19,19 @@ namespace LibationCli
//***********************************************//
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
if (!Directory.Exists(config.LibationFiles.Location))
{
Console.Error.WriteLine($"Cannot find LibationFiles at {config.LibationFiles.Location}");
PrintLibationFilestipAndExit();
}
if (!File.Exists(config.LibationFiles.SettingsFilePath))
{
Console.Error.WriteLine($"Cannot find settings files at {config.LibationFiles.SettingsFilePath}");
PrintLibationFilestipAndExit();
}
LibationScaffolding.RunPostConfigMigrations(config, ephemeralSettings: true);
#if classic
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
@@ -26,6 +40,12 @@ namespace LibationCli
#endif
}
static void PrintLibationFilestipAndExit()
{
Console.Error.WriteLine($"Override LibationFiles directory location with '--libationFiles' option or '{LibationFiles.LIBATION_FILES_DIR}' environment variable.");
Environment.Exit(-1);
}
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)

View File

@@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
#nullable enable
namespace LibationCli;
public enum Justify
{
Left,
Right,
Center
}
public class TextTableOptions
{
public Justify Justify { get; set; }
public Justify CenterTiebreak { get; set; }
public char PaddingCharacter { get; set; } = ' ';
public int SideBorderPadding { get; set; } = 1;
public int IntercellPadding { get; set; } = 1;
public bool DrawBorder { get; set; } = true;
public bool DrawHeader { get; set; } = true;
public BorderDefinition Border { get; set; } = BorderDefinition.LightRounded;
}
public record BorderDefinition
{
public char Vertical { get; set; }
public char Horizontal { get; set; }
public char VerticalSeparator { get; set; }
public char HorizontalSeparator { get; set; }
public char CornerTopLeft { get; set; }
public char CornerTopRight { get; set; }
public char CornerBottomLeft { get; set; }
public char CornerBottomRight { get; set; }
public char Tee { get; set; }
public char TeeTop { get; set; }
public char TeeBottom { get; set; }
public char TeeLeft { get; set; }
public char TeeRight { get; set; }
public BorderDefinition(
char vertical,
char horizontal,
char verticalSeparator,
char horizontalSeparator,
char cornerTopLef,
char cornerTopRight,
char cornerBottomLeft,
char cornerBottomRight,
char tee,
char teeTop,
char teeBottom,
char teeLeft,
char teeRight)
{
Vertical = vertical;
Horizontal = horizontal;
VerticalSeparator = verticalSeparator;
HorizontalSeparator = horizontalSeparator;
CornerTopLeft = cornerTopLef;
CornerTopRight = cornerTopRight;
CornerBottomLeft = cornerBottomLeft;
CornerBottomRight = cornerBottomRight;
Tee = tee;
TeeTop = teeTop;
TeeBottom = teeBottom;
TeeLeft = teeLeft;
TeeRight = teeRight;
}
public void TestPrint(TextWriter writer)
=> writer.DrawTable<TestObject>([], new TextTableOptions { Border = this }, t => t.ColA, t => t.ColB, t => t.ColC);
public static BorderDefinition Ascii => new BorderDefinition('|', '-', '|', '-', '-', '-', '-', '-', '|', '-', '-', '|', '|');
public static BorderDefinition Light => new BorderDefinition('│', '─', '│', '─', '┌', '┐', '└', '┘', '┼', '┬', '┴', '├', '┤');
public static BorderDefinition Heavy => new BorderDefinition('┃', '━', '┃', '━', '┏', '┓', '┗', '┛', '╋', '┳', '┻', '┣', '┫');
public static BorderDefinition Double => new BorderDefinition('║', '═', '║', '═', '╔', '╗', '╚', '╝', '╬', '╦', '╩', '╠', '╣');
public static BorderDefinition LightRounded => Light with { CornerTopLeft = '╭', CornerTopRight = '╮', CornerBottomLeft = '╰', CornerBottomRight = '╯' };
public static BorderDefinition DoubleHorizontal => Light with { HorizontalSeparator = '═', Tee = '╪', TeeLeft = '╞', TeeRight = '╡' };
public static BorderDefinition DoubleVertical => Light with { VerticalSeparator = '║', Tee = '╫', TeeTop = '╥', TeeBottom = '╨' };
public static BorderDefinition DoubleOuter => Double with { VerticalSeparator = '│', HorizontalSeparator = '─', TeeLeft = '╟', TeeRight = '╢', Tee = '┼', TeeTop = '╤', TeeBottom = '╧' };
public static BorderDefinition DoubleInner => Light with { VerticalSeparator = '║', HorizontalSeparator = '═', TeeLeft = '╞', TeeRight = '╡', Tee = '╬', TeeTop = '╥', TeeBottom = '╨' };
private record TestObject(string ColA, string ColB, string ColC);
}
public record ColumnDef<T>(string ColumnName, Func<T, string?> ValueGetter);
public static class TextTableExtention
{
/// <summary>
/// Draw a text-based table to the provided TextWriter.
/// </summary>
/// <typeparam name="T">Data row type</typeparam>
/// <param name="textWriter"></param>
/// <param name="rows">Data rows to be drawn</param>
/// <param name="options">Table drawing options</param>
/// <param name="columnSelectors">Data cell selector. Header name is based on member name</param>
public static void DrawTable<T>(this TextWriter textWriter, IEnumerable<T> rows, TextTableOptions options, params Expression<Func<T, string>>[] columnSelectors)
{
//Convert MemberExpression to ColumnDef<T>
var columnDefs = new ColumnDef<T>[columnSelectors.Length];
for (int i = 0; i < columnDefs.Length; i++)
{
var exp = columnSelectors[i].Body as MemberExpression
?? throw new ArgumentException($"Expression at index {i} is not a member access expression", nameof(columnSelectors));
columnDefs[i] = new ColumnDef<T>(exp.Member.Name, columnSelectors[i].Compile());
}
textWriter.DrawTable(rows, options, columnDefs);
}
/// <summary>
/// Draw a text-based table to the provided TextWriter.
/// </summary>
/// <typeparam name="T">Data row type</typeparam>
/// <param name="textWriter"></param>
/// <param name="rows">Data rows to be drawn</param>
/// <param name="options">Table drawing options</param>
/// <param name="columnSelectors">Column header name and cell value selector.</param>
public static void DrawTable<T>(this TextWriter textWriter, IEnumerable<T> rows, TextTableOptions options, params ColumnDef<T>[] columnSelectors)
{
var rowsArray = rows.ToArray();
var colNames = columnSelectors.Select(c => c.ColumnName).ToArray();
var colWidths = new int[columnSelectors.Length];
for (int i = 0; i < columnSelectors.Length; i++)
{
var nameWidth = options.DrawHeader ? StrLen(colNames[i]) : 0;
var maxValueWidth = rowsArray.Length == 0 ? 0 : rows.Max(o => StrLen(columnSelectors[i].ValueGetter(o)));
colWidths[i] = Math.Max(nameWidth, maxValueWidth);
}
textWriter.DrawTop(colWidths, options);
textWriter.DrawHeader(colNames, colWidths, options);
foreach (var row in rowsArray)
{
textWriter.DrawLeft(options, options.Border.Vertical, options.PaddingCharacter);
var cellValues = columnSelectors.Select((def, j) => def.ValueGetter(row).PadText(colWidths[j], options));
textWriter.DrawRow(options, options.Border.VerticalSeparator, options.PaddingCharacter, cellValues);
textWriter.DrawRight(options, options.Border.Vertical, options.PaddingCharacter);
}
textWriter.DrawBottom(colWidths, options);
}
private static void DrawHeader(this TextWriter textWriter, string[] colNames, int[] colWidths, TextTableOptions options)
{
if (!options.DrawHeader)
return;
//Draw column header names
textWriter.DrawLeft(options, options.Border.Vertical, options.PaddingCharacter);
var cellValues = colNames.Select((n, i) => n.PadText(colWidths[i], options));
textWriter.DrawRow(options, options.Border.VerticalSeparator, options.PaddingCharacter, cellValues);
textWriter.DrawRight(options, options.Border.Vertical, options.PaddingCharacter);
//Draw header separator
textWriter.DrawLeft(options, options.Border.TeeLeft, options.Border.HorizontalSeparator);
cellValues = colWidths.Select(w => new string(options.Border.HorizontalSeparator, w));
textWriter.DrawRow(options, options.Border.Tee, options.Border.HorizontalSeparator, cellValues);
textWriter.DrawRight(options, options.Border.TeeRight, options.Border.HorizontalSeparator);
}
private static void DrawTop(this TextWriter textWriter, int[] colWidths, TextTableOptions options)
{
if (!options.DrawBorder)
return;
textWriter.DrawLeft(options, options.Border.CornerTopLeft, options.Border.Horizontal);
var cellValues = colWidths.Select(w => new string(options.Border.Horizontal, w));
textWriter.DrawRow(options, options.Border.TeeTop, options.Border.Horizontal, cellValues);
textWriter.DrawRight(options, options.Border.CornerTopRight, options.Border.Horizontal);
}
private static void DrawBottom(this TextWriter textWriter, int[] colWidths, TextTableOptions options)
{
if (!options.DrawBorder)
return;
textWriter.DrawLeft(options, options.Border.CornerBottomLeft, options.Border.Horizontal);
var cellValues = colWidths.Select(w => new string(options.Border.Horizontal, w));
textWriter.DrawRow(options, options.Border.TeeBottom, options.Border.Horizontal, cellValues);
textWriter.DrawRight(options, options.Border.CornerBottomRight, options.Border.Horizontal);
}
private static void DrawLeft(this TextWriter textWriter, TextTableOptions options, char borderChar, char cellPadChar)
{
if (!options.DrawBorder)
return;
textWriter.Write(borderChar);
textWriter.Write(new string(cellPadChar, options.SideBorderPadding));
}
private static void DrawRight(this TextWriter textWriter, TextTableOptions options, char borderChar, char cellPadChar)
{
if (options.DrawBorder)
{
textWriter.Write(new string(cellPadChar, options.SideBorderPadding));
textWriter.WriteLine(borderChar);
}
else
{
textWriter.WriteLine();
}
}
private static void DrawRow(this TextWriter textWriter, TextTableOptions options, char colSeparator, char cellPadChar, IEnumerable<string> cellValues)
{
var cellPadding = new string(cellPadChar, options.IntercellPadding);
var separator = cellPadding + colSeparator + cellPadding;
textWriter.Write(string.Join(separator, cellValues));
}
private static string PadText(this string? text, int totalWidth, TextTableOptions options)
{
if (string.IsNullOrEmpty(text))
return new string(options.PaddingCharacter, totalWidth);
else if (StrLen(text) >= totalWidth)
return text;
return options.Justify switch
{
Justify.Right => PadLeft(text),
Justify.Center => PadCenter(text),
_ or Justify.Left => PadRight(text),
};
string PadCenter(string text)
{
var half = (totalWidth - StrLen(text)) / 2;
text = options.CenterTiebreak == Justify.Right
? new string(options.PaddingCharacter, half) + text
: text + new string(options.PaddingCharacter, half);
return options.CenterTiebreak == Justify.Right
? text.PadRight(totalWidth, options.PaddingCharacter)
: text.PadLeft(totalWidth, options.PaddingCharacter);
}
string PadLeft(string text)
{
var padSize = totalWidth - StrLen(text);
return new string(options.PaddingCharacter, padSize) + text;
}
string PadRight(string text)
{
var padSize = totalWidth - StrLen(text);
return text + new string(options.PaddingCharacter, padSize);
}
}
/// <summary>
/// Determine the width of the string in console characters, accounting for wide unicode characters.
/// </summary>
private static int StrLen(string? str)
=> string.IsNullOrEmpty(str) ? 0 : str.Sum(c => CharIsWide(c) ? 2 : 1);
/// <summary>
/// Determines if the character is a unicode "Full Width" character which takes up two spaces in the console.
/// </summary>
static bool CharIsWide(char c)
=> (c >= '\uFF01' && c <= '\uFF61') || (c >= '\uFFE0' && c <= '\uFFE6');
}

View File

@@ -8,20 +8,18 @@ using Dinah.Core;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
public partial class Configuration
{
public static string ProcessDirectory { get; } = Path.GetDirectoryName(Environment.ProcessPath)!;
public static string AppDir_Relative => $@".{Path.DirectorySeparatorChar}{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LIBATION_FILES_KEY));
public static string AppDir_Relative => $@".{Path.DirectorySeparatorChar}{LibationFiles.LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LibationFiles.LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
public static string MyMusic => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), "Libation"));
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
public static string LocalAppData => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"));
public static string DefaultLibationFilesDirectory => !IsWindows ? LocalAppData : UserProfile;
public static string DefaultBooksDirectory => Path.Combine(!IsWindows ? MyMusic : UserProfile, nameof(Books));
public enum KnownDirectories
public enum KnownDirectories
{
None = 0,
@@ -58,7 +56,7 @@ namespace LibationFileManager
(KnownDirectories.MyDocs, () => MyDocs),
// this is important to not let very early calls try to accidentally load LibationFiles too early.
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => LibationSettingsDirectory)
(KnownDirectories.LibationFiles, () => Instance.LibationFiles.Location)
};
public static string? GetKnownDirectoryPath(KnownDirectories directory)
{

View File

@@ -1,216 +0,0 @@
using System;
using System.ComponentModel;
using System.IO;
using FileManager;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using Serilog;
using Dinah.Core.Logging;
using System.Diagnostics;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
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 (LibationSettingsDirectory is not null)
return LibationSettingsDirectory;
// FIRST: must write here before SettingsFilePath in next step reads cache
LibationSettingsDirectory = getLibationFilesSettingFromJson();
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
var logPath = Path.Combine(LibationFiles, "Log.log");
// BAD: Serilog.WriteTo[1].Args
// "[1]" assumes ordinal position
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
SetWithJsonPath(jsonpath, "path", logPath, true);
return LibationSettingsDirectory;
}
}
/// <summary>
/// Directory pointed to by appsettings.json
/// </summary>
private static string? LibationSettingsDirectory { get; set; }
/// <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[] possibleAppsettingsDirectories = new[]
{
ProcessDirectory,
LocalAppData,
UserProfile,
WinTemp,
};
//Try to find and validate appsettings.json in each folder
foreach (var dir in possibleAppsettingsDirectories)
{
var appsettingsFile = Path.Combine(dir, appsettings_filename);
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, DefaultLibationFilesDirectory } }.ToString(Formatting.Indented);
foreach (var dir in possibleAppsettingsDirectories)
{
//Don't try to create appsettings.json in the program files directory on *.nix systems.
//However, still _look_ for one there for backwards compatibility with previous installations
if (!IsWindows && dir == ProcessDirectory)
continue;
var appsettingsFile = Path.Combine(dir, appsettings_filename);
try
{
Directory.CreateDirectory(dir);
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()
{
// 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(AppsettingsJsonFile));
if (jObjFinal[LIBATION_FILES_KEY]?.Value<string>() is not string valueFinal)
throw new InvalidDataException($"{LIBATION_FILES_KEY} not found in {AppsettingsJsonFile}");
if (IsWindows)
{
valueFinal = Environment.ExpandEnvironmentVariables(valueFinal);
}
else
{
//If the shell command fails and returns null, proceed with the verbatim
//LIBATION_FILES_KEY path and hope for the best. If Libation can't find
//anything at this path it will set LIBATION_FILES_KEY to UserProfile
valueFinal = runShellCommand("echo " + valueFinal) ?? valueFinal;
}
return valueFinal;
static string? runShellCommand(string command)
{
var psi = new ProcessStartInfo
{
FileName = "/bin/sh",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
ArgumentList =
{
"-c",
command
}
};
try
{
var proc = Process.Start(psi);
proc?.WaitForExit();
return proc?.StandardOutput?.ReadToEnd()?.Trim();
}
catch (Exception e)
{
Serilog.Log.Error(e, "Failed to run shell command. {@Arguments}", psi.ArgumentList);
return null;
}
}
}
public static void SetLibationFiles(string directory)
{
LibationSettingsDirectory = null;
var startingContents = File.ReadAllText(AppsettingsJsonFile);
var jObj = JObject.Parse(startingContents);
jObj[LIBATION_FILES_KEY] = directory;
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
try
{
// 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

@@ -15,6 +15,8 @@ namespace LibationFileManager
{
private IConfigurationRoot? configuration;
public bool SerilogInitialized { get; private set; }
public void ConfigureLogging()
{
//pass explicit assemblies to the ConfigurationReaderOptions
@@ -33,14 +35,15 @@ namespace LibationFileManager
typeof(FileLoggerConfigurationExtensions).Assembly); // Serilog.Sinks.File
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.AddJsonFile(Instance.LibationFiles.SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration, readerOptions)
.Destructure.ByTransforming<LongPath>(lp => lp.Path)
.Destructure.With<LogFileFilter>()
.CreateLogger();
}
SerilogInitialized = true;
}
[Description("The importance of a log event")]
public LogEventLevel LogLevel

View File

@@ -2,12 +2,12 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using FileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
#nullable enable
namespace LibationFileManager
@@ -19,18 +19,19 @@ namespace LibationFileManager
// default setting and directory creation occur in class responsible for files.
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
private IJsonBackedDictionary? JsonBackedDictionary { get; set; }
private IJsonBackedDictionary Settings => JsonBackedDictionary
?? throw new InvalidOperationException($"{nameof(LoadPersistentSettings)} must first be called prior to accessing {nameof(Settings)}");
private IPersistentDictionary? persistentDictionary;
internal void LoadPersistentSettings(string settingsFile)
=> JsonBackedDictionary = new PersistentDictionary(settingsFile);
private IPersistentDictionary Settings
{
get
{
if (persistentDictionary is null)
throw new InvalidOperationException($"{nameof(persistentDictionary)} must first be set by accessing {nameof(LibationFiles)} or calling {nameof(SettingsFileIsValid)}");
return persistentDictionary;
}
}
internal void LoadEphemeralSettings(JObject dataStore)
=> JsonBackedDictionary = new EphemeralDictionary(dataStore);
private LibationFiles? _libationFiles;
[Description("Location for storage of program-created files")]
public LibationFiles LibationFiles => _libationFiles ??= new LibationFiles();
public bool RemoveProperty(string propertyName) => Settings.RemoveProperty(propertyName);
@@ -80,8 +81,6 @@ namespace LibationFileManager
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public static string GetDescription(string propertyName)
{
var attribute = typeof(Configuration)

View File

@@ -11,60 +11,17 @@ namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
{
/// <summary>
/// Returns true if <see cref="SettingsFilePath"/> exists and the <see cref="Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Books"/> directory.
/// </summary>
public bool LibationSettingsAreValid => SettingsFileIsValid(SettingsFilePath);
/// <summary>
/// Returns true if <paramref name="settingsFile"/> exists and the <see cref="Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Books"/> directory.
/// </summary>
/// <param name="settingsFile">File path to the settings JSON file</param>
public static bool SettingsFileIsValid(string settingsFile)
{
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
try
{
var settingsJson = JObject.Parse(File.ReadAllText(settingsFile));
return !string.IsNullOrWhiteSpace(settingsJson[nameof(Books)]?.Value<string>());
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to load settings file: {@SettingsFile}", settingsFile);
try
{
Serilog.Log.Logger.Information("Deleting invalid settings file: {@SettingsFile}", settingsFile);
FileUtility.SaferDelete(settingsFile);
Serilog.Log.Logger.Information("Creating a new, empty setting file: {@SettingsFile}", settingsFile);
try
{
File.WriteAllText(settingsFile, "{}");
}
catch (Exception createEx)
{
Serilog.Log.Logger.Error(createEx, "Failed to create new settings file: {@SettingsFile}", settingsFile);
}
}
catch (Exception deleteEx)
{
Serilog.Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {@SettingsFile}", settingsFile);
}
return false;
}
}
#region singleton stuff
#if DEBUG
public static Configuration CreateMockInstance()
{
var mockInstance = new Configuration() { persistentDictionary = new MockPersistentDictionary() };
#if !DEBUG
if (!new StackTrace().GetFrames().Select(f => f.GetMethod()?.DeclaringType?.Assembly.GetName().Name).Any(f => f?.EndsWith(".Tests") ?? false))
throw new InvalidOperationException($"Can only mock {nameof(Configuration)} in Debug mode or in test assemblies.");
#endif
var mockInstance = new Configuration() { JsonBackedDictionary = new EphemeralDictionary() };
mockInstance.SetString("Light", "ThemeVariant");
Instance = mockInstance;
return mockInstance;
@@ -75,14 +32,7 @@ namespace LibationFileManager
}
private static readonly Configuration s_SingletonInstance = new();
public static Configuration Instance { get; private set; } = s_SingletonInstance;
#else
public static Configuration CreateMockInstance()
=> throw new InvalidOperationException($"Can only mock {nameof(Configuration)} in Debug mode.");
public static void RestoreSingletonInstance()
=> throw new InvalidOperationException($"Can only mock {nameof(Configuration)} in Debug mode.");
public static Configuration Instance { get; } = new();
#endif
private Configuration() { }
#endregion

View File

@@ -4,16 +4,25 @@ using Newtonsoft.Json.Linq;
#nullable enable
namespace LibationFileManager;
internal class MockPersistentDictionary : IPersistentDictionary
internal class EphemeralDictionary : IJsonBackedDictionary
{
private JObject JsonObject { get; } = new();
private JObject JsonObject { get; }
public EphemeralDictionary()
{
JsonObject = new();
}
public EphemeralDictionary(JObject dataStore)
{
JsonObject = dataStore;
}
public bool Exists(string propertyName)
=> JsonObject.ContainsKey(propertyName);
public string? GetString(string propertyName, string? defaultValue = null)
=> JsonObject[propertyName]?.Value<string>() ?? defaultValue;
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
=> GetObject(propertyName) is object obj ? IPersistentDictionary.UpCast<T>(obj) : defaultValue;
=> GetObject(propertyName) is object obj ? IJsonBackedDictionary.UpCast<T>(obj) : defaultValue;
public object? GetObject(string propertyName)
=> JsonObject[propertyName]?.Value<object>();
public void SetString(string propertyName, string? newValue)

View File

@@ -19,7 +19,7 @@ namespace LibationFileManager
public static event EventHandler<CacheEntry>? Inserted;
public static event EventHandler<CacheEntry>? Removed;
private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
private static readonly FileCacheV2<CacheEntry> Cache = new();

View File

@@ -65,11 +65,19 @@ namespace LibationFileManager
AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
var configAppAssembly = Assembly.LoadFrom(configApp);
var type = typeof(IInteropFunctions);
InteropFunctionsType = configAppAssembly
.GetTypes()
.FirstOrDefault(type.IsAssignableFrom);
try
{
var configAppAssembly = Assembly.LoadFrom(configApp);
var type = typeof(IInteropFunctions);
InteropFunctionsType = configAppAssembly
.GetTypes()
.FirstOrDefault(type.IsAssignableFrom);
}
catch (Exception e)
{
//None of the interop functions are strictly necessary for Libation to run.
Serilog.Log.Logger.Error(e, "Unable to load types from assembly {@configApp}", configApp);
}
}
private static string? getOSConfigApp()
{

View File

@@ -0,0 +1,282 @@
using Dinah.Core.Logging;
using FileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AppScaffolding")]
[assembly: InternalsVisibleTo("LibationUiBase.Tests")]
#nullable enable
namespace LibationFileManager;
/// <summary>
/// Provides access to Libation's configuration and settings file locations, including methods for validating and
/// updating the Libation files directory and Settings.json file. An instance is bount to a single appsettings.json file.
/// </summary>
public class LibationFiles
{
internal static string? s_DefaultLibationFilesDirectory;
public static string DefaultLibationFilesDirectory => s_DefaultLibationFilesDirectory ??= Configuration.IsWindows ? Configuration.UserProfile : Configuration.LocalAppData;
public const string LIBATION_FILES_KEY = "LibationFiles";
public const string SETTINGS_JSON = "Settings.json";
public const string LIBATION_FILES_DIR = "LIBATION_FILES_DIR";
/// <summary>
/// Directory pointed to by appsettings.json
/// </summary>
public LongPath Location { get; private set; }
/// <summary>
/// Returns true if <see cref="SettingsFilePath"/> exists and the <see cref="Configuration.Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Configuration.Books"/> directory.
/// </summary>
public bool SettingsAreValid => SettingsFileIsValid(SettingsFilePath);
/// <summary>
/// Found Location of appsettings.json. This file must exist or be able to be created for Libation to start.
/// </summary>
internal string? AppsettingsJsonFile { get; }
/// <summary>
/// File path to Settings.json inside <see cref="Location"/>
/// </summary>
public string SettingsFilePath => Path.Combine(Location, SETTINGS_JSON);
internal LibationFiles()
{
var libationFilesDir = Environment.GetEnvironmentVariable(LIBATION_FILES_DIR);
if (Directory.Exists(libationFilesDir))
{
Location = libationFilesDir;
}
else
{
AppsettingsJsonFile = GetOrCreateAppsettingsFile();
Location = GetLibationFilesFromAppsettings(AppsettingsJsonFile);
}
}
internal LibationFiles(string appSettingsFile)
{
AppsettingsJsonFile = appSettingsFile;
Location = GetLibationFilesFromAppsettings(AppsettingsJsonFile);
}
/// <summary>
/// Set the location of the Libation Files directory, updating appsettings.json.
/// </summary>
public void SetLibationFiles(LongPath libationFilesDirectory)
{
if (AppsettingsJsonFile is null)
{
Environment.SetEnvironmentVariable(LIBATION_FILES_DIR, libationFilesDirectory);
return;
}
var startingContents = File.ReadAllText(AppsettingsJsonFile);
var jObj = JObject.Parse(startingContents);
jObj[LIBATION_FILES_KEY] = (string)(Location = libationFilesDirectory);
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
try
{
// 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, libationFilesDirectory });
}
catch (Exception ex)
{
Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, libationFilesDirectory });
}
}
/// <summary>
/// Returns true if <paramref name="settingsFile"/> exists and the <see cref="Configuration.Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Configuration.Books"/> directory.
/// </summary>
/// <param name="settingsFile">File path to the Settings.json file</param>
public static bool SettingsFileIsValid(string settingsFile)
{
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
try
{
var settingsJson = JObject.Parse(File.ReadAllText(settingsFile));
return !string.IsNullOrWhiteSpace(settingsJson[nameof(Configuration.Books)]?.Value<string>());
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Failed to load settings file: {@SettingsFile}", settingsFile);
try
{
Log.Logger.Information("Deleting invalid settings file: {@SettingsFile}", settingsFile);
FileUtility.SaferDelete(settingsFile);
Log.Logger.Information("Creating a new, empty setting file: {@SettingsFile}", settingsFile);
try
{
File.WriteAllText(settingsFile, "{}");
}
catch (Exception createEx)
{
Log.Logger.Error(createEx, "Failed to create new settings file: {@SettingsFile}", settingsFile);
}
}
catch (Exception deleteEx)
{
Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {@SettingsFile}", settingsFile);
}
return false;
}
}
/// <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[] possibleAppsettingsDirectories = new[]
{
Configuration.ProcessDirectory,
Configuration.LocalAppData,
Configuration.UserProfile,
Configuration.WinTemp,
};
//Try to find and validate appsettings.json in each folder
foreach (var dir in possibleAppsettingsDirectories)
{
var appsettingsFile = Path.Combine(dir, appsettings_filename);
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, DefaultLibationFilesDirectory } }.ToString(Formatting.Indented);
foreach (var dir in possibleAppsettingsDirectories)
{
//Don't try to create appsettings.json in the program files directory on *.nix systems.
//However, still _look_ for one there for backwards compatibility with previous installations
if (!Configuration.IsWindows && dir == Configuration.ProcessDirectory)
continue;
var appsettingsFile = Path.Combine(dir, appsettings_filename);
try
{
Directory.CreateDirectory(dir);
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}");
}
/// <summary>
/// Get the LibationFiles directory from appsettings.json, expanding environment variables as needed.
/// </summary>
/// <param name="appsettingsPath"></param>
/// <returns></returns>
/// <exception cref="InvalidDataException">The appsettings.json file does not contain a "LibationFiles" key</exception>
private static string GetLibationFilesFromAppsettings(LongPath appsettingsPath)
{
// 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(appsettingsPath));
if (jObjFinal[LIBATION_FILES_KEY]?.Value<string>() is not string libationFiles)
throw new InvalidDataException($"{LIBATION_FILES_KEY} not found in {appsettingsPath}");
if (Configuration.IsWindows)
{
libationFiles = Environment.ExpandEnvironmentVariables(libationFiles);
}
else
{
//If the shell command fails and returns null, proceed with the verbatim
//LIBATION_FILES_KEY path and hope for the best. If Libation can't find
//anything at this path it will set LIBATION_FILES_KEY to UserProfile
libationFiles = runShellCommand("echo " + libationFiles) ?? libationFiles;
}
return libationFiles;
static string? runShellCommand(string command)
{
var psi = new ProcessStartInfo
{
FileName = "/bin/sh",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
ArgumentList =
{
"-c",
command
}
};
try
{
var proc = Process.Start(psi);
proc?.WaitForExit();
return proc?.StandardOutput?.ReadToEnd()?.Trim();
}
catch (Exception e)
{
Log.Error(e, "Failed to run shell command. {@Arguments}", psi.ArgumentList);
return null;
}
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using FileManager;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
@@ -42,7 +43,7 @@ namespace LibationFileManager
{
// not customizable. don't move to config
private static string ImagesDirectory { get; }
= new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("Images").FullName;
= new DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("Images").FullName;
private static string getPath(PictureDefinition def)
=> Path.Combine(ImagesDirectory, $"{def.PictureId}{def.Size}.jpg");

View File

@@ -20,7 +20,7 @@ namespace LibationFileManager
public List<NamedFilter> Filters { get; set; } = new();
}
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "QuickFilters.json");
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "QuickFilters.json");
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed

View File

@@ -5,7 +5,7 @@ namespace LibationFileManager
public static class SqliteStorage
{
// not customizable. don't move to config
private static string databasePath => Path.Combine(Configuration.Instance.LibationFiles, "LibationContext.db");
private static string databasePath => Path.Combine(Configuration.Instance.LibationFiles.Location, "LibationContext.db");
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;Pooling=False;";
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using DataLayer;
using Dinah.Core;
using FileManager;
using LibationFileManager;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
@@ -279,6 +280,6 @@ namespace LibationSearchEngine
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName;
}
}

View File

@@ -0,0 +1,242 @@
using Dinah.Core;
using Dinah.Core.Logging;
using LibationFileManager;
using LibationUiBase.Forms;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace LibationUiBase;
/// <summary>
/// Contains the results of a, initial setup prompt.
/// </summary>
public interface ILibationSetup
{
public bool IsNewUser { get; }
public bool IsReturningUser { get; }
}
/// <summary>
/// Contains the results of a Libation Files install location selection prompt.
/// </summary>
public interface ILibationInstallLocation
{
public string? SelectedDirectory { get; }
}
/// <summary>
/// Provides configuration and delegates for running the Libation setup process, including user prompts for initial
/// setup and selecting installation locations.
/// </summary>
/// <remarks>LibationSetup encapsulates the logic required to ensure that Libation is properly configured before
/// use. This class is used at application startup to ensure that all required settings are present and valid
/// before proceeding.</remarks>
public class LibationSetup
{
/// <summary> Asynchronous delegate to show the setup prompt </summary>
public Func<Task<ILibationSetup>>? SetupPromptAsync { get; init; }
/// <summary> Asynchronous delegate to show the Libation Files selection dialog prompt </summary>
public Func<Task<ILibationInstallLocation?>>? SelectFolderPromptAsync { get; init; }
/// <summary> Synchronous delegate to show the setup prompt </summary>
public Func<ILibationSetup>? SetupPrompt { get; init; }
/// <summary> Synchronous delegate to show the Libation Files selection dialog prompt </summary>
public Func<ILibationInstallLocation?>? SelectFolderPrompt { get; init; }
private LibationFiles Files { get; }
public LibationSetup(LibationFiles libationFiles)
{
Files = libationFiles;
}
/// <summary>
/// Runs Libation setup if needed.
/// Verifies that
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<bool> RunSetupIfNeededAsync()
{
// all returns should be preceded by either:
// - if libationFiles.LibationSettingsAreValid
// - OnCancelled()
if (Files.SettingsAreValid)
return true;
// check for existing settings in default location
// First check if file exists so that, if it doesn't, we don't
// overwrite user's LibationFiles setting in appsettings.json
var defaultSettingsFile = Path.Combine(LibationFiles.DefaultLibationFilesDirectory, LibationFiles.SETTINGS_JSON);
if (File.Exists(defaultSettingsFile) && LibationFiles.SettingsFileIsValid(defaultSettingsFile))
{
Files.SetLibationFiles(LibationFiles.DefaultLibationFilesDirectory);
if (Files.SettingsAreValid)
return true;
}
var setupResult
= SetupPromptAsync is not null ? await SetupPromptAsync()
: SetupPrompt is not null ? SetupPrompt()
: throw new InvalidOperationException("No setup prompt provided");
if (setupResult.IsNewUser)
{
return await CreateDefaultSettingsAsync();
}
else if (setupResult.IsReturningUser)
{
var chooseFolderResult
= SelectFolderPromptAsync is not null ? await SelectFolderPromptAsync()
: SelectFolderPrompt is not null ? SelectFolderPrompt()
: throw new InvalidOperationException("No select folder prompt provided");
if (string.IsNullOrWhiteSpace(chooseFolderResult?.SelectedDirectory))
return false;
Files.SetLibationFiles(chooseFolderResult.SelectedDirectory);
if (Files.SettingsAreValid)
return true;
// path did not result in valid settings
var continueResult = await MessageBoxBase.ShowAsyncImpl(null,
$"""
No valid settings were found at this location.
Would you like to create a new install settings in this folder?
{chooseFolderResult.SelectedDirectory}
""",
"New install?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question,
MessageBoxDefaultButton.Button1);
return continueResult == DialogResult.Yes && await CreateDefaultSettingsAsync();
}
else
{
return false;
}
}
private async Task<bool> CreateDefaultSettingsAsync()
{
if (!TryCreateDirectory())
{
var result = await MessageBoxBase.ShowAsyncImpl(null,
$"""
Could not create the Libation Settings folder at:
{Files.Location.Path}
Would you like to create a new install settings in this folder?
{LibationFiles.DefaultLibationFilesDirectory}
""",
"Error Creating Libation Settings",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question,
MessageBoxDefaultButton.Button1);
if (result is not DialogResult.Yes)
return false;
Files.SetLibationFiles(LibationFiles.DefaultLibationFilesDirectory);
//We should never not be able to access DefaultLibationFilesDirectory.
//If we can't write here, something is very wrong and we shouldn't even try to continue.
if (!TryCreateDirectory())
{
await MessageBoxBase.ShowAsyncImpl(null,
$"""
An error occurred while creating default settings folder:
{LibationFiles.DefaultLibationFilesDirectory}
""",
"Error Creating Libation Settings",
MessageBoxButtons.OK,
MessageBoxIcon.Error,
MessageBoxDefaultButton.Button1);
return false;
}
}
if (Files.SettingsAreValid)
return true;
try
{
WriteDefaultSettingsFile(Files.SettingsFilePath);
return Files.SettingsAreValid;
}
catch (Exception ex)
{
// We are able to create the LibationFiles directory, but we can't write a settings file in it.
// Examples of this is the root of a system drive (C:\)
Log.Logger.TryLogError(ex, $"Failed to create {LibationFiles.SETTINGS_JSON} in {Files.Location}");
if (!Files.Location.PathWithoutPrefix.Equals(LibationFiles.DefaultLibationFilesDirectory, StringComparison.OrdinalIgnoreCase))
{
var result = await MessageBoxBase.ShowAsyncImpl(null,
$"""
Could not create the Libation Settings file at:
{Files.SettingsFilePath}
Would you like to create a new install settings in this folder?
{LibationFiles.DefaultLibationFilesDirectory}
""",
"Error Creating Libation Settings",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question,
MessageBoxDefaultButton.Button1);
if (result is not DialogResult.Yes)
return false;
// Try again in the default location
Log.Logger.TryLogInformation($"Changing {LibationFiles.LIBATION_FILES_KEY} to {LibationFiles.DefaultLibationFilesDirectory}");
Files.SetLibationFiles(LibationFiles.DefaultLibationFilesDirectory);
return await CreateDefaultSettingsAsync();
}
else
{
await MessageBoxBase.ShowAsyncImpl(null,
$"""
An error occurred while creating default settings file in:
{LibationFiles.DefaultLibationFilesDirectory}
""",
"Error Creating Libation Settings",
MessageBoxButtons.OK,
MessageBoxIcon.Error,
MessageBoxDefaultButton.Button1);
return false;
}
}
}
private bool TryCreateDirectory()
{
try
{
Directory.CreateDirectory(Files.Location);
return true;
}
catch (Exception ex)
{
Log.Logger.TryLogError(ex, $"Failed to create {LibationFiles.LIBATION_FILES_KEY} directory at {Files.Location}");
return false;
}
}
private void WriteDefaultSettingsFile(string settingsFilePath)
{
var booksParent = Configuration.IsWindows ? Files.Location.Path : Configuration.MyMusic;
var jObj = new JObject
{
{ nameof(Configuration.Books), Path.Combine(booksParent, nameof(Configuration.Books)) }
};
var contents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
File.WriteAllText(settingsFilePath, contents);
}
}

View File

@@ -1,10 +1,11 @@
using System;
using LibationFileManager;
using LibationUiBase;
using System;
using System.Windows.Forms;
using LibationFileManager;
namespace LibationWinForms.Dialogs
{
public partial class LibationFilesDialog : Form
public partial class LibationFilesDialog : Form, ILibationInstallLocation
{
public string SelectedDirectory { get; private set; }
@@ -26,7 +27,12 @@ namespace LibationWinForms.Dialogs
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs
}, Configuration.KnownDirectories.UserProfile);
libationFilesSelectControl.SelectDirectory(config.LibationFiles);
var selectedDir = System.IO.Directory.Exists(Configuration.Instance.LibationFiles.Location.PathWithoutPrefix)
? Configuration.Instance.LibationFiles.Location.PathWithoutPrefix
: Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile);
libationFilesSelectControl.SelectDirectory(selectedDir);
}
private void saveBtn_Click(object sender, EventArgs e)

View File

@@ -58,7 +58,7 @@ namespace LibationWinForms.Dialogs
LongPath dir = "";
try
{
dir = LibationFileManager.Configuration.Instance.LibationFiles;
dir = LibationFileManager.Configuration.Instance.LibationFiles.Location;
Go.To.Folder(dir.ShortPathName);
}
catch

View File

@@ -17,7 +17,7 @@ namespace LibationWinForms.Dialogs
if (File.Exists(LogFileFilter.LogFilePath))
Go.To.File(LogFileFilter.LogFilePath);
else
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
Go.To.Folder(Configuration.Instance.LibationFiles.Location.ShortPathName);
}
private void Load_Important(Configuration config)

View File

@@ -1,10 +1,11 @@
using System;
using LibationUiBase;
using System;
using System.Linq;
using System.Windows.Forms;
namespace LibationWinForms.Dialogs
{
public partial class SetupDialog : Form
public partial class SetupDialog : Form, ILibationSetup
{
public bool IsNewUser { get; private set; }
public bool IsReturningUser { get; private set; }

View File

@@ -1,14 +1,14 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using AppScaffolding;
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForms
{
@@ -18,8 +18,10 @@ namespace LibationWinForms
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
static extern bool AllocConsole();
private static Form1 form1;
[STAThread]
static void Main()
static async Task Main()
{
Task<List<LibraryBook>> libraryLoadTask;
@@ -41,9 +43,10 @@ namespace LibationWinForms
//***********************************************//
// Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration
var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations();
LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = ShowMessageBox;
// do this as soon as possible (post-config)
RunInstaller(config);
await RunSetupIfNeededAsync(config);
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
@@ -85,104 +88,62 @@ namespace LibationWinForms
// global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd
postLoggingGlobalExceptionHandling();
var form1 = new Form1();
form1 = new Form1();
form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask);
LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = ShowMessageBox;
Application.Run(form1);
#region Message Box Handler for LibationUiBase
Task<LibationUiBase.Forms.DialogResult> ShowMessageBox(
object owner,
string message,
string caption,
LibationUiBase.Forms.MessageBoxButtons buttons,
LibationUiBase.Forms.MessageBoxIcon icon,
LibationUiBase.Forms.MessageBoxDefaultButton defaultButton,
bool _)
{
var result = form1.Invoke(() =>
MessageBox.Show(
owner as IWin32Window ?? form1,
message,
caption,
(MessageBoxButtons)buttons,
(MessageBoxIcon)icon,
(MessageBoxDefaultButton)defaultButton));
return Task.FromResult((LibationUiBase.Forms.DialogResult)result);
}
#endregion;
}
private static void RunInstaller(Configuration config)
#region Message Box Handler for LibationUiBase
static Task<LibationUiBase.Forms.DialogResult> ShowMessageBox(
object owner,
string message,
string caption,
LibationUiBase.Forms.MessageBoxButtons buttons,
LibationUiBase.Forms.MessageBoxIcon icon,
LibationUiBase.Forms.MessageBoxDefaultButton defaultButton,
bool _)
{
// all returns should be preceded by either:
// - if config.LibationSettingsAreValid
// - error message, Exit()
Func<DialogResult> showMessageBox = () => MessageBox.Show(
owner as IWin32Window ?? form1,
message,
caption,
(MessageBoxButtons)buttons,
(MessageBoxIcon)icon,
(MessageBoxDefaultButton)defaultButton);
if (config.LibationSettingsAreValid)
return;
var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
var result = form1 is null ? showMessageBox() : form1.Invoke(showMessageBox);
return Task.FromResult((LibationUiBase.Forms.DialogResult)result);
}
#endregion;
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
Configuration.SetLibationFiles(defaultLibationFilesDir);
private static async Task RunSetupIfNeededAsync(Configuration config)
{
var setup = new LibationSetup(config.LibationFiles)
{
SetupPrompt = ShowSetup,
SelectFolderPrompt = SelectInstallLocation
};
if (config.LibationSettingsAreValid)
return;
static void CancelInstallation()
if (!await setup.RunSetupIfNeededAsync())
{
MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Application.Exit();
Environment.Exit(0);
Environment.Exit(-1);
}
var setupDialog = new SetupDialog();
if (setupDialog.ShowDialog() != DialogResult.OK)
static ILibationSetup ShowSetup()
{
CancelInstallation();
return;
var setupDialog = new SetupDialog();
setupDialog.ShowDialog();
return setupDialog;
}
if (setupDialog.IsNewUser)
{
Configuration.SetLibationFiles(defaultLibationFilesDir);
config.Books = Configuration.DefaultBooksDirectory;
}
else if (setupDialog.IsReturningUser)
static ILibationInstallLocation SelectInstallLocation()
{
var libationFilesDialog = new LibationFilesDialog();
if (libationFilesDialog.ShowDialog() != DialogResult.OK)
{
CancelInstallation();
return;
}
Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
return;
// path did not result in valid settings
var continueResult = MessageBox.Show(
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
"New install?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (continueResult != DialogResult.Yes)
{
CancelInstallation();
return;
}
config.Books = Configuration.DefaultBooksDirectory;
return libationFilesDialog.ShowDialog() is DialogResult.OK ? libationFilesDialog : null;
}
if (!config.LibationSettingsAreValid)
CancelInstallation();
}
/// <summary>migrations which require Forms or are long-running</summary>

View File

@@ -1,7 +1,6 @@
<?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>
@@ -19,17 +18,5 @@
<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

@@ -0,0 +1,20 @@
<?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>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.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>

View File

Binary file not shown.

View File

@@ -30,15 +30,27 @@
</ItemGroup>
<ItemGroup>
<None Update="background.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="fileicon">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Info.plist">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.entitlements">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.entitlements.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="libation.icns">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation_DS_Store">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -29,17 +29,8 @@ namespace MacOSConfigApp
{
Serilog.Log.Information($"Extracting upgrade bundle to {AppPath}");
//tar wil overwrite existing without elevated privileges
Process.Start("tar", $"-xf {upgradeBundle.SurroundWithQuotes()} -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
""");
//Upgrade bundle is a DMG
Process.Start("open", upgradeBundle.SurroundWithQuotes())?.WaitForExit();
}
//Using osascript -e '[script]' works from the terminal, but I haven't figured

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More