diff --git a/src/Markdowser/App.axaml.cs b/src/Markdowser/App.axaml.cs index 1249b3b..9a29aca 100644 --- a/src/Markdowser/App.axaml.cs +++ b/src/Markdowser/App.axaml.cs @@ -3,10 +3,15 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; +using CuteUtils.Logging; + using Markdowser.Models; +using Markdowser.Utilities; using Markdowser.ViewModels; using Markdowser.Views; +using System.IO; + namespace Markdowser; public partial class App : Application @@ -18,6 +23,11 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + if (!Directory.Exists(Configuration.ApplicationDataPath)) + { + _ = Directory.CreateDirectory(Configuration.ApplicationDataPath); + } + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.MainWindow = new MainWindow @@ -25,12 +35,23 @@ public partial class App : Application DataContext = new MainWindowViewModel(), }; + // Clear log files + GlobalState.Logger.ClearLogFile(LogSeverity.Debug); + GlobalState.Logger.ClearLogFile(LogSeverity.Info); + GlobalState.Logger.ClearLogFile(LogSeverity.Warn); + GlobalState.Logger.ClearLogFile(LogSeverity.Error); + GlobalState.Logger.ClearLogFile(LogSeverity.Fatal); + desktop.MainWindow.Closing += (sender, e) => { + GlobalState.Logger.LogInfo("Saving settings..."); Settings.SaveSettings(); + GlobalState.Logger.LogInfo("Settings saved."); }; } + GlobalState.Logger.LogInfo("Application started."); + base.OnFrameworkInitializationCompleted(); if (Current is not null) diff --git a/src/Markdowser/Commands/HyperlinkCommand.cs b/src/Markdowser/Commands/HyperlinkCommand.cs index 6c97b5e..c2a10d8 100644 --- a/src/Markdowser/Commands/HyperlinkCommand.cs +++ b/src/Markdowser/Commands/HyperlinkCommand.cs @@ -1,7 +1,6 @@ using Markdowser.Utilities; using System; -using System.Diagnostics; using System.Windows.Input; namespace Markdowser.Commands; @@ -23,7 +22,7 @@ public class HyperlinkCommand : ICommand { if (parameter is string url) { - Debug.WriteLine($"Hyperlink clicked: {url}"); + GlobalState.Logger.LogDebug($"Hyperlink clicked: {url}"); if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) { @@ -35,7 +34,7 @@ public class HyperlinkCommand : ICommand } else { - Debug.WriteLine($"Invalid URL: {url}"); + GlobalState.Logger.LogDebug($"Invalid URL: {url}"); } } } diff --git a/src/Markdowser/Markdowser.csproj b/src/Markdowser/Markdowser.csproj index 24b1d1b..e3b8058 100644 --- a/src/Markdowser/Markdowser.csproj +++ b/src/Markdowser/Markdowser.csproj @@ -7,6 +7,7 @@ app.manifest true 0.1.0.0 + false @@ -30,12 +31,14 @@ + + diff --git a/src/Markdowser/Models/Settings.cs b/src/Markdowser/Models/Settings.cs index 4179104..0dcb68e 100644 --- a/src/Markdowser/Models/Settings.cs +++ b/src/Markdowser/Models/Settings.cs @@ -1,6 +1,8 @@ using Markdowser.Utilities; +using System; using System.IO; +using System.Reflection; using System.Text.Json; namespace Markdowser.Models; @@ -15,6 +17,8 @@ public class Settings public string? HomeUrl { get; set; } + public string UserAgent { get; set; } = $"{Assembly.GetExecutingAssembly().GetName().Name}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"; + public static void SaveSettings() { if (!Directory.Exists(Configuration.ApplicationDataPath)) @@ -29,7 +33,15 @@ public class Settings { if (File.Exists(Configuration.SettingsFilePath)) { - return JsonSerializer.Deserialize(File.ReadAllText(Configuration.SettingsFilePath)) ?? new Settings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(Configuration.SettingsFilePath)) ?? new Settings(); + } + catch (Exception ex) + { + GlobalState.Logger.LogWarn(ex.Message); + return new Settings(); + } } else { diff --git a/src/Markdowser/Processing/IContentProcessor.cs b/src/Markdowser/Processing/IContentProcessor.cs index 09c6412..cd8e4b8 100644 --- a/src/Markdowser/Processing/IContentProcessor.cs +++ b/src/Markdowser/Processing/IContentProcessor.cs @@ -9,6 +9,10 @@ namespace Markdowser.Processing; internal interface IContentProcessor { + string Name { get; } + + string Description { get; } + bool CanProcess(HttpContentHeaders httpContentHeaders); Task Process(HttpResponseMessage httpResponseMessage, IProgress progress); diff --git a/src/Markdowser/Processing/Processors/CommonImageProcessor.cs b/src/Markdowser/Processing/Processors/CommonImageProcessor.cs index 5364948..50e2522 100644 --- a/src/Markdowser/Processing/Processors/CommonImageProcessor.cs +++ b/src/Markdowser/Processing/Processors/CommonImageProcessor.cs @@ -7,8 +7,13 @@ using System.Net.Http.Headers; using System.Threading.Tasks; namespace Markdowser.Processing.Processors; + internal class CommonImageProcessor : IContentProcessor { + public string Name => "Common Image Processor"; + + public string Description => "Processes common image types (e.g. PNG, JPEG, GIF)"; + public bool CanProcess(HttpContentHeaders httpContentHeaders) { if (httpContentHeaders.ContentType?.MediaType == "image/svg+xml") @@ -23,4 +28,4 @@ internal class CommonImageProcessor : IContentProcessor { return new CommonImageContentViewModel(httpResponseMessage.RequestMessage?.RequestUri?.Host?.ToString() ?? "Image", await httpResponseMessage.Content.ReadAsStreamAsync()); } -} +} \ No newline at end of file diff --git a/src/Markdowser/Processing/Processors/HtmlProcessor.cs b/src/Markdowser/Processing/Processors/HtmlProcessor.cs index 66d6c7d..edaa421 100644 --- a/src/Markdowser/Processing/Processors/HtmlProcessor.cs +++ b/src/Markdowser/Processing/Processors/HtmlProcessor.cs @@ -1,10 +1,10 @@ using HtmlAgilityPack; +using Markdowser.Utilities; using Markdowser.ViewModels; using Markdowser.ViewModels.Content; using System; -using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; @@ -19,6 +19,10 @@ internal partial class HtmlProcessor : IContentProcessor { private readonly ReverseMarkdown.Converter markdownConverter = new(); + public string Name => "HTML Processor"; + + public string Description => "Processes HTML content"; + public HtmlProcessor() { markdownConverter.Config.UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.Bypass; @@ -52,11 +56,11 @@ internal partial class HtmlProcessor : IContentProcessor htmlDoc.LoadHtml(html.ToString()); string title = htmlDoc.DocumentNode.SelectSingleNode("html/head/title")?.InnerText?.Trim() ?? httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? "Untitled"; - Debug.WriteLine("Converting HTML to markdown..."); + GlobalState.Logger.LogDebug("Converting HTML to markdown..."); string markdown = markdownConverter.Convert(html.ToString()); - Debug.WriteLine("Processing markdown..."); + GlobalState.Logger.LogDebug("Processing markdown..."); int currentLine = 0; diff --git a/src/Markdowser/Program.cs b/src/Markdowser/Program.cs index 12b002f..9564c36 100644 --- a/src/Markdowser/Program.cs +++ b/src/Markdowser/Program.cs @@ -1,11 +1,17 @@ using Avalonia; using Avalonia.ReactiveUI; +using Markdowser.Utilities; + using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.FontAwesome; using Projektanker.Icons.Avalonia.MaterialDesign; +using ReactiveUI; + using System; +using System.Reactive; +using System.Threading.Tasks; namespace Markdowser; @@ -17,8 +23,30 @@ internal static class Program [STAThread] public static void Main(string[] args) { - _ = BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + AppDomain.CurrentDomain.UnhandledException += (sender, e) => + { + GlobalState.Logger.LogFatal(e.ExceptionObject.ToString() ?? "Unknown error."); + }; + + TaskScheduler.UnobservedTaskException += (sender, e) => + { + GlobalState.Logger.LogFatal(e.Exception.ToString()); + }; + + RxApp.DefaultExceptionHandler = Observer.Create(ex => + { + GlobalState.Logger.LogFatal(ex.ToString()); + }); + + try + { + _ = BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + GlobalState.Logger.LogFatal(ex.ToString()); + } } // Avalonia configuration, don't remove; also used by visual designer. diff --git a/src/Markdowser/Utilities/Configuration.cs b/src/Markdowser/Utilities/Configuration.cs index f859e02..6975467 100644 --- a/src/Markdowser/Utilities/Configuration.cs +++ b/src/Markdowser/Utilities/Configuration.cs @@ -1,12 +1,39 @@ -using System; +using Syroot.Windows.IO; + +using System; using System.IO; +using System.Runtime.InteropServices; namespace Markdowser.Utilities; internal static class Configuration { - public static string DownloadPath => Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - public static string ApplicationDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), nameof(Markdowser)); + public static string DownloadPath + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return KnownFolders.Downloads.Path; + } + + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Downloads"); + } + } + + public static string ApplicationDataPath + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StoneRed", nameof(Markdowser)); + } + + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StoneRed", nameof(Markdowser)); + } + } public static string SettingsFilePath => Path.Combine(ApplicationDataPath, "settings.json"); + public static string LogFilePath => Path.Combine(ApplicationDataPath, $"{nameof(Markdowser)}.log"); } \ No newline at end of file diff --git a/src/Markdowser/Utilities/GlobalState.cs b/src/Markdowser/Utilities/GlobalState.cs index 856fb75..38cc0f6 100644 --- a/src/Markdowser/Utilities/GlobalState.cs +++ b/src/Markdowser/Utilities/GlobalState.cs @@ -1,5 +1,7 @@ using Avalonia.Controls; +using CuteUtils.Logging; + using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -16,6 +18,42 @@ internal static class GlobalState private static string url = string.Empty; + public static Logger Logger { get; } = new() + { + Config = new() + { + DebugConfig = new() + { + LogTarget = LogTarget.DebugConsole + }, + InfoConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + WarnConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + ErrorConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + FatalConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + FormatConfig = new() + { + DebugConsoleFormat = new LogFormatBuilder().DateTime().Text(" ").LogSeverity(padding: -6).FilePath().Text(":").MemberName().Text(":").LineNumber().Text(Environment.NewLine).Message(), + FileFormat = new LogFormatBuilder().DateTime().Text(" ").LogSeverity(padding: -6).FilePath().Text(":").MemberName().Text(":").LineNumber().Text(Environment.NewLine).Message(), + } + } + }; + public static Stack BackHistory { get; } = new(); public static Stack ForwardHistory { get; } = new(); diff --git a/src/Markdowser/Utilities/HttpPathResolver.cs b/src/Markdowser/Utilities/HttpPathResolver.cs index d959dff..a121f17 100644 --- a/src/Markdowser/Utilities/HttpPathResolver.cs +++ b/src/Markdowser/Utilities/HttpPathResolver.cs @@ -10,7 +10,6 @@ using Markdowser.Models; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Net.Http; using System.Threading.Tasks; @@ -40,7 +39,7 @@ public class HttpPathResolver : IPathResolver relativeOrAbsolutePath = new Uri(new Uri(GlobalState.Url), relativeOrAbsolutePath).ToString(); } - Debug.WriteLine($"Resolving image: {relativeOrAbsolutePath}"); + GlobalState.Logger.LogDebug($"Resolving image: {relativeOrAbsolutePath}"); HttpResponseMessage httpResponseMessage; @@ -80,5 +79,7 @@ public class HttpPathResolver : IPathResolver { Dispatcher.UIThread.Post(() => vm.WindowNotificationManager.Show(new Notification("Error", message, NotificationType.Error))); } + + GlobalState.Logger.LogError(message); } } \ No newline at end of file diff --git a/src/Markdowser/ViewModels/ContentViewModelBase.cs b/src/Markdowser/ViewModels/ContentViewModelBase.cs index 95f0edf..13e720a 100644 --- a/src/Markdowser/ViewModels/ContentViewModelBase.cs +++ b/src/Markdowser/ViewModels/ContentViewModelBase.cs @@ -1,6 +1,12 @@ -namespace Markdowser.ViewModels; +using CuteUtils.Logging; + +using Markdowser.Utilities; + +namespace Markdowser.ViewModels; public abstract class ContentViewModelBase(string title) { public string Title { get; } = title; + + public Logger Logger { get; } = GlobalState.Logger; } \ No newline at end of file diff --git a/src/Markdowser/ViewModels/MainWindowViewModel.cs b/src/Markdowser/ViewModels/MainWindowViewModel.cs index a26e6a2..00a53e6 100644 --- a/src/Markdowser/ViewModels/MainWindowViewModel.cs +++ b/src/Markdowser/ViewModels/MainWindowViewModel.cs @@ -12,7 +12,6 @@ using ReactiveUI; using System; using System.Collections.ObjectModel; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Reflection; @@ -48,7 +47,7 @@ public partial class MainWindowViewModel : ViewModelBase _ = this.RaiseAndSetIfChanged(ref currentTab!, value); - Url = currentTab?.Tag?.ToString() ?? string.Empty; + Url = value?.Tag?.ToString() ?? string.Empty; FetchUrl(true); } @@ -134,18 +133,32 @@ public partial class MainWindowViewModel : ViewModelBase public ICommand CloseTab => ReactiveCommand.Create(() => { + if (IsBusy) + { + WindowNotificationManager.Show(new Notification("Busy", "The browser is currently busy.", NotificationType.Warning)); + return; + } + if (Tabs.Count > 1) { int currentIndex = Tabs.IndexOf(CurrentTab); - _ = Tabs.Remove(CurrentTab); CurrentTab = currentIndex > 0 ? Tabs[currentIndex - 1] : Tabs[0]; + + Tabs.RemoveAt(currentIndex); + this.RaisePropertyChanged(nameof(CloseTabEnabled)); } }); public ICommand NewTab => ReactiveCommand.Create(() => { + if (IsBusy) + { + WindowNotificationManager.Show(new Notification("Busy", "The browser is currently busy.", NotificationType.Warning)); + return; + } + TabItem tab = new() { Header = "New Tab", Name = Guid.NewGuid().ToString() }; Tabs.Add(tab); CurrentTab = tab; @@ -163,7 +176,6 @@ public partial class MainWindowViewModel : ViewModelBase public MainWindowViewModel() { - httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"{nameof(Markdowser)}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"); httpClient.Timeout = TimeSpan.FromSeconds(10); content = DefaultContent; @@ -209,7 +221,7 @@ public partial class MainWindowViewModel : ViewModelBase if (string.IsNullOrWhiteSpace(Url)) { Content = DefaultContent; - Debug.WriteLine("URL is empty."); + Logger.LogDebug("URL is empty."); CurrentTab.Header = "New Tab"; return; } @@ -234,12 +246,19 @@ public partial class MainWindowViewModel : ViewModelBase } } + httpClient.DefaultRequestHeaders.UserAgent.Clear(); + if (!httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(Settings.Current.UserAgent)) + { + WindowNotificationManager.Show(new Notification("Invalid User Agent", "Failed to set user agent.", NotificationType.Error)); + return; + } + IsBusy = true; Progress = 0; _ = Task.Run(async () => { - Debug.WriteLine("Fetching URL..."); + Logger.LogInfo($"Fetching URL: {Url}"); try { @@ -279,6 +298,8 @@ public partial class MainWindowViewModel : ViewModelBase } Dispatcher.UIThread.Post(() => CurrentTab.Header = Content.Title); + + Logger.LogInfo($"Fetched URL: {Url}"); } catch (HttpRequestException e) { @@ -298,6 +319,8 @@ public partial class MainWindowViewModel : ViewModelBase _ = errorMessage.AppendLine($"Failed to fetch URL: {e.Message}"); Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); + + Logger.LogError(e.Message); } catch (Exception e) { @@ -308,6 +331,8 @@ public partial class MainWindowViewModel : ViewModelBase Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); Dispatcher.UIThread.Post(() => CurrentTab.Header = $"Error: {e.GetType().Name}"); + + Logger.LogError(e.Message); } finally { diff --git a/src/Markdowser/ViewModels/SettingsViewModel.cs b/src/Markdowser/ViewModels/SettingsViewModel.cs index 7d80466..b4d9d9c 100644 --- a/src/Markdowser/ViewModels/SettingsViewModel.cs +++ b/src/Markdowser/ViewModels/SettingsViewModel.cs @@ -16,6 +16,12 @@ public class SettingsViewModel : ViewModelBase set => Settings.Current.SearchEngineUrl = value; } + public string UserAgent + { + get => Settings.Current.UserAgent; + set => Settings.Current.UserAgent = value; + } + public bool DarkMode { get => Settings.Current.DarkMode; diff --git a/src/Markdowser/ViewModels/ViewModelBase.cs b/src/Markdowser/ViewModels/ViewModelBase.cs index 5c0baf0..41bcadd 100644 --- a/src/Markdowser/ViewModels/ViewModelBase.cs +++ b/src/Markdowser/ViewModels/ViewModelBase.cs @@ -2,10 +2,14 @@ using Avalonia.Controls.Notifications; using Avalonia.Platform; +using CuteUtils.Logging; + using Markdowser.Models; +using Markdowser.Utilities; using ReactiveUI; +using System.Diagnostics; using System.Reflection; namespace Markdowser.ViewModels; @@ -13,12 +17,30 @@ namespace Markdowser.ViewModels; public class ViewModelBase : ReactiveObject { private WindowNotificationManager? windowNotificationManager; - public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"; + + public string Title + { + get + { + string title = nameof(Markdowser); + Assembly assembly = Assembly.GetExecutingAssembly(); + FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location); + string? version = fileVersionInfo.ProductVersion; + +#if DEBUG + return $"{title} - Dev {version}"; +#else + return $"{title} - {version}"; +#endif + } + } public WindowIcon Icon => Settings.Current.DarkMode ? new WindowIcon(AssetLoader.Open(new("avares://Markdowser/Assets/Markdowser-Dark-Transparent.ico"))) : new WindowIcon(AssetLoader.Open(new("avares://Markdowser/Assets/Markdowser-Light-Transparent.ico"))); public WindowNotificationManager WindowNotificationManager => windowNotificationManager!; + public Logger Logger => GlobalState.Logger; + protected internal void InitializeWindowNotificationManager(Window window) { TopLevel? topLevel = TopLevel.GetTopLevel(window); diff --git a/src/Markdowser/Views/Content/MarkdownContentView.axaml b/src/Markdowser/Views/Content/MarkdownContentView.axaml index 2627761..3dfdc1d 100644 --- a/src/Markdowser/Views/Content/MarkdownContentView.axaml +++ b/src/Markdowser/Views/Content/MarkdownContentView.axaml @@ -26,7 +26,7 @@ - +