diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index d6529b39..89a94f2f 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Dinah.Core; @@ -52,7 +51,7 @@ namespace AaxDecrypter // delete file after validation is complete FileUtility.SaferDelete(OutputFileName); - } + } public abstract Task CancelAsync(); @@ -72,7 +71,7 @@ namespace AaxDecrypter => RetrievedNarrators?.Invoke(this, narrators); protected void OnRetrievedCoverArt(byte[] coverArt) => RetrievedCoverArt?.Invoke(this, coverArt); - protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) + protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) => DecryptProgressUpdate?.Invoke(this, downloadProgress); protected void OnDecryptTimeRemaining(TimeSpan timeRemaining) => DecryptTimeRemaining?.Invoke(this, timeRemaining); @@ -111,8 +110,8 @@ namespace AaxDecrypter { FileUtility.SaferDelete(jsonDownloadState); - if (DownloadOptions.AudibleKey is not null && - DownloadOptions.AudibleIV is not null && + if (DownloadOptions.AudibleKey is not null && + DownloadOptions.AudibleIV is not null && DownloadOptions.RetainEncryptedFile) { string aaxPath = Path.ChangeExtension(TempFilePath, ".aax"); @@ -156,12 +155,7 @@ namespace AaxDecrypter private NetworkFileStreamPersister NewNetworkFilePersister() { - var headers = new System.Net.WebHeaderCollection - { - { "User-Agent", DownloadOptions.UserAgent } - }; - - var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers); + var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } }); return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState); } } diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index d03e44f8..2bd4863b 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -1,91 +1,53 @@ using Dinah.Core; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; +using System.Threading.Tasks; namespace AaxDecrypter { - /// - /// A for a single Uri. - /// - public class SingleUriCookieContainer : CookieContainer - { - private Uri baseAddress; - public Uri Uri - { - get => baseAddress; - set - { - baseAddress = new UriBuilder(value.Scheme, value.Host).Uri; - } - } - - public CookieCollection GetCookies() - { - return GetCookies(Uri); - } - } - - /// - /// A resumable, simultaneous file downloader and reader. - /// + /// A resumable, simultaneous file downloader and reader. public class NetworkFileStream : Stream, IUpdatable { public event EventHandler Updated; #region Public Properties - /// - /// Location to save the downloaded data. - /// + /// Location to save the downloaded data. [JsonProperty(Required = Required.Always)] public string SaveFilePath { get; } - /// - /// Http(s) address of the file to download. - /// + /// Http(s) address of the file to download. [JsonProperty(Required = Required.Always)] public Uri Uri { get; private set; } - /// - /// All cookies set by caller or by the remote server. - /// + /// Http headers to be sent to the server with the request. [JsonProperty(Required = Required.Always)] - public SingleUriCookieContainer CookieContainer { get; } + public Dictionary RequestHeaders { get; private set; } - /// - /// Http headers to be sent to the server with the request. - /// - [JsonProperty(Required = Required.Always)] - public WebHeaderCollection RequestHeaders { get; private set; } - - /// - /// The position in that has been written and flushed to disk. - /// + /// The position in that has been written and flushed to disk. [JsonProperty(Required = Required.Always)] public long WritePosition { get; private set; } - /// - /// The total length of the file to download. - /// + /// The total length of the file to download. [JsonProperty(Required = Required.Always)] public long ContentLength { get; private set; } + [JsonIgnore] + public bool IsCancelled { get; private set; } + #endregion #region Private Properties - private HttpWebRequest HttpRequest { get; set; } private FileStream _writeFile { get; } private FileStream _readFile { get; } - private Stream _networkStream { get; set; } - private bool hasBegunDownloading { get; set; } - public bool IsCancelled { get; private set; } - private EventWaitHandle downloadEnded { get; set; } - private EventWaitHandle downloadedPiece { get; set; } + private EventWaitHandle _downloadedPiece { get; set; } + private Task _backgroundDownloadTask { get; set; } #endregion @@ -102,15 +64,12 @@ namespace AaxDecrypter #region Constructor - /// - /// A resumable, simultaneous file downloader and reader. - /// + /// A resumable, simultaneous file downloader and reader. /// Path to a location on disk to save the downloaded data from /// Http(s) address of the file to download. /// The position in to begin downloading. /// Http headers to be sent to the server with the . - /// A with cookies to send with the . It will also be populated with any cookies set by the server. - public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null) + public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary requestHeaders = null) { ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath)); ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri)); @@ -122,8 +81,7 @@ namespace AaxDecrypter SaveFilePath = saveFilePath; Uri = uri; WritePosition = writePosition; - RequestHeaders = requestHeaders ?? new WebHeaderCollection(); - CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri }; + RequestHeaders = requestHeaders ?? new(); _writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite) { @@ -139,12 +97,10 @@ namespace AaxDecrypter #region Downloader - /// - /// Update the . - /// + /// Update the . private void Update() { - RequestHeaders = HttpRequest.Headers; + RequestHeaders["Range"] = $"bytes={WritePosition}-"; try { Updated?.Invoke(this, EventArgs.Empty); @@ -155,9 +111,7 @@ namespace AaxDecrypter } } - /// - /// Set a different to the same file targeted by this instance of - /// + /// Set a different to the same file targeted by this instance of /// New host must match existing host. public void SetUriForSameFile(Uri uriToSameFile) { @@ -165,37 +119,31 @@ namespace AaxDecrypter if (uriToSameFile.Host != Uri.Host) throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}"); - if (hasBegunDownloading) + if (_backgroundDownloadTask is not null) throw new InvalidOperationException("Cannot change Uri after download has started."); Uri = uriToSameFile; - HttpRequest = WebRequest.CreateHttp(Uri); - - HttpRequest.CookieContainer = CookieContainer; - HttpRequest.Headers = RequestHeaders; - //If NetworkFileStream is resuming, Header will already contain a range. - HttpRequest.Headers.Remove("Range"); - HttpRequest.AddRange(WritePosition); + RequestHeaders["Range"] = $"bytes={WritePosition}-"; } - /// - /// Begins downloading to in a background thread. - /// - private void BeginDownloading() - { - downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset); + /// Begins downloading to in a background thread. + /// The downloader + private Task BeginDownloading() + { if (ContentLength != 0 && WritePosition == ContentLength) - { - hasBegunDownloading = true; - downloadEnded.Set(); - return; - } + return Task.CompletedTask; if (ContentLength != 0 && WritePosition > ContentLength) throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10})."); - var response = HttpRequest.GetResponse() as HttpWebResponse; + + var request = new HttpRequestMessage(HttpMethod.Get, Uri); + + foreach (var header in RequestHeaders) + request.Headers.Add(header.Key, header.Value); + + var response = new HttpClient().Send(request, HttpCompletionOption.ResponseHeadersRead); if (response.StatusCode != HttpStatusCode.PartialContent) throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}."); @@ -203,24 +151,17 @@ namespace AaxDecrypter //Content length is the length of the range request, and it is only equal //to the complete file length if requesting Range: bytes=0- if (WritePosition == 0) - ContentLength = response.ContentLength; + ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault(); - _networkStream = response.GetResponseStream(); - downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset); + var networkStream = response.Content.ReadAsStream(); + _downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset); //Download the file in the background. - new Thread(() => DownloadFile()) - { IsBackground = true } - .Start(); - - hasBegunDownloading = true; - return; + return Task.Run(() => DownloadFile(networkStream)); } - /// - /// Download to . - /// - private void DownloadFile() + /// Download to . + private void DownloadFile(Stream networkStream) { var downloadPosition = WritePosition; var nextFlush = downloadPosition + DATA_FLUSH_SZ; @@ -231,7 +172,7 @@ namespace AaxDecrypter int bytesRead; do { - bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ); + bytesRead = networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ); _writeFile.Write(buff, 0, bytesRead); downloadPosition += bytesRead; @@ -242,15 +183,12 @@ namespace AaxDecrypter WritePosition = downloadPosition; Update(); nextFlush = downloadPosition + DATA_FLUSH_SZ; - downloadedPiece.Set(); + _downloadedPiece.Set(); } } while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0); - _writeFile.Close(); - _networkStream.Close(); WritePosition = downloadPosition; - Update(); if (!IsCancelled && WritePosition < ContentLength) throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10})."); @@ -264,8 +202,10 @@ namespace AaxDecrypter } finally { - downloadedPiece.Set(); - downloadEnded.Set(); + networkStream.Close(); + _writeFile.Close(); + _downloadedPiece.Set(); + Update(); } } @@ -274,96 +214,7 @@ namespace AaxDecrypter #region Json Connverters public static JsonSerializerSettings GetJsonSerializerSettings() - { - var settings = new JsonSerializerSettings(); - settings.Converters.Add(new CookieContainerConverter()); - settings.Converters.Add(new WebHeaderCollectionConverter()); - return settings; - } - - internal class CookieContainerConverter : JsonConverter - { - public override bool CanConvert(Type objectType) - => objectType == typeof(SingleUriCookieContainer); - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - var jObj = JObject.Load(reader); - - var result = new SingleUriCookieContainer() - { - Uri = new Uri(jObj["Uri"].Value()), - Capacity = jObj["Capacity"].Value(), - MaxCookieSize = jObj["MaxCookieSize"].Value(), - PerDomainCapacity = jObj["PerDomainCapacity"].Value() - }; - - var cookieList = jObj["Cookies"].ToList(); - - foreach (var cookie in cookieList) - { - result.Add( - new Cookie - { - Comment = cookie["Comment"].Value(), - HttpOnly = cookie["HttpOnly"].Value(), - Discard = cookie["Discard"].Value(), - Domain = cookie["Domain"].Value(), - Expired = cookie["Expired"].Value(), - Expires = cookie["Expires"].Value(), - Name = cookie["Name"].Value(), - Path = cookie["Path"].Value(), - Port = cookie["Port"].Value(), - Secure = cookie["Secure"].Value(), - Value = cookie["Value"].Value(), - Version = cookie["Version"].Value(), - }); - } - - return result; - } - - public override bool CanWrite => true; - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - var cookies = value as SingleUriCookieContainer; - var obj = (JObject)JToken.FromObject(value); - var container = cookies.GetCookies(); - var propertyNames = container.Select(c => JToken.FromObject(c)); - obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames))); - obj.WriteTo(writer); - } - } - - internal class WebHeaderCollectionConverter : JsonConverter - { - public override bool CanConvert(Type objectType) - => objectType == typeof(WebHeaderCollection); - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - var jObj = JObject.Load(reader); - var result = new WebHeaderCollection(); - - foreach (var kvp in jObj) - result.Add(kvp.Key, kvp.Value.Value()); - - return result; - } - - public override bool CanWrite => true; - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - var jObj = new JObject(); - var type = value.GetType(); - var headers = value as WebHeaderCollection; - var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k])); - jObj.Add(jHeaders); - jObj.WriteTo(writer); - } - } + => new JsonSerializerSettings(); #endregion @@ -383,8 +234,7 @@ namespace AaxDecrypter { get { - if (!hasBegunDownloading) - BeginDownloading(); + _backgroundDownloadTask ??= BeginDownloading(); return ContentLength; } } @@ -401,15 +251,14 @@ namespace AaxDecrypter [JsonIgnore] public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; } - public override void Flush() => throw new NotImplementedException(); - public override void SetLength(long value) => throw new NotImplementedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException(); + public override void Flush() => throw new InvalidOperationException(); + public override void SetLength(long value) => throw new InvalidOperationException(); + public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException(); public override int Read(byte[] buffer, int offset, int count) { - if (!hasBegunDownloading) - BeginDownloading(); - + _backgroundDownloadTask ??= BeginDownloading(); + var toRead = Math.Min(count, Length - Position); WaitToPosition(Position + toRead); return _readFile.Read(buffer, offset, count); @@ -428,38 +277,32 @@ namespace AaxDecrypter return _readFile.Position = newPosition; } - /// - /// Blocks until the file has downloaded to at least , then returns. - /// + /// Blocks until the file has downloaded to at least , then returns. /// The minimum required flished data length in . private void WaitToPosition(long requiredPosition) { while (WritePosition < requiredPosition - && hasBegunDownloading - && !IsCancelled - && !downloadEnded.WaitOne(0)) + && _backgroundDownloadTask?.IsCompleted is false + && !IsCancelled) { - downloadedPiece.WaitOne(100); + _downloadedPiece.WaitOne(100); } } public override void Close() { IsCancelled = true; - - while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ; + _backgroundDownloadTask?.Wait(); _readFile.Close(); _writeFile.Close(); - _networkStream?.Close(); Update(); } #endregion ~NetworkFileStream() { - downloadEnded?.Close(); - downloadedPiece?.Close(); + _downloadedPiece?.Close(); } } } diff --git a/Source/HangoverAvalonia/Controls/CheckedListBox.axaml b/Source/HangoverAvalonia/Controls/CheckedListBox.axaml index f797e06b..df1c6319 100644 --- a/Source/HangoverAvalonia/Controls/CheckedListBox.axaml +++ b/Source/HangoverAvalonia/Controls/CheckedListBox.axaml @@ -8,7 +8,7 @@ - + diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index 40795aff..c00bc51c 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -55,6 +55,9 @@ MainVM.cs + + MainWindow.axaml + diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index 66b4935d..8d9fd607 100644 --- a/Source/LibationAvalonia/AvaloniaUtils.cs +++ b/Source/LibationAvalonia/AvaloniaUtils.cs @@ -18,22 +18,6 @@ namespace LibationAvalonia return defaultBrush; } - public static Window GetParentWindow(this IControl control) - { - Window window = null; - - var p = control.Parent; - while (p != null) - { - if (p is Window) - { - window = (Window)p; - break; - } - p = p.Parent; - } - - return window; - } + public static Window GetParentWindow(this IControl control) => control.VisualRoot as Window; } } diff --git a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs index 02b85fe6..658e0e0b 100644 --- a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs @@ -5,6 +5,7 @@ using Dinah.Core; using LibationFileManager; using System.Collections.Generic; using ReactiveUI; +using System.Linq; namespace LibationAvalonia.Controls { @@ -16,7 +17,6 @@ namespace LibationAvalonia.Controls public static readonly StyledProperty SubDirectoryProperty = AvaloniaProperty.Register(nameof(SubDirectory)); - public static readonly StyledProperty DirectoryProperty = AvaloniaProperty.Register(nameof(Directory)); @@ -90,8 +90,19 @@ namespace LibationAvalonia.Controls private async void CustomDirBrowseBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - OpenFolderDialog ofd = new(); - customStates.CustomDir = await ofd.ShowAsync(VisualRoot as Window); + var options = new Avalonia.Platform.Storage.FolderPickerOpenOptions + { + AllowMultiple = false + }; + + var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options); + + customStates.CustomDir = + selectedFolders + .SingleOrDefault()?. + TryGetUri(out var uri) is true + ? uri.LocalPath + : customStates.CustomDir; } private void CheckStates_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -125,7 +136,6 @@ namespace LibationAvalonia.Controls Directory = customStates.CustomChecked ? selectedDir : System.IO.Path.Combine(selectedDir, SubDirectory); } - private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) { if (e.Property.Name == nameof(Directory) && e.OldValue is null) diff --git a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs index 8020436c..eab179ea 100644 --- a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs @@ -9,6 +9,9 @@ using System.Linq; using System.Threading.Tasks; using ReactiveUI; using AudibleApi; +using Avalonia.Platform.Storage; +using LibationFileManager; +using Avalonia.Platform.Storage.FileIO; namespace LibationAvalonia.Dialogs { @@ -110,24 +113,29 @@ namespace LibationAvalonia.Dialogs public async void ImportButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - - OpenFileDialog ofd = new(); - ofd.Filters.Add(new() { Name = "JSON File", Extensions = new() { "json" } }); - ofd.Directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - ofd.AllowMultiple = false; + var openFileDialogOptions = new FilePickerOpenOptions + { + Title = $"Select the audible-cli [account].json file", + AllowMultiple = false, + SuggestedStartLocation = new BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)), + FileTypeFilter = new FilePickerFileType[] + { + new("JSON files (*.json)") { Patterns = new[] { "json" } }, + } + }; string audibleAppDataDir = GetAudibleCliAppDataPath(); - if (Directory.Exists(audibleAppDataDir)) - ofd.Directory = audibleAppDataDir; + openFileDialogOptions.SuggestedStartLocation = new BclStorageFolder(audibleAppDataDir); - var filePath = await ofd.ShowAsync(this); + var selectedFiles = await StorageProvider.OpenFilePickerAsync(openFileDialogOptions); + var selectedFile = selectedFiles.SingleOrDefault(); - if (filePath is null || filePath.Length == 0) return; + if (!selectedFile.TryGetUri(out var uri)) return; try { - var jsonText = File.ReadAllText(filePath[0]); + var jsonText = File.ReadAllText(uri.LocalPath); var mkbAuth = Mkb79Auth.FromJson(jsonText); var account = await mkbAuth.ToAccountAsync(); @@ -148,7 +156,7 @@ namespace LibationAvalonia.Dialogs { await MessageBox.ShowAdminAlert( this, - $"An error occurred while importing an account from:\r\n{filePath[0]}\r\n\r\nIs the file encrypted?", + $"An error occurred while importing an account from:\r\n{uri.LocalPath}\r\n\r\nIs the file encrypted?", "Error Importing Account", ex); } @@ -263,26 +271,36 @@ namespace LibationAvalonia.Dialogs return; } - SaveFileDialog sfd = new(); - sfd.Filters.Add(new() { Name = "JSON File", Extensions = new() { "json" } }); + var options = new FilePickerSaveOptions + { + Title = $"Save Sover Image", + SuggestedStartLocation = new BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)), + SuggestedFileName = $"{acc.AccountId}.json", + DefaultExtension = "json", + ShowOverwritePrompt = true, + FileTypeChoices = new FilePickerFileType[] + { + new("JSON files (*.json)") { Patterns = new[] { "json" } }, + } + }; string audibleAppDataDir = GetAudibleCliAppDataPath(); if (Directory.Exists(audibleAppDataDir)) - sfd.Directory = audibleAppDataDir; + options.SuggestedStartLocation = new BclStorageFolder(audibleAppDataDir); - string fileName = await sfd.ShowAsync(this); - if (fileName is null) - return; + var selectedFile = await StorageProvider.SaveFilePickerAsync(options); + + if (!selectedFile.TryGetUri(out var uri)) return; try { var mkbAuth = Mkb79Auth.FromAccount(account); var jsonText = mkbAuth.ToJson(); - File.WriteAllText(fileName, jsonText); + File.WriteAllText(uri.LocalPath, jsonText); - await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{fileName}", "Success!"); + await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{uri.LocalPath}", "Success!"); } catch (Exception ex) { diff --git a/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs index 992ae7a3..5d6f444e 100644 --- a/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs @@ -7,6 +7,7 @@ using System; using System.ComponentModel; using System.IO; using ReactiveUI; +using Avalonia.Platform.Storage; namespace LibationAvalonia.Dialogs { @@ -46,27 +47,30 @@ namespace LibationAvalonia.Dialogs public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) { + var options = new FilePickerSaveOptions + { + Title = $"Save Sover Image", + SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)), + SuggestedFileName = $"{PictureFileName}.jpg", + DefaultExtension = "jpg", + ShowOverwritePrompt = true, + FileTypeChoices = new FilePickerFileType[] + { + new("Jpeg (*.jpg)") { Patterns = new[] { "jpg" } } + } + }; - SaveFileDialog saveFileDialog = new(); - saveFileDialog.Filters.Add(new FileDialogFilter { Name = "Jpeg", Extensions = new System.Collections.Generic.List() { "jpg" } }); - saveFileDialog.InitialFileName = PictureFileName; - saveFileDialog.Directory - = !LibationFileManager.Configuration.IsWindows ? null - : Directory.Exists(BookSaveDirectory) ? BookSaveDirectory - : Path.GetDirectoryName(BookSaveDirectory); + var selectedFile = await StorageProvider.SaveFilePickerAsync(options); - var fileName = await saveFileDialog.ShowAsync(this); - - if (fileName is null) - return; + if (!selectedFile.TryGetUri(out var uri)) return; try { - File.WriteAllBytes(fileName, CoverBytes); + File.WriteAllBytes(uri.LocalPath, CoverBytes); } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, $"Failed to save picture to {fileName}"); + Serilog.Log.Logger.Error(ex, $"Failed to save picture to {uri.LocalPath}"); await MessageBox.Show(this, $"An error was encountered while trying to save the picture\r\n\r\n{ex.Message}", "Failed to save picture", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1); } } diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml index 0224c16c..b7c76037 100644 --- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml @@ -351,21 +351,27 @@ - - + + - + + KnownDirectories { get; } = new() + { + Configuration.KnownDirectories.WinTemp, + Configuration.KnownDirectories.UserProfile, + Configuration.KnownDirectories.AppDir, + Configuration.KnownDirectories.MyDocs, + Configuration.KnownDirectories.LibationFiles + }; + + public string InProgressDirectory { get; set; } public void LoadSettings(Configuration config) { BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask; @@ -252,9 +261,7 @@ namespace LibationAvalonia.Dialogs FolderTemplate = config.FolderTemplate; FileTemplate = config.FileTemplate; ChapterFileTemplate = config.ChapterFileTemplate; - InProgressDirectory - = config.InProgress == Configuration.AppDir_Absolute ? Configuration.KnownDirectories.AppDir - : Configuration.GetKnownDirectory(config.InProgress); + InProgressDirectory = config.InProgress; UseCoverAsFolderIcon = config.UseCoverAsFolderIcon; } @@ -289,9 +296,7 @@ namespace LibationAvalonia.Dialogs config.FolderTemplate = FolderTemplate; config.FileTemplate = FileTemplate; config.ChapterFileTemplate = ChapterFileTemplate; - config.InProgress - = InProgressDirectory is Configuration.KnownDirectories.AppDir ? Configuration.AppDir_Absolute - : Configuration.GetKnownDirectoryPath(InProgressDirectory); + config.InProgress = InProgressDirectory; config.UseCoverAsFolderIcon = UseCoverAsFolderIcon; diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index b31929d4..7059f5fa 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -18,95 +18,98 @@ - - - en;es - + en;es + - - ..\bin\Avalonia\Debug - embedded - + + ..\bin\Avalonia\Debug + embedded + - - ..\bin\Avalonia\Release - embedded - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + ..\bin\Avalonia\Release + embedded + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - True - True - Resources.resx - - + + + + + - - - ResXFileCodeGenerator - Resources.Designer.cs - - + + + True + True + Resources.resx + + + MainWindow.axaml + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + - - - - @@ -120,16 +123,16 @@ - - Always - - - Always - - - Always - - + + Always + + + Always + + + Always + + diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.BackupCounts.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.BackupCounts.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.BackupCounts.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.BackupCounts.cs diff --git a/Source/LibationAvalonia/Views/MainWindow.Export.cs b/Source/LibationAvalonia/Views/MainWindow.Export.cs new file mode 100644 index 00000000..f5d903be --- /dev/null +++ b/Source/LibationAvalonia/Views/MainWindow.Export.cs @@ -0,0 +1,62 @@ +using ApplicationServices; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using LibationFileManager; +using System; +using System.Linq; + +namespace LibationAvalonia.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_Export() { } + + public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + try + { + var options = new FilePickerSaveOptions + { + Title = "Where to export Library", + SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books), + SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}.xlsx", + DefaultExtension = "xlsx", + ShowOverwritePrompt = true, + FileTypeChoices = new FilePickerFileType[] + { + new("Excel Workbook (*.xlsx)") { Patterns = new[] { "xlsx" } }, + new("CSV files (*.csv)") { Patterns = new[] { "csv" } }, + new("JSON files (*.json)") { Patterns = new[] { "json" } }, + new("All files (*.*)") { Patterns = new[] { "*" } }, + } + }; + + var selectedFile = await StorageProvider.SaveFilePickerAsync(options); + + if (!selectedFile.TryGetUri(out var uri)) return; + + var ext = System.IO.Path.GetExtension(uri.LocalPath); + switch (ext) + { + case "xlsx": // xlsx + default: + LibraryExporter.ToXlsx(uri.LocalPath); + break; + case "csv": // csv + LibraryExporter.ToCsv(uri.LocalPath); + break; + case "json": // json + LibraryExporter.ToJson(uri.LocalPath); + break; + } + + await MessageBox.Show("Library exported to:\r\n" + uri.LocalPath, "Library Exported"); + } + catch (Exception ex) + { + await MessageBox.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex); + } + } + } +} diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.Filter.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.Filter.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.Filter.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.Filter.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.Liberate.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.Liberate.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.Liberate.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.Liberate.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow._NoUI.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.NoUI.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow._NoUI.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.NoUI.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.ProcessQueue.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.ProcessQueue.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.QuickFilters.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.QuickFilters.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.QuickFilters.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.QuickFilters.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.RemoveBooks.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.RemoveBooks.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.ScanAuto.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.ScanAuto.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.ScanAuto.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.ScanAuto.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.ScanManual.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.ScanManual.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.ScanManual.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.ScanNotification.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.ScanNotification.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.ScanNotification.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.ScanNotification.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.Settings.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.Settings.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.Settings.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.Settings.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.VisibleBooks.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.VisibleBooks.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml rename to Source/LibationAvalonia/Views/MainWindow.axaml diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs similarity index 100% rename from Source/LibationAvalonia/Views/MainWindow/MainWindow.axaml.cs rename to Source/LibationAvalonia/Views/MainWindow.axaml.cs diff --git a/Source/LibationAvalonia/Views/MainWindow/MainWindow.Export.axaml.cs b/Source/LibationAvalonia/Views/MainWindow/MainWindow.Export.axaml.cs deleted file mode 100644 index fa39ebc3..00000000 --- a/Source/LibationAvalonia/Views/MainWindow/MainWindow.Export.axaml.cs +++ /dev/null @@ -1,52 +0,0 @@ -using ApplicationServices; -using Avalonia.Controls; -using System; -using System.Linq; - -namespace LibationAvalonia.Views -{ - //DONE - public partial class MainWindow - { - private void Configure_Export() { } - - public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - try - { - var saveFileDialog = new SaveFileDialog - { - Title = "Where to export Library", - }; - saveFileDialog.Filters.Add(new FileDialogFilter { Name = "Excel Workbook (*.xlsx)", Extensions = new() { "xlsx" } }); - saveFileDialog.Filters.Add(new FileDialogFilter { Name = "CSV files (*.csv)", Extensions = new() { "csv" } }); - saveFileDialog.Filters.Add(new FileDialogFilter { Name = "JSON files (*.json)", Extensions = new() { "json" } }); - saveFileDialog.Filters.Add(new FileDialogFilter { Name = "All files (*.*)", Extensions = new() { "*" } }); - - var fileName = await saveFileDialog.ShowAsync(this); - if (fileName is null) return; - - var ext = System.IO.Path.GetExtension(fileName); - switch (ext) - { - case "xlsx": // xlsx - default: - LibraryExporter.ToXlsx(fileName); - break; - case "csv": // csv - LibraryExporter.ToCsv(fileName); - break; - case "json": // json - LibraryExporter.ToJson(fileName); - break; - } - - await MessageBox.Show("Library exported to:\r\n" + fileName, "Library Exported"); - } - catch (Exception ex) - { - await MessageBox.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex); - } - } - } -} diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 30cff7a5..8c479f94 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -6,6 +6,7 @@ using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Platform.Storage; using DataLayer; using FileLiberator; using LibationAvalonia.Controls; @@ -42,7 +43,7 @@ namespace LibationAvalonia.Views }; var pdvm = new ProductsDisplayViewModel(); - pdvm.DisplayBooksAsync(sampleEntries); + _ = pdvm.DisplayBooksAsync(sampleEntries); DataContext = pdvm; return; @@ -106,17 +107,22 @@ namespace LibationAvalonia.Views { try { - var openFileDialog = new OpenFileDialog + var openFileDialogOptions = new FilePickerOpenOptions { Title = $"Locate the audio file for '{entry.Book.Title}'", - Filters = new() { new() { Name = "All files (*.*)", Extensions = new() { "*" } } }, - AllowMultiple = false + AllowMultiple = false, + SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books), + FileTypeFilter = new FilePickerFileType[] + { + new("All files (*.*)") { Patterns = new[] { "*" } }, + } }; - var filePaths = await openFileDialog.ShowAsync(this.GetParentWindow()); - var filePath = filePaths.SingleOrDefault(); - if (!string.IsNullOrWhiteSpace(filePath)) - FilePathCache.Insert(entry.AudibleProductId, filePath); + var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions); + var selectedFile = selectedFiles.SingleOrDefault(); + + if (selectedFile.TryGetUri(out var uri)) + FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath); } catch (Exception ex) { diff --git a/Source/LibationFileManager/AudibleFileStorage.cs b/Source/LibationFileManager/AudibleFileStorage.cs index ccf00fd6..f2cbda41 100644 --- a/Source/LibationFileManager/AudibleFileStorage.cs +++ b/Source/LibationFileManager/AudibleFileStorage.cs @@ -16,7 +16,16 @@ namespace LibationFileManager public static LongPath DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName; public static LongPath DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName; - private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage(); + static AudibleFileStorage() + { + //Clean up any partially-decrypted files from previous Libation instances. + //Do no clean DownloadsInProgressDirectory because those files are resumable + foreach (var tempFile in Directory.EnumerateFiles(DecryptInProgressDirectory)) + FileUtility.SaferDelete(tempFile); + } + + + private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage(); public static bool AaxcExists(string productId) => AAXC.Exists(productId); public static AudioFileStorage Audio { get; } = new AudioFileStorage(); diff --git a/Source/LibationFileManager/Configuration.KnownDirectories.cs b/Source/LibationFileManager/Configuration.KnownDirectories.cs index c69d16a1..5ad6a75e 100644 --- a/Source/LibationFileManager/Configuration.KnownDirectories.cs +++ b/Source/LibationFileManager/Configuration.KnownDirectories.cs @@ -25,7 +25,7 @@ namespace LibationFileManager [Description("The same folder that Libation is running from")] AppDir = 2, - [Description("Windows temporary folder")] + [Description("System temporary folder")] WinTemp = 3, [Description("My Documents")]