From 3e104176fc2af3eb4772acabacaa42ba16d95db6 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sat, 17 Oct 2026 23:53:09 +0900 Subject: [PATCH 1/3] calibre downloads on macOS and Linux come from calibre's own server calibre's GitHub releases keep files only for the newest version: when 9.16.0 came out, the pinned 9.15.0 .txz and .dmg links there started returning 404, so installing calibre failed on macOS and Linux. download.calibre-ebook.com keeps every version; the files are the same (sizes and SHA-256 checked). Co-Authored-By: Claude Opus 5.5 --- src/Filee.Engines/Infrastructure/engines.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Filee.Engines/Infrastructure/engines.json b/src/Filee.Engines/Infrastructure/engines.json index b2657805..a9bb5511 100644 --- a/src/Filee.Engines/Infrastructure/engines.json +++ b/src/Filee.Engines/Infrastructure/engines.json @@ -105,7 +105,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-x86_64.txz", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0-x86_64.txz", "sha256": "3F5301C0AA51E5FB2D5F6DCD04024BA4E86501AB328CE5D9D6760EFCCB887990", "size": 194998300, "kind": "tar.xz" @@ -144,7 +144,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0-arm64.txz", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0-arm64.txz", "sha256": "15AB887F9D787809BC834CA0EA6408E981B409F0B23046C41C18DE948EB50A38", "size": 182488416, "kind": "tar.xz" @@ -176,7 +176,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0.dmg", "sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019", "size": 350293492, "kind": "dmg" @@ -215,7 +215,7 @@ }, "calibre": { "version": "9.15.0", - "url": "https://github.com/kovidgoyal/calibre/releases/download/v9.15.0/calibre-9.15.0.dmg", + "url": "https://download.calibre-ebook.com/9.15.0/calibre-9.15.0.dmg", "sha256": "1B3A7451175B73AE2BAA28FE1BC1C53EE34D65B489881E7EC02C20F9E0FF6019", "size": 350293492, "kind": "dmg" From 42c00e2c5872f68efbad633b037971ea4d955cab Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 18 Oct 2026 12:52:08 +0900 Subject: [PATCH 2/3] Explain and fix a Filee that Explorer can't reach (#99) A Filee running as administrator (started by an installer run elevated) ignores drops from Explorer, can't read Explorer's selection, and the Filee that Explorer's menu starts crashed on the elevated Filee's mutex or waited 30 s on its pipe, all without a log line. - Read Filee's and Explorer's integrity levels at start-up. When Filee runs elevated and Explorer doesn't: log a warning, show a banner on the Home page and a one-time notice card, both with "Restart without administrator rights" (relaunch through Explorer's shell, after releasing the single-instance lock; a failed restart says so). - A later launch that can't open the lock or is refused by the pipe logs it and shows one message box (once per burst of launches); the pipe refusal no longer waits the whole 30 s. - Log unhandled exceptions (any thread, unobserved tasks, UI thread) through a logger set up before anything else, and one start-up line with version, path, install kind, integrity levels, OS and argument count. - UI watchdog: logs a UI thread blocked for more than 5 s and when it answers again. - Rate-limited diagnostics: forwarded launches received and handled, drag gestures that never reached the donut, shortcuts that found no selection (with the foreground window), the first drag over the drop zone. - FFmpeg -version / -encoders time out for real (background reads, kill on timeout); engine statuses are checked once in the background at start-up; unreachable network shares on PATH are skipped after 2 s. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/App.axaml.cs | 48 ++- src/Filee.App/Assets/i18n/en.json | 7 + src/Filee.App/Assets/i18n/ko.json | 7 + src/Filee.App/Assets/i18n/zh-CN.json | 7 + src/Filee.App/Program.cs | 130 +++++++- src/Filee.App/Services/AppHost.cs | 1 + src/Filee.App/Services/ElevationService.cs | 81 +++++ src/Filee.App/Services/FileLogger.cs | 9 +- src/Filee.App/Services/LocalizationService.cs | 30 +- src/Filee.App/Services/LogThrottle.cs | 37 +++ src/Filee.App/Services/RadialController.cs | 12 + src/Filee.App/Services/SingleInstance.cs | 64 +++- src/Filee.App/Services/StartupDiagnostics.cs | 56 ++++ src/Filee.App/Services/UiWatchdog.cs | 86 ++++++ src/Filee.App/Services/WindowService.cs | 11 + .../ViewModels/Pages/HomePageViewModel.cs | 23 +- .../Views/ElevationNoticeWindow.axaml | 38 +++ .../Views/ElevationNoticeWindow.axaml.cs | 51 ++++ src/Filee.App/Views/Pages/HomePage.axaml | 15 + src/Filee.App/Views/Pages/HomePage.axaml.cs | 12 +- src/Filee.App/Views/RadialWindow.axaml.cs | 4 + src/Filee.Core/Conversion/ConverterCatalog.cs | 43 ++- src/Filee.Core/Platform/ElevationState.cs | 57 ++++ src/Filee.Core/Platform/IPlatformServices.cs | 6 + .../Infrastructure/EngineEnvironment.cs | 42 ++- src/Filee.Engines/Media/FfmpegConverter.cs | 50 ++- src/Filee.Platform.Windows/Elevation.cs | 251 +++++++++++++++ src/Filee.Platform.Windows/NativeMethods.cs | 41 +++ .../WindowsPlatformServices.cs | 11 + tests/Filee.App.Tests/DiagnosticsTests.cs | 288 ++++++++++++++++++ tests/Filee.App.Tests/SingleInstanceTests.cs | 69 +++++ .../ElevationAndCatalogTests.cs | 111 +++++++ tests/Filee.Engines.Tests/EngineProbeTests.cs | 56 ++++ 33 files changed, 1705 insertions(+), 49 deletions(-) create mode 100644 src/Filee.App/Services/ElevationService.cs create mode 100644 src/Filee.App/Services/LogThrottle.cs create mode 100644 src/Filee.App/Services/StartupDiagnostics.cs create mode 100644 src/Filee.App/Services/UiWatchdog.cs create mode 100644 src/Filee.App/Views/ElevationNoticeWindow.axaml create mode 100644 src/Filee.App/Views/ElevationNoticeWindow.axaml.cs create mode 100644 src/Filee.Core/Platform/ElevationState.cs create mode 100644 src/Filee.Platform.Windows/Elevation.cs create mode 100644 tests/Filee.App.Tests/DiagnosticsTests.cs create mode 100644 tests/Filee.Core.Tests/ElevationAndCatalogTests.cs create mode 100644 tests/Filee.Engines.Tests/EngineProbeTests.cs diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 9ad8ebec..7d9d39b8 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -22,6 +22,9 @@ public partial class App : Application private IDisposable? _convertDebounce; private bool _selectionPending; + /// Forwarded launches and empty selections can come in bursts: a few lines a minute are enough. + private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30)); + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() @@ -34,6 +37,9 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// Ends Filee as the tray's Quit does. + public static void Quit() => (Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); + private void Start(IClassicDesktopStyleApplicationLifetime desktop) { // macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while @@ -42,12 +48,20 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) Filee.Platform.MacOS.MacOSServicesProvider.Register(files => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files])))); var log = AppHost.Get>(); + // Exceptions in UI event handlers end Filee; write them down first (they are not marked handled). + Dispatcher.UIThread.UnhandledException += (_, e) => + log.LogCritical(e.Exception, "Unhandled exception on the UI thread"); + var watchdog = new UiWatchdog(action => Dispatcher.UIThread.Post(action), AppHost.Get>()); + watchdog.Start(); var store = AppHost.Get(); store.Load(); var loc = AppHost.Get(); loc.Attach(this); ApplySettings(store.Settings); + // Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts + // every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile. + _ = AppHost.Get().CheckInBackgroundAsync(); // Global gestures → donut toolbar var triggers = AppHost.Get(); @@ -66,6 +80,11 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) ? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection(); if (files.Count > 0 && !store.Settings.Paused) radial.ShowForFiles(e.X, e.Y, files); + else if (files.Count == 0 && Throttle.TryEnter("empty-selection", out var held)) + log.LogInformation("The selection shortcut found no selected files in {Window}{Elevation}{Held}", + platform.DescribeForegroundWindow() is { Length: > 0 } window ? window : "the foreground window", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator and can't read Explorer)" : "", + LogThrottle.HeldText(held)); } catch (Exception ex) when (ex is not OutOfMemoryException) { @@ -104,13 +123,12 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) // Later launches (context menu, Send To, double-clicking the exe) forward their arguments here. if (Program.Instance is { } instance) - { - instance.ArgumentsReceived += args => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args))); - instance.StartListening(); - } + ListenForLaterLaunches(instance, log); + Program.InstanceAcquired += again => ListenForLaterLaunches(again, log); desktop.Exit += (_, _) => { + watchdog.Dispose(); AppHost.Get().Dispose(); AppHost.Get().Dispose(); AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); @@ -118,6 +136,21 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) HandleCommandLine(Program.Options, firstLaunch: true); WatchForUpdates(store); + // Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it): + // say so once, next to the tray, and on the Home page for as long as it lasts. + if (AppHost.Get().ShellCannotReachFilee) + DispatcherTimer.RunOnce(() => AppHost.Get().ShowElevationNotice(), TimeSpan.FromSeconds(2)); + } + + private void ListenForLaterLaunches(SingleInstance instance, ILogger log) + { + instance.ArgumentsReceived += args => + { + if (Throttle.TryEnter("forwarded", out var held)) + log.LogInformation("A later launch handed over {Count} argument(s){Held}", args.Length, LogThrottle.HeldText(held)); + Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args), forwarded: true)); + }; + instance.StartListening(); } /// @@ -193,8 +226,13 @@ private static void ApplySettings(AppSettings settings) AppHost.Get().Apply(settings); } - private void HandleCommandLine(CommandLine options, bool firstLaunch = false) + private void HandleCommandLine(CommandLine options, bool firstLaunch = false, bool forwarded = false) { + // Pairs with "A later launch handed over …": the request reached the UI thread too. + if (forwarded && Throttle.TryEnter("forwarded-handled", out var held)) + AppHost.Get>().LogInformation("Handling a later launch: {Request}{Held}", + options.Quit ? "quit" : options.ConvertFiles.Count > 0 ? $"convert {options.ConvertFiles.Count} file(s)" : "show Filee", + LogThrottle.HeldText(held)); if (options.Quit) { // The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this). diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index df514816..a9e58ed2 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -25,6 +25,13 @@ "home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.", "home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.", "home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility.\nIt works as soon as you switch it on.", + "home.elevated": "Filee is running as administrator, so Explorer can't drop files on it or use its menu.\nThe shortcut can't read Explorer's selection either.\nRestart Filee without administrator rights to fix this.", + "home.elevated_restart": "Restart without administrator rights", + "home.elevated_restart_failed": "Filee couldn't restart itself.\nQuit Filee from the tray, then start it again.", + "elevation.notice_title": "Filee is running as administrator", + "elevation.notice_text": "Explorer can't drop files on Filee or use its menu while it runs this way.\nRestart it without administrator rights.", + "startup.elevated_other": "Another Filee is running as administrator.\nExplorer can't hand files to it.\nQuit Filee from the tray, then start it again.", + "startup.no_answer": "Filee is running but didn't answer.\nIf it runs as administrator, quit it from the tray and start it again.", "home.recent": "Recent conversions", "home.recent_empty": "Your converted files will show up here.", "home.open_folder": "Open folder", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 911d0f3b..2fca389d 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -25,6 +25,13 @@ "home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요.\n켜는 즉시 동작해요.", + "home.elevated": "Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n단축키도 탐색기에서 선택한 파일을 읽지 못해요.\n관리자 권한 없이 다시 시작해 주세요.", + "home.elevated_restart": "관리자 권한 없이 다시 시작", + "home.elevated_restart_failed": "Filee를 다시 시작하지 못했어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.", + "elevation.notice_title": "Filee가 관리자 권한으로 실행 중이에요", + "elevation.notice_text": "이 상태에서는 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n관리자 권한 없이 다시 시작해 주세요.", + "startup.elevated_other": "다른 Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기가 파일을 넘겨줄 수 없어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.", + "startup.no_answer": "Filee가 실행 중이지만 응답하지 않아요.\n관리자 권한으로 실행 중이라면 트레이에서 종료한 뒤 다시 실행해 주세요.", "home.recent": "최근 변환", "home.recent_empty": "변환한 파일이 여기에 표시됩니다.", "home.open_folder": "폴더 열기", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index d6f4f5b8..04414b37 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -25,6 +25,13 @@ "home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。\n打开后立即生效。", + "home.elevated": "Filee 正以管理员身份运行。\n因此无法从资源管理器拖放文件,也无法使用右键菜单。\n快捷键也无法读取资源管理器中选中的文件。\n请以非管理员身份重新启动 Filee。", + "home.elevated_restart": "以非管理员身份重新启动", + "home.elevated_restart_failed": "Filee 无法自行重新启动。\n请从托盘退出 Filee,然后再次启动。", + "elevation.notice_title": "Filee 正以管理员身份运行", + "elevation.notice_text": "此时无法从资源管理器拖放文件,也无法使用右键菜单。\n请以非管理员身份重新启动。", + "startup.elevated_other": "另一个 Filee 正以管理员身份运行。\n资源管理器无法把文件交给它。\n请从托盘退出 Filee,然后再次启动。", + "startup.no_answer": "Filee 正在运行,但没有响应。\n如果它以管理员身份运行,请从托盘退出,然后再次启动。", "home.recent": "最近的转换", "home.recent_empty": "转换后的文件会显示在这里。", "home.open_folder": "打开文件夹", diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index 45d9e23c..bcd7fa63 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -1,6 +1,8 @@ // Entry point. Order matters: +// 0. The log and the handlers for unhandled exceptions, so even a start that fails leaves a line; then one line +// describing this launch (version, path, rights, OS). // 1. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments -// to the running instance and exits. +// to the running instance and exits. When it can't (the running Filee is elevated, or hung) it says so. // 2. Installer commands (--quit, --uninstall-cleanup) run without any window. // 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit). @@ -9,6 +11,10 @@ using Avalonia.Controls; using Avalonia.Media; using Filee.App.Services; +using Filee.Core.Platform; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Filee.App; @@ -26,20 +32,67 @@ internal static class Program /// How long a later launch waits for a Filee that is still starting to take its arguments. private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(30); + /// Integrity levels of this process and Explorer, read at start-up (Unknown off Windows). + public static ElevationState Elevation { get; private set; } = ElevationState.Unknown; + + /// Program's own log, set up before anything else so even a failed start leaves a line. + internal static ILogger Log { get; private set; } = NullLogger.Instance; + + /// Raised on the UI thread when the single-instance lock was taken again after a failed restart. + public static event Action? InstanceAcquired; + [STAThread] public static int Main(string[] args) { Options = CommandLine.Parse(args); - Instance = SingleInstance.Acquire(); - if (Options.Quit || Options.UninstallCleanup) - return RunInstallerCommand(Instance); - if (!Instance.IsFirst) + Log = StartupDiagnostics.CreateEarlyLogger(Path.Combine(UserDataStore.DefaultDirectory, "logs")); + StartupDiagnostics.LogUnhandledExceptions(Log); + Elevation = Filee.Platform.Windows.Elevation.Probe(); + var startup = StartupDiagnostics.Describe(UpdateService.CurrentVersion, Environment.ProcessPath, + StartupDiagnostics.InstallKind(UpdateService.IsInstalled, UpdateService.IsReleaseBuild), Elevation, + StartupDiagnostics.OperatingSystemText(), args.Length); + var installerCommand = Options.Quit || Options.UninstallCleanup; + + SingleInstance instance; + try { - Instance.ForwardToFirst(args, ForwardTimeout); - Instance.Dispose(); - return 0; + instance = SingleInstance.Acquire(); + } + catch (UnauthorizedAccessException ex) + { + // The lock belongs to a Filee running as administrator: this process may not even open it. Before this + // was caught, Filee ended here without a word (Explorer's menu showed a busy cursor, then nothing). + Log.LogWarning("{Startup}", startup); + Log.LogWarning(ex, "Another Filee is running with administrator rights; this launch can't reach it"); + if (!installerCommand) + TellUserOnce("startup.elevated_other"); + return 1; + } + Instance = instance; + if (installerCommand) + { + Log.LogInformation("{Startup}", startup); + return RunInstallerCommand(instance); + } + if (!instance.IsFirst) + { + var result = instance.Forward(args, ForwardTimeout); + if (result != ForwardResult.Delivered) + { + // Logged only when it failed: Explorer starts one Filee per selected file. + Log.LogWarning("{Startup}", startup); + Log.LogWarning("Could not hand {Count} argument(s) to the running Filee: {Result}", args.Length, result); + TellUserOnce(result == ForwardResult.AccessDenied ? "startup.elevated_other" : "startup.no_answer"); + } + ReleaseInstance(); + return result == ForwardResult.Delivered ? 0 : 1; } + Log.LogInformation("{Startup}", startup); + if (Elevation.ShellCannotReachFilee) + Log.LogWarning("Filee runs as administrator ({Elevation}) while Explorer doesn't: Explorer can't drop files on it, " + + "use its menu or have its selection read", Elevation); + try { return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown); @@ -55,7 +108,66 @@ public static int Main(string[] args) } finally { - Instance.Dispose(); + ReleaseInstance(); + } + } + + /// Lets go of the single-instance lock (at exit, or before restarting without administrator rights). + public static void ReleaseInstance() + { + Instance?.Dispose(); + Instance = null; + } + + /// Takes the lock back after a restart that didn't happen, so later launches find this Filee again. + public static void ReacquireInstance() + { + if (Instance is not null) + return; + try + { + var instance = SingleInstance.Acquire(); + if (!instance.IsFirst) + { + // Another Filee started meanwhile (perhaps the restart did happen after all): it is the running one. + Log.LogWarning("Another Filee took the single-instance lock during the restart"); + instance.Dispose(); + return; + } + Instance = instance; + InstanceAcquired?.Invoke(instance); + } + catch (UnauthorizedAccessException ex) + { + Log.LogWarning(ex, "Could not take the single-instance lock back"); + } + } + + /// + /// Shows a launch problem in a system message box (Filee's own windows aren't available in this process). Explorer + /// starts one Filee per selected file: a lock shared between them lets only the first one show it. + /// + private static void TellUserOnce(string key) + { + if (!OperatingSystem.IsWindows()) + return; + try + { + using var shown = new Mutex(initiallyOwned: true, @"Local\" + SingleInstance.DefaultName + "-notice", out var createdNew); + if (!createdNew) + return; + try + { + Filee.Platform.Windows.Elevation.ShowWarning(LocalizationService.EarlyText(key)); + } + finally + { + shown.ReleaseMutex(); + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException) + { + Log.LogWarning(ex, "Could not show the launch problem"); } } diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 5b03168f..f5bbd380 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -44,6 +44,7 @@ public static IServiceProvider Build(string? dataDirectory = null) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Conversion engines services.AddSingleton(_ => new EngineEnvironment(dataDir)); diff --git a/src/Filee.App/Services/ElevationService.cs b/src/Filee.App/Services/ElevationService.cs new file mode 100644 index 00000000..33291e8c --- /dev/null +++ b/src/Filee.App/Services/ElevationService.cs @@ -0,0 +1,81 @@ +// Filee running as administrator while Explorer doesn't (see ElevationState): the Home page shows why gestures, drops +// and the Explorer menu do nothing, and offers to restart Filee with Explorer's rights. The restart lets go of the +// single-instance lock first, so the new Filee becomes the running one instead of handing its arguments to this one +// (which it couldn't, being refused by an elevated process anyway), then quits this Filee. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// Elevation state of this run and the restart without administrator rights. +public sealed partial class ElevationService : ObservableObject +{ + private readonly ILogger _log; + private readonly Func _relaunch; + private readonly Action _releaseInstance; + private readonly Action _reacquireInstance; + private readonly Action _shutdown; + + /// The app's service: the state Program read at start-up, the real relaunch and shutdown. + public ElevationService(ILogger log) : this(log, Program.Elevation, RelaunchThroughShell, + Program.ReleaseInstance, Program.ReacquireInstance, App.Quit) + { + } + + /// Starts the executable with Explorer's rights; returns null when started, else why not. + /// Lets go of the single-instance lock. + /// Takes the lock back when the restart failed. + /// Ends this Filee. + internal ElevationService(ILogger log, ElevationState state, Func relaunch, + Action releaseInstance, Action reacquireInstance, Action shutdown) + { + _log = log; + State = state; + _relaunch = relaunch; + _releaseInstance = releaseInstance; + _reacquireInstance = reacquireInstance; + _shutdown = shutdown; + } + + public ElevationState State { get; } + + /// Explorer can't drop files on Filee, use its menu, or have its selection read: show the warning. + public bool ShellCannotReachFilee => State.ShellCannotReachFilee; + + /// The last restart attempt failed (the Home page says so). + [ObservableProperty] private bool _restartFailed; + + /// + /// Starts Filee again through Explorer and quits this one. Returns false (and sets ) + /// when Explorer couldn't start it; this Filee then keeps running as before. + /// + public bool RestartWithoutAdministratorRights(string? executable = null) + { + executable ??= Environment.ProcessPath; + if (string.IsNullOrEmpty(executable)) + { + _log.LogWarning("Restart without administrator rights: the path of Filee.exe is unknown"); + RestartFailed = true; + return false; + } + + _log.LogInformation("Restarting without administrator rights through Explorer"); + _releaseInstance(); + var error = _relaunch(executable); + if (error is null) + { + RestartFailed = false; + _shutdown(); + return true; + } + _log.LogWarning("Restart without administrator rights failed: {Error}", error); + _reacquireInstance(); + RestartFailed = true; + return false; + } + + private static string? RelaunchThroughShell(string executable) => + Filee.Platform.Windows.Elevation.RelaunchThroughShell(executable, "", out var error) ? null : error ?? "unknown error"; +} diff --git a/src/Filee.App/Services/FileLogger.cs b/src/Filee.App/Services/FileLogger.cs index 4de17498..6a26a98e 100644 --- a/src/Filee.App/Services/FileLogger.cs +++ b/src/Filee.App/Services/FileLogger.cs @@ -6,8 +6,9 @@ namespace Filee.App.Services; public sealed class FileLoggerProvider : ILoggerProvider { + // Shared by every provider: Program's early logger and AppHost's write to the same file. + private static readonly Lock WriteLock = new(); private readonly string _directory; - private readonly object _lock = new(); public FileLoggerProvider(string directory) { @@ -16,7 +17,7 @@ public FileLoggerProvider(string directory) foreach (var old in Directory.EnumerateFiles(directory, "filee-*.log") .Where(f => File.GetLastWriteTime(f) < DateTime.Now.AddDays(-7))) { - try { File.Delete(old); } catch (IOException) { } + try { File.Delete(old); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } @@ -26,13 +27,13 @@ public void Dispose() { } private void Write(string line) { - lock (_lock) + lock (WriteLock) { try { File.AppendAllText(Path.Combine(_directory, $"filee-{DateTime.Now:yyyyMMdd}.log"), line + Environment.NewLine); } - catch (IOException) { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs index f09685a0..9d1dbf83 100644 --- a/src/Filee.App/Services/LocalizationService.cs +++ b/src/Filee.App/Services/LocalizationService.cs @@ -100,13 +100,35 @@ public static string Resolve(string setting) return "en"; } - /// Reads a translation file. Missing files yield an empty dictionary (English is used instead). - internal static Dictionary LoadStrings(string code) + /// + /// Reads a translation file. Missing files yield an empty dictionary (English is used instead). + /// reads assets before Avalonia is set up (a message shown by Program). + /// + internal static Dictionary LoadStrings(string code, IAssetLoader? loader = null) { var uri = new Uri($"avares://Filee/Assets/i18n/{code}.json"); - if (!AssetLoader.Exists(uri)) + if (!(loader?.Exists(uri) ?? AssetLoader.Exists(uri))) return []; - using var stream = AssetLoader.Open(uri); + using var stream = loader?.Open(uri) ?? AssetLoader.Open(uri); return JsonSerializer.Deserialize(stream, AppJsonContext.Default.DictionaryStringString) ?? []; } + + /// + /// One string in the system's language, before Avalonia (and the settings) are loaded: for the messages a launch + /// shows when it can't reach the running Filee. Falls back to English, then to the key. + /// + internal static string EarlyText(string key) + { + try + { + var loader = new StandardAssetLoader(typeof(LocalizationService).Assembly); + var code = Resolve("auto"); + if (LoadStrings(code, loader).TryGetValue(key, out var text) || LoadStrings("en", loader).TryGetValue(key, out text)) + return text; + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException or ArgumentException) + { + } + return key; + } } diff --git a/src/Filee.App/Services/LogThrottle.cs b/src/Filee.App/Services/LogThrottle.cs new file mode 100644 index 00000000..d539fecb --- /dev/null +++ b/src/Filee.App/Services/LogThrottle.cs @@ -0,0 +1,37 @@ +// Keeps diagnostics that can come in bursts to a few log lines: Explorer starts one Filee per selected file, and a +// gesture or shortcut can repeat many times a minute. The first event of a kind is logged, then at most one per +// interval, saying how many were held back in between. + +namespace Filee.App.Services; + +/// Rate limit for log lines, per kind of event. Thread-safe. +internal sealed class LogThrottle(TimeSpan interval, TimeProvider? time = null) +{ + private readonly TimeProvider _time = time ?? TimeProvider.System; + private readonly Dictionary _kinds = new(StringComparer.Ordinal); + private readonly Lock _lock = new(); + + /// + /// True when an event of kind may be logged now. is how many of + /// that kind were held back since the last one logged (0 when it returns false). + /// + public bool TryEnter(string kind, out int held) + { + lock (_lock) + { + var now = _time.GetUtcNow(); + if (_kinds.TryGetValue(kind, out var entry) && now - entry.Last < interval) + { + _kinds[kind] = (entry.Last, entry.Held + 1); + held = 0; + return false; + } + held = entry.Held; + _kinds[kind] = (now, 0); + return true; + } + } + + /// " (3 more since the last one)", or "" when none were held back. + public static string HeldText(int held) => held > 0 ? $" ({held} more since the last one)" : ""; +} diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 202d9bc4..789759aa 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -46,6 +46,10 @@ public sealed class RadialController( private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder" private IDisposable? _handOffTimer; private int _dragGeneration; + private bool _dragReachedDonut; + + /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then. + private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1)); /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -69,6 +73,7 @@ public void ShowForDrag(int x, int y) // A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up. window.DonutControl.LeaveProgressMode(); _dropped = false; + _dragReachedDonut = false; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -101,6 +106,12 @@ public void OnDragGestureEnded() { if (!_visible || viewModel.IsClickMode) return; + // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept + // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart. + if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held)) + log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "", + LogThrottle.HeldText(held)); // Nothing was brought to the donut: the window is invisible but catches clicks, so go right away. if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode) { @@ -154,6 +165,7 @@ private RadialWindow EnsureWindow() var window = new RadialWindow { DataContext = viewModel }; var donut = window.DonutControl; + window.DragEntered += () => _dragReachedDonut = true; window.FilesEntered += files => { if (viewModel.IsClickMode || SameFiles(files, viewModel.Files)) diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs index 64285de3..4f1c1247 100644 --- a/src/Filee.App/Services/SingleInstance.cs +++ b/src/Filee.App/Services/SingleInstance.cs @@ -10,7 +10,7 @@ namespace Filee.App.Services; public sealed class SingleInstance : IDisposable { // string.GetHashCode() is randomized per process, so a stable hash is required for both processes to agree. - private static readonly string DefaultName = "Filee-" + Convert.ToHexString( + internal static readonly string DefaultName = "Filee-" + Convert.ToHexString( System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Environment.UserName)))[..12]; private readonly Mutex _mutex; private readonly string _name; @@ -67,6 +67,19 @@ public void StartListening() { // A client disconnected half-way; keep serving. } + catch (UnauthorizedAccessException) + { + // The pipe name is still held by a Filee with other rights (one that just restarted this one + // without administrator rights and is closing): try again once it is gone. + try + { + await Task.Delay(500, _cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } } }); } @@ -75,11 +88,19 @@ public void StartListening() /// Sends this process's arguments to the first instance. It only listens once its services are up, which can take /// over ten seconds on a slow or busy computer: a second launch (or the installer's --quit) right after the first /// lost its arguments after one 3-second try. Tries again until has passed; false when the - /// first instance never answered (it is shutting down, or hung). + /// first instance never answered (it is shutting down, or hung) or refused this process. + /// + public bool ForwardToFirst(string[] args, TimeSpan wait) => Forward(args, wait) == ForwardResult.Delivered; + + /// + /// Like , telling why it failed. A pipe that keeps refusing access belongs to a Filee + /// running with other rights (as administrator, while this one isn't): that never changes, so it gives up after a + /// few tries instead of waiting the whole time. /// - public bool ForwardToFirst(string[] args, TimeSpan wait) + public ForwardResult Forward(string[] args, TimeSpan wait) { var deadline = DateTime.UtcNow + wait; + var refusals = 0; while (true) { var left = deadline - DateTime.UtcNow; @@ -89,17 +110,29 @@ public bool ForwardToFirst(string[] args, TimeSpan wait) client.Connect((int)Math.Clamp(left.TotalMilliseconds, 100, 1000)); Filee.Platform.Windows.Foreground.AllowOtherProcess(); // its donut takes the focus (Esc, click away) JsonSerializer.Serialize(client, args, AppJsonContext.Default.StringArray); - return true; + return ForwardResult.Delivered; + } + catch (UnauthorizedAccessException) + { + // Also thrown for a moment while the pipe is being recreated between two launches: try a few times. + if (++refusals >= RefusalsBeforeGivingUp) + return ForwardResult.AccessDenied; + if (DateTime.UtcNow >= deadline) + return ForwardResult.NoAnswer; + Thread.Sleep(100); } - catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or TimeoutException) { if (DateTime.UtcNow >= deadline) - return false; + return ForwardResult.NoAnswer; Thread.Sleep(100); // the pipe is busy with another launch, or not there yet } } } + /// Access-denied answers in a row after which stops trying. + private const int RefusalsBeforeGivingUp = 5; + /// /// Waits until the first instance has exited (it releases the mutex then), e.g. after forwarding --quit. /// Returns false on timeout. @@ -121,8 +154,12 @@ public bool WaitForFirstToExit(TimeSpan timeout) return true; } + /// Stops listening and releases the mutex. Safe to call twice (a restart releases it before shutting down). public void Dispose() { + if (_disposed) + return; + _disposed = true; _cts.Cancel(); if (IsFirst) { @@ -134,4 +171,19 @@ public void Dispose() } _mutex.Dispose(); } + + private bool _disposed; +} + +/// Outcome of . +public enum ForwardResult +{ + /// The running Filee has the arguments. + Delivered, + + /// Nobody took them in time: the running Filee is starting, shutting down or hung. + NoAnswer, + + /// The running Filee's pipe refuses this process: it runs with other rights (as administrator). + AccessDenied, } diff --git a/src/Filee.App/Services/StartupDiagnostics.cs b/src/Filee.App/Services/StartupDiagnostics.cs new file mode 100644 index 00000000..aa6df551 --- /dev/null +++ b/src/Filee.App/Services/StartupDiagnostics.cs @@ -0,0 +1,56 @@ +// What Filee writes to its log before and around start-up: one line describing this launch, and every exception that +// would otherwise end the process (or vanish in a task) without a trace. A report of a Filee that ignored every +// gesture came with an empty log; these lines are what tells such cases apart. + +using System.Runtime.InteropServices; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Services; + +internal static class StartupDiagnostics +{ + /// + /// A logger for Program, before the service container exists. It writes to the same daily file. A log folder that + /// can't be created gives a logger that drops everything: logging must never stop Filee from starting. + /// + public static ILogger CreateEarlyLogger(string logDirectory) + { + try + { + return new FileLoggerProvider(logDirectory).CreateLogger("Filee.App.Program"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return NullLogger.Instance; + } + } + + /// + /// The start-up line: "Filee 1.11.0 starting: C:\…\Filee.exe (installed), integrity Medium, Explorer Medium, + /// Microsoft Windows 10.0.26200 X64, 2 argument(s)". Arguments are counted, not written: they are file paths. + /// + public static string Describe(string version, string? executable, string installKind, ElevationState elevation, + string operatingSystem, int argumentCount) => + $"Filee {version} starting: {executable ?? "(unknown path)"} ({installKind}), {elevation}, {operatingSystem}, " + + $"{argumentCount} argument(s)"; + + /// How this copy got here: Setup, a release archive, or a build from source. + public static string InstallKind(bool installed, bool release) => + installed ? "installed" : release ? "portable" : "development build"; + + /// "Microsoft Windows 10.0.26200 X64". + public static string OperatingSystemText() => + $"{RuntimeInformation.OSDescription.Trim()} {RuntimeInformation.OSArchitecture}"; + + /// Writes exceptions from any thread and unobserved task failures to . + public static void LogUnhandledExceptions(ILogger log) + { + AppDomain.CurrentDomain.UnhandledException += (_, e) => + log.LogCritical(e.ExceptionObject as Exception, "Unhandled exception (process ending: {Terminating}): {Object}", + e.IsTerminating, e.ExceptionObject is Exception ? "see below" : e.ExceptionObject); + TaskScheduler.UnobservedTaskException += (_, e) => + log.LogError(e.Exception, "A background task failed and nobody looked at the error"); + } +} diff --git a/src/Filee.App/Services/UiWatchdog.cs b/src/Filee.App/Services/UiWatchdog.cs new file mode 100644 index 00000000..8d397c18 --- /dev/null +++ b/src/Filee.App/Services/UiWatchdog.cs @@ -0,0 +1,86 @@ +// Notices when the UI thread stops answering. A report said every gesture, shortcut and drop did nothing while the log +// stayed empty; a line saying the UI thread was stuck (and for how long) tells a hang from events that never arrived. +// A background timer posts a ping to the UI thread every second. While a ping waits longer than the threshold, one +// warning is written for that episode; when the ping finally runs, one line says how long the UI thread was gone. + +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// Logs UI thread stalls longer than a threshold, once per episode. +internal sealed class UiWatchdog : IDisposable +{ + /// How often the UI thread is pinged. + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1); + + /// Stalls shorter than this aren't worth a line (a big folder list, a first window). + public static readonly TimeSpan DefaultThreshold = TimeSpan.FromSeconds(5); + + private readonly Action _post; + private readonly ILogger _log; + private readonly TimeProvider _time; + private readonly TimeSpan _threshold; + private readonly Lock _lock = new(); + private DateTimeOffset? _pingSentAt; + private bool _reported; + private ITimer? _timer; + + /// Queues an action on the UI thread (Dispatcher.UIThread.Post in the app). + public UiWatchdog(Action post, ILogger log, TimeProvider? time = null, TimeSpan? threshold = null) + { + _post = post; + _log = log; + _time = time ?? TimeProvider.System; + _threshold = threshold ?? DefaultThreshold; + } + + /// Starts pinging every on a thread-pool timer. + public void Start() => _timer ??= _time.CreateTimer(_ => Tick(), null, Interval, Interval); + + /// One timer tick: sends a ping, or checks how long the last one has been waiting. + internal void Tick() + { + lock (_lock) + { + if (_pingSentAt is { } sent) + { + var waited = _time.GetUtcNow() - sent; + if (!_reported && waited > _threshold) + { + _reported = true; + _log.LogWarning("UI thread blocked for {Seconds:0} s", waited.TotalSeconds); + } + return; + } + _pingSentAt = _time.GetUtcNow(); + } + try + { + _post(Pong); + } + catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException) + { + // The dispatcher is shutting down: nothing left to watch. + lock (_lock) + _pingSentAt = null; + } + } + + /// Runs on the UI thread when it got to the ping. + private void Pong() + { + lock (_lock) + { + if (_reported && _pingSentAt is { } sent) + _log.LogInformation("UI thread responsive again after {Seconds:0.0} s", (_time.GetUtcNow() - sent).TotalSeconds); + _pingSentAt = null; + _reported = false; + } + } + + public void Dispose() + { + _timer?.Dispose(); + _timer = null; + } +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 1c23eec7..be501452 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -130,6 +130,17 @@ public void ShowFeedbackNotice() _feedbackNotice.Show(); } + /// Shows the "Filee is running as administrator" card in the bottom-right corner (once per run). + public void ShowElevationNotice() + { + if (_elevationNoticeShown) + return; + _elevationNoticeShown = true; + new ElevationNoticeWindow(services.GetRequiredService()).Show(); + } + + private bool _elevationNoticeShown; + /// Opens the preset popup. Returns true when the user saved (the preset object was updated). public async Task EditPresetAsync(Preset preset, Window? owner) { diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index 274e7c6d..189d3d26 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -83,17 +83,21 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly IPlatformServices _platform; private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; + private readonly ElevationService? _elevation; public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, - ConversionService? conversions = null) + ConversionService? conversions = null, ElevationService? elevation = null) { _store = store; _loc = loc; _platform = platform; _triggers = triggers; _conversions = conversions; + _elevation = elevation; if (triggers is not null) triggers.StatusChanged += OnTriggerStatusChanged; + if (elevation is not null) + elevation.PropertyChanged += OnElevationChanged; var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag); DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers)); store.HistoryChanged += OnHistoryChanged; @@ -107,10 +111,27 @@ public void Dispose() _store.HistoryChanged -= OnHistoryChanged; if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; + if (_elevation is not null) + _elevation.PropertyChanged -= OnElevationChanged; } private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(NeedsAccessibility)); + private void OnElevationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => + OnPropertyChanged(nameof(ElevationRestartFailed)); + + /// + /// Windows: Filee runs as administrator while Explorer doesn't, so drops, the Explorer menu and the shortcut can't + /// reach it (the warning above the drop zone). + /// + public bool IsElevated => _elevation?.ShellCannotReachFilee == true; + + /// The restart button didn't work: the warning says what to do instead. + public bool ElevationRestartFailed => _elevation?.RestartFailed == true; + + [RelayCommand] + private void RestartWithoutAdministratorRights() => _elevation?.RestartWithoutAdministratorRights(); + /// macOS: gestures wait for the Accessibility permission (the reminder above the drop zone). public bool NeedsAccessibility => _triggers?.NeedsPermission == true; diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml b/src/Filee.App/Views/ElevationNoticeWindow.axaml new file mode 100644 index 00000000..6eaa795a --- /dev/null +++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + private static int _dragEnterLogged; + public HomePage() { InitializeComponent(); DragDrop.AddDragEnterHandler(DropZone, (_, e) => { DropZone.Classes.Add("over"); - e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None; + var files = e.DataTransfer.Contains(DataFormat.File); + e.DragEffects = files ? DragDropEffects.Copy : DragDropEffects.None; + if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0) + AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files); }); DragDrop.AddDragOverHandler(DropZone, (_, e) => e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None); diff --git a/src/Filee.App/Views/RadialWindow.axaml.cs b/src/Filee.App/Views/RadialWindow.axaml.cs index cf7b0407..0cfaab79 100644 --- a/src/Filee.App/Views/RadialWindow.axaml.cs +++ b/src/Filee.App/Views/RadialWindow.axaml.cs @@ -24,6 +24,9 @@ public RadialWindow() public DonutMenu DonutControl => Donut; + /// A drag entered the window, with or without files (for the log: did the drag reach the donut at all). + public event Action? DragEntered; + /// Files entered the window during a drag. public event Action>? FilesEntered; @@ -44,6 +47,7 @@ public RadialWindow() private void OnDragEnter(object? sender, DragEventArgs e) { + DragEntered?.Invoke(); var files = LocalFiles(e); if (files.Count == 0) { diff --git a/src/Filee.Core/Conversion/ConverterCatalog.cs b/src/Filee.Core/Conversion/ConverterCatalog.cs index 584765fc..886923e1 100644 --- a/src/Filee.Core/Conversion/ConverterCatalog.cs +++ b/src/Filee.Core/Conversion/ConverterCatalog.cs @@ -6,7 +6,9 @@ namespace Filee.Core.Conversion; public sealed class ConverterCatalog { private readonly IReadOnlyList _all; - private Dictionary? _statusCache; + private volatile Dictionary? _statusCache; + private int _generation; + private readonly Lock _statusLock = new(); private IReadOnlyList _priority = []; public ConverterCatalog(IEnumerable converters) => _all = converters.ToList(); @@ -36,19 +38,48 @@ public IReadOnlyList Priority _all.OrderBy(c => PriorityIndex(c)).ThenBy(c => _all.ToList().IndexOf(c)).ToList(); /// Cached availability of a converter. - public EngineStatus StatusOf(IConverter converter) - { - _statusCache ??= _all.ToDictionary(c => c.Id, SafeStatus); - return _statusCache[converter.Id]; - } + public EngineStatus StatusOf(IConverter converter) => Statuses()[converter.Id]; /// Re-checks every engine (e.g. after the user changed an engine path). public void Refresh() { + Interlocked.Increment(ref _generation); _statusCache = null; Changed?.Invoke(this, EventArgs.Empty); } + /// + /// Checks every engine on a background thread, so the first conversion menu doesn't wait for them (some start a + /// program to read its version). A caller that needs a status meanwhile waits for this check instead of starting + /// its own. + /// + public Task CheckInBackgroundAsync() => Task.Run(() => Statuses()); + + /// + /// The statuses, checked once and shared between threads. A during a check makes it check + /// again, so a result from before a settings change is never kept. + /// + private Dictionary Statuses() + { + if (_statusCache is { } cached) + return cached; + lock (_statusLock) + { + while (true) + { + if (_statusCache is { } done) + return done; + var generation = Volatile.Read(ref _generation); + var statuses = _all.ToDictionary(c => c.Id, SafeStatus); + if (generation == Volatile.Read(ref _generation)) + { + _statusCache = statuses; + return statuses; + } + } + } + } + /// Creates a planner over the currently available converters. public RoutePlanner CreatePlanner() => new(_all.Where(c => StatusOf(c).IsAvailable), c => Math.Min(PriorityIndex(c), 9)); diff --git a/src/Filee.Core/Platform/ElevationState.cs b/src/Filee.Core/Platform/ElevationState.cs new file mode 100644 index 00000000..26b6a94b --- /dev/null +++ b/src/Filee.Core/Platform/ElevationState.cs @@ -0,0 +1,57 @@ +// Whether Filee runs with more rights than the desktop shell. Windows keeps a lower process (Explorer) from sending +// input to a higher one (User Interface Privilege Isolation): drops from Explorer are refused, the second Filee its +// menu starts can't open the running Filee's mutex or pipe, and the running Filee can't read Explorer's selection. +// The decision is plain logic over integrity levels, so it is tested everywhere; reading the levels is Windows code. + +namespace Filee.Core.Platform; + +/// Windows mandatory integrity levels, lowest first. +public enum IntegrityLevel +{ + /// Not known: another operating system, or the token could not be read. + Unknown, + Untrusted, + Low, + Medium, + MediumPlus, + High, + System, +} + +/// Integrity levels of this process and of the desktop shell (Explorer). +/// This process. +/// The process that owns the desktop window (Explorer), or Unknown when there is none. +/// True when this process runs with a full administrator token (UAC "Run as administrator"). +public sealed record ElevationState(IntegrityLevel Process, IntegrityLevel Shell, bool Elevated) +{ + /// Nothing known (any OS but Windows). + public static ElevationState Unknown { get; } = new(IntegrityLevel.Unknown, IntegrityLevel.Unknown, false); + + /// + /// True when Filee runs as administrator while Explorer doesn't: Explorer can't drop files on Filee or hand files + /// over through its menu. Unknown levels never count, and neither does a PC where Explorer runs elevated too + /// (UAC switched off). + /// + public bool ShellCannotReachFilee => + Process >= IntegrityLevel.High && Shell != IntegrityLevel.Unknown && Shell < Process; + + /// + /// Maps the last sub-authority of a mandatory label SID (S-1-16-rid) to a level. Values between the + /// well-known ones belong to the level below them, as Windows compares them numerically. + /// + public static IntegrityLevel FromRid(uint rid) => rid switch + { + >= 0x4000 => IntegrityLevel.System, + >= 0x3000 => IntegrityLevel.High, + >= 0x2100 => IntegrityLevel.MediumPlus, + >= 0x2000 => IntegrityLevel.Medium, + >= 0x1000 => IntegrityLevel.Low, + _ => IntegrityLevel.Untrusted, + }; + + /// For the log: "integrity High (elevated), Explorer Medium". + public override string ToString() => + Process == IntegrityLevel.Unknown && Shell == IntegrityLevel.Unknown && !Elevated + ? "integrity unknown" + : $"integrity {Process}{(Elevated ? " (elevated)" : "")}, Explorer {Shell}"; +} diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index 7a440a48..5a1b0d9f 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -31,6 +31,12 @@ static IReadOnlyList AppNames(IEnumerable names) /// Files currently selected in the foreground file manager window (empty if none). IReadOnlyList GetFileManagerSelection(); + /// + /// For the log when a shortcut found no selected files: the foreground window's class and process + /// ("CabinetWClass / explorer"). Empty where the platform can't tell. + /// + string DescribeForegroundWindow() => ""; + /// System drag threshold in the same units as screen coordinates. int SystemDragThreshold { get; } diff --git a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs index 77db4ef3..4e28b23f 100644 --- a/src/Filee.Engines/Infrastructure/EngineEnvironment.cs +++ b/src/Filee.Engines/Infrastructure/EngineEnvironment.cs @@ -142,10 +142,48 @@ public static bool UsesFileesCopy(string id) => OwnCopyFolder(id) is null SystemCopyFolder(id) is { } folder ? Path.Combine(folder, ProgramName(program)) : null; /// Searches the PC again (Settings → Engines → Check again, settings changes). - public static void ForgetSystemCopies() => SystemCopies.Clear(); + public static void ForgetSystemCopies() + { + SystemCopies.Clear(); + ReachableShares.Clear(); + } private static string? FindOnSystem(string id) => - SystemFolders(id).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null); + SystemFolders(id).Where(IsReachable).Select(folder => FolderWithPrograms(id, folder)).FirstOrDefault(found => found is not null); + + /// How long a network share on the PATH may take to answer before the search leaves it out. + private static readonly TimeSpan ShareTimeout = TimeSpan.FromSeconds(2); + + private static readonly ConcurrentDictionary ReachableShares = new(StringComparer.OrdinalIgnoreCase); + + /// + /// False for a folder on a network share that doesn't answer in time. A PATH entry on a server that is offline + /// (a mapped work share at home) made every file check wait for the network timeout, many seconds each, and the + /// search runs for every engine. Each share is asked once, until . + /// + private static bool IsReachable(string folder) + { + if (ShareOf(folder) is not { } share) + return true; + return ReachableShares.GetOrAdd(share, s => + { + var probe = Task.Run(() => Directory.Exists(s)); + return probe.Wait(ShareTimeout) && probe.Result; + }); + } + + /// + /// The share root of a UNC path (\\server\share), or null for a local path. Device paths (\\?\, + /// \\.\) are local. Plain string work, the same on every OS. + /// + internal static string? ShareOf(string path) + { + if (!path.StartsWith(@"\\", StringComparison.Ordinal) || path.StartsWith(@"\\?\", StringComparison.Ordinal) + || path.StartsWith(@"\\.\", StringComparison.Ordinal)) + return null; + var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 ? $@"\\{parts[0]}\{parts[1]}" : parts.Length == 1 ? $@"\\{parts[0]}" : null; + } private static IEnumerable SystemFolders(string id) { diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 5bc2877a..05ab40fd 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -175,13 +175,8 @@ private static string VersionOf(string ffmpeg) => RedirectStandardOutput = true, RedirectStandardError = true, }) ?? throw new InvalidOperationException("FFmpeg could not be started."); - var firstLine = process.StandardOutput.ReadLine() ?? ""; - process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(10_000)) - { - process.Kill(entireProcessTree: true); - throw new InvalidOperationException("FFmpeg did not start in time."); - } + var firstLine = (OutputWithin(process, ProbeTimeout) ?? throw new InvalidOperationException("FFmpeg did not start in time.")) + .Split('\n')[0]; if (process.ExitCode != 0) throw new InvalidOperationException($"FFmpeg does not start (exit code {process.ExitCode})."); // "ffmpeg version 9.0.2-full_build-www.gyan.dev Copyright …" → "9.0.2" @@ -212,10 +207,8 @@ private static string VersionOf(string ffmpeg) => }); if (process is null) return null; - process.ErrorDataReceived += (_, _) => { }; - process.BeginErrorReadLine(); - var list = process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(10_000) || process.ExitCode != 0) + var list = OutputWithin(process, ProbeTimeout); + if (list is null || process.ExitCode != 0) return null; // " V....D libx265 libx265 H.265 / HEVC (codec hevc)" return list.Split('\n') @@ -230,6 +223,41 @@ private static string VersionOf(string ffmpeg) => } }); + /// How long -version and -encoders may take (they answer in well under a second). + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10); + + /// + /// Standard output of a started once it has exited, or null (and the process killed) + /// when it doesn't exit within . Both streams are read in the background: reading + /// standard output to its end first waited for ever on an FFmpeg that hung, the timeout never came into play. + /// + internal static string? OutputWithin(Process process, TimeSpan timeout) + { + var output = process.StandardOutput.ReadToEndAsync(); + var errors = process.StandardError.ReadToEndAsync(); // drained, so a full pipe can't stall the program + if (!process.WaitForExit(timeout)) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // Exited meanwhile. + } + return null; + } + // Exited: the streams end now (a grandchild that kept them open must not hold this up either). + try + { + return Task.WhenAll(output, errors).Wait(TimeSpan.FromSeconds(5)) ? output.Result : null; + } + catch (AggregateException) + { + return null; // a pipe broke while reading + } + } + private static void TryDelete(string path) { try diff --git a/src/Filee.Platform.Windows/Elevation.cs b/src/Filee.Platform.Windows/Elevation.cs new file mode 100644 index 00000000..22f2ce53 --- /dev/null +++ b/src/Filee.Platform.Windows/Elevation.cs @@ -0,0 +1,251 @@ +// Integrity levels of Filee and Explorer, and starting a program with Explorer's rights instead of Filee's. +// +// Filee runs as administrator when something elevated started it: an installer the user ran "as administrator" starts +// Filee with its own token at the end, and the in-app updater inherits that. Explorer then can't drop files on Filee +// or hand files to it (see ElevationState). Starting Filee again "as the user" goes through Explorer itself: the +// desktop's shell view gives out its Shell.Application object, and ShellExecute on that object runs inside Explorer, +// so the new process gets Explorer's token. This is the documented way back down (no API lowers a running process). + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Filee.Core.Platform; + +namespace Filee.Platform.Windows; + +/// Elevation checks and the de-elevated restart. Every member is safe to call on any OS. +public static class Elevation +{ + /// Reads the integrity levels of this process and of Explorer. off Windows. + public static ElevationState Probe() + { + if (!OperatingSystem.IsWindows()) + return ElevationState.Unknown; + try + { + var process = LevelOfProcess(NativeMethods.GetCurrentProcess(), out var elevated); + return new ElevationState(process, ShellLevel(), elevated); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return ElevationState.Unknown; + } + } + + /// + /// Starts through Explorer, so it gets Explorer's rights instead of this process's. + /// Returns false when there is no Explorer desktop to ask (another shell, Explorer restarting) or it refused; + /// then says why, in English. + /// + public static bool RelaunchThroughShell(string executable, string arguments, out string? error) + { + if (OperatingSystem.IsWindows()) + return RelaunchOnWindows(executable, arguments, out error); + error = "Not supported on this operating system."; + return false; + } + + [SupportedOSPlatform("windows")] + private static bool RelaunchOnWindows(string executable, string arguments, out string? error) + { + // Explorer's COM objects want a single-threaded apartment; a thread of its own keeps the caller's apartment + // out of it and bounds the wait if Explorer doesn't answer. + string? failure = null; + var thread = new Thread(() => + { + try + { + ShellExecuteFromDesktop(executable, arguments); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Anything Explorer or the COM binder throws: report it, never take Filee down from this thread. + failure = $"{ex.GetType().Name}: {ex.Message}"; + } + }) + { IsBackground = true, Name = "Filee shell relaunch" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + if (!thread.Join(TimeSpan.FromSeconds(15))) + { + error = "Explorer did not answer."; + return false; + } + error = failure; + return failure is null; + } + + [SupportedOSPlatform("windows")] + private static void ShellExecuteFromDesktop(string executable, string arguments) + { + var shellWindowsType = Type.GetTypeFromCLSID(new Guid("9BA05972-F6A8-11CF-A442-00A0C90A8F39"), throwOnError: true)!; + object? shellWindows = null, desktop = null, browser = null, view = null, background = null; + try + { + shellWindows = Activator.CreateInstance(shellWindowsType) + ?? throw new InvalidOperationException("ShellWindows is not available."); + object location = CSIDL_DESKTOP; + object? empty = null; // VT_EMPTY + desktop = ((IShellWindows)shellWindows).FindWindowSW(ref location, ref empty, SWC_DESKTOP, out _, SWFO_NEEDDISPATCH) + ?? throw new InvalidOperationException("Explorer's desktop window was not found."); + var topLevelBrowser = SID_STopLevelBrowser; + var shellBrowserId = typeof(IShellBrowser).GUID; + browser = ((IServiceProvider)desktop).QueryService(ref topLevelBrowser, ref shellBrowserId); + view = ((IShellBrowser)browser).QueryActiveShellView(); + var dispatchId = IID_IDispatch; + background = ((IShellView)view).GetItemObject(SVGIO_BACKGROUND, ref dispatchId); + // IShellFolderViewDual.Application is Explorer's IShellDispatch2: its ShellExecute runs in Explorer. + dynamic folderView = background; + dynamic application = folderView.Application; + try + { + application.ShellExecute(executable, arguments, Path.GetDirectoryName(executable) ?? "", "", SW_SHOWNORMAL); + } + finally + { + Marshal.FinalReleaseComObject((object)application); + } + } + finally + { + // Released here, on their apartment's thread, which ends right after. Two of them may be one object. + foreach (var item in new[] { background, view, browser, desktop, shellWindows } + .OfType().Where(Marshal.IsComObject).Distinct(ReferenceEqualityComparer.Instance)) + Marshal.FinalReleaseComObject(item); + } + } + + /// Shows a plain Windows message box (for problems before Filee's own windows can open). No-op off Windows. + public static void ShowWarning(string text) + { + if (OperatingSystem.IsWindows()) + NativeMethods.MessageBox(0, text, "Filee", + NativeMethods.MB_OK | NativeMethods.MB_ICONWARNING | NativeMethods.MB_SETFOREGROUND | NativeMethods.MB_TOPMOST); + } + + [SupportedOSPlatform("windows")] + private static IntegrityLevel ShellLevel() + { + var desktop = NativeMethods.GetShellWindow(); + if (desktop == 0) + return IntegrityLevel.Unknown; + NativeMethods.GetWindowThreadProcessId(desktop, out var pid); + if (pid == 0) + return IntegrityLevel.Unknown; + var process = NativeMethods.OpenProcess(NativeMethods.PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (process == 0) + return IntegrityLevel.Unknown; + try + { + return LevelOfProcess(process, out _); + } + finally + { + NativeMethods.CloseHandle(process); + } + } + + [SupportedOSPlatform("windows")] + private static unsafe IntegrityLevel LevelOfProcess(nint process, out bool elevated) + { + elevated = false; + if (!NativeMethods.OpenProcessToken(process, NativeMethods.TOKEN_QUERY, out var token)) + return IntegrityLevel.Unknown; + try + { + uint isElevated = 0; + if (NativeMethods.GetTokenInformation(token, NativeMethods.TokenElevation, &isElevated, sizeof(uint), out _)) + elevated = isElevated != 0; + + // TOKEN_MANDATORY_LABEL: a SID_AND_ATTRIBUTES whose SID points into the same buffer. + NativeMethods.GetTokenInformation(token, NativeMethods.TokenIntegrityLevel, null, 0, out var length); + if (length == 0) + return IntegrityLevel.Unknown; + var buffer = stackalloc byte[(int)length]; + if (!NativeMethods.GetTokenInformation(token, NativeMethods.TokenIntegrityLevel, buffer, length, out _)) + return IntegrityLevel.Unknown; + var sid = *(nint*)buffer; + var count = *(byte*)NativeMethods.GetSidSubAuthorityCount(sid); + if (count == 0) + return IntegrityLevel.Unknown; + var rid = *(uint*)NativeMethods.GetSidSubAuthority(sid, (uint)(count - 1)); + return ElevationState.FromRid(rid); + } + finally + { + NativeMethods.CloseHandle(token); + } + } + + private const int CSIDL_DESKTOP = 0; + private const int SWC_DESKTOP = 8; + private const int SWFO_NEEDDISPATCH = 1; + private const uint SVGIO_BACKGROUND = 0; + private const int SW_SHOWNORMAL = 1; + private static readonly Guid SID_STopLevelBrowser = new("4C96BE40-915C-11CF-99D3-00AA004AE837"); + private static readonly Guid IID_IDispatch = new("00020400-0000-0000-C000-000000000046"); + + // Only the methods Filee calls are typed; the slots before them keep the vtable order and are never called. + + [ComImport, Guid("85CB6900-4D95-11CF-960C-0080C7F4EE85"), InterfaceType(ComInterfaceType.InterfaceIsDual)] + private interface IShellWindows + { + void get_Count(); + void Item(); + void _NewEnum(); + void Register(); + void RegisterPending(); + void Revoke(); + void OnNavigate(); + void OnActivated(); + + [return: MarshalAs(UnmanagedType.IDispatch)] + object? FindWindowSW(ref object location, ref object? locationRoot, int windowClass, out int hwnd, int options); + } + + [ComImport, Guid("6D5140C1-7436-11CE-8034-00AA006009FA"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IServiceProvider + { + [return: MarshalAs(UnmanagedType.IUnknown)] + object QueryService(ref Guid service, ref Guid riid); + } + + [ComImport, Guid("000214E2-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellBrowser + { + void GetWindow(); + void ContextSensitiveHelp(); + void InsertMenusSB(); + void SetMenuSB(); + void RemoveMenusSB(); + void SetStatusTextSB(); + void EnableModelessSB(); + void TranslateAcceleratorSB(); + void BrowseObject(); + void GetViewStateStream(); + void GetControlWindow(); + void SendControlMsg(); + + [return: MarshalAs(UnmanagedType.IUnknown)] + object QueryActiveShellView(); + } + + [ComImport, Guid("000214E3-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IShellView + { + void GetWindow(); + void ContextSensitiveHelp(); + void TranslateAccelerator(); + void EnableModeless(); + void UIActivate(); + void Refresh(); + void CreateViewWindow(); + void DestroyViewWindow(); + void GetCurrentInfo(); + void AddPropertySheetPages(); + void SaveViewState(); + void SelectItem(); + + [return: MarshalAs(UnmanagedType.IUnknown)] + object GetItemObject(uint item, ref Guid riid); + } +} diff --git a/src/Filee.Platform.Windows/NativeMethods.cs b/src/Filee.Platform.Windows/NativeMethods.cs index b4521c18..e936aaf1 100644 --- a/src/Filee.Platform.Windows/NativeMethods.cs +++ b/src/Filee.Platform.Windows/NativeMethods.cs @@ -78,6 +78,47 @@ public unsafe struct PACKAGE_ID [LibraryImport("kernel32.dll")] public static unsafe partial int PackageFamilyNameFromId(PACKAGE_ID* packageId, ref uint packageFamilyNameLength, char* packageFamilyName); + // Tokens and integrity levels (Elevation). + public const uint TOKEN_QUERY = 0x0008; + public const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + public const int TokenElevation = 20; + public const int TokenIntegrityLevel = 25; + + [LibraryImport("kernel32.dll")] + public static partial nint GetCurrentProcess(); + + [LibraryImport("kernel32.dll", SetLastError = true)] + public static partial nint OpenProcess(uint access, [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, uint processId); + + [LibraryImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool CloseHandle(nint handle); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool OpenProcessToken(nint process, uint access, out nint token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static unsafe partial bool GetTokenInformation(nint token, int informationClass, void* information, uint length, out uint returnLength); + + /// Pointer to the sub-authority count (a byte) inside a SID. + [LibraryImport("advapi32.dll")] + public static partial nint GetSidSubAuthorityCount(nint sid); + + /// Pointer to sub-authority (a DWORD) inside a SID. + [LibraryImport("advapi32.dll")] + public static partial nint GetSidSubAuthority(nint sid, uint index); + + /// The desktop window of the shell (Explorer's Progman), or 0 when no shell runs. + [LibraryImport("user32.dll")] + public static partial nint GetShellWindow(); + + public const uint MB_OK = 0x0, MB_ICONWARNING = 0x30, MB_SETFOREGROUND = 0x10000, MB_TOPMOST = 0x40000; + + [LibraryImport("user32.dll", EntryPoint = "MessageBoxW", StringMarshalling = StringMarshalling.Utf16)] + public static partial int MessageBox(nint owner, string text, string caption, uint type); + /// Returns the Win32 class name of a window ("CabinetWClass" for Explorer, ...). public static string ClassNameOf(nint hwnd) { diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index 103f2558..16c3bb6d 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -105,6 +105,17 @@ public IReadOnlyList GetFileManagerSelection() return []; } + public string DescribeForegroundWindow() + { + var foreground = NativeMethods.GetForegroundWindow(); + if (foreground == 0) + return "no foreground window"; + var root = NativeMethods.GetAncestor(foreground, NativeMethods.GA_ROOT); + if (root == 0) + root = foreground; + return $"{NativeMethods.ClassNameOf(root)} / {ProcessNameOf(root) ?? "?"}"; + } + public int SystemDragThreshold => Math.Max(2, NativeMethods.GetSystemMetrics(NativeMethods.SM_CXDRAG)); public bool PrefersReducedMotion => diff --git a/tests/Filee.App.Tests/DiagnosticsTests.cs b/tests/Filee.App.Tests/DiagnosticsTests.cs new file mode 100644 index 00000000..036e85ca --- /dev/null +++ b/tests/Filee.App.Tests/DiagnosticsTests.cs @@ -0,0 +1,288 @@ +// The log lines that explain a Filee which seems to ignore everything: UI thread stalls, bursts kept short, the +// start-up line, and the restart without administrator rights. + +using Avalonia.Headless.XUnit; +using Filee.App.Services; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Tests; + +/// A clock the test moves by hand. +internal sealed class ManualClock : TimeProvider +{ + public DateTimeOffset Now { get; set; } = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + + public void Advance(TimeSpan by) => Now += by; +} + +/// Collects formatted log lines. +internal sealed class ListLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Lines { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Lines) + Lines.Add((logLevel, formatter(state, exception))); + } +} + +public class UiWatchdogTests +{ + private readonly ManualClock _clock = new(); + private readonly ListLogger _log = new(); + private readonly Queue _uiQueue = new(); + + private UiWatchdog Create() => new(_uiQueue.Enqueue, _log, _clock, TimeSpan.FromSeconds(5)); + + /// Ticks once a second for seconds while the UI thread runs nothing. + private static void TickFor(UiWatchdog watchdog, ManualClock clock, int seconds) + { + for (var i = 0; i < seconds; i++) + { + clock.Advance(TimeSpan.FromSeconds(1)); + watchdog.Tick(); + } + } + + private void RunUi() + { + while (_uiQueue.TryDequeue(out var action)) + action(); + } + + [Fact] + public void A_responsive_ui_thread_logs_nothing() + { + var watchdog = Create(); + for (var i = 0; i < 20; i++) + { + _clock.Advance(TimeSpan.FromSeconds(1)); + watchdog.Tick(); + RunUi(); + } + Assert.Empty(_log.Lines); + Assert.Empty(_uiQueue); + } + + [Fact] + public void A_stall_is_reported_once_and_its_end_with_the_length() + { + var watchdog = Create(); + watchdog.Tick(); // ping sent at 0 s + TickFor(watchdog, _clock, 4); + Assert.Empty(_log.Lines); // 4 s: under the threshold + + TickFor(watchdog, _clock, 8); // 12 s + var warning = Assert.Single(_log.Lines); + Assert.Equal(LogLevel.Warning, warning.Level); + Assert.Equal("UI thread blocked for 6 s", warning.Message); // the first tick past 5 s + Assert.Single(_uiQueue); // no second ping while the first waits + + _clock.Advance(TimeSpan.FromSeconds(0.5)); + RunUi(); + Assert.Equal(2, _log.Lines.Count); + Assert.Equal(LogLevel.Information, _log.Lines[1].Level); + Assert.Equal($"UI thread responsive again after {12.5:0.0} s", _log.Lines[1].Message); + } + + [Fact] + public void Each_stall_is_a_new_episode() + { + var watchdog = Create(); + for (var episode = 0; episode < 2; episode++) + { + watchdog.Tick(); + TickFor(watchdog, _clock, 7); + RunUi(); + // Quiet seconds between the stalls. + for (var i = 0; i < 3; i++) + { + _clock.Advance(TimeSpan.FromSeconds(1)); + watchdog.Tick(); + RunUi(); + } + } + Assert.Equal(2, _log.Lines.Count(l => l.Level == LogLevel.Warning)); + Assert.Equal(2, _log.Lines.Count(l => l.Level == LogLevel.Information)); + } + + [Fact] + public void A_dispatcher_that_has_shut_down_does_not_break_the_timer() + { + var watchdog = new UiWatchdog(_ => throw new InvalidOperationException("shut down"), _log, _clock); + watchdog.Tick(); + TickFor(watchdog, _clock, 10); + Assert.Empty(_log.Lines); + } +} + +public class LogThrottleTests +{ + [Fact] + public void Logs_the_first_then_one_per_interval_counting_the_rest() + { + var clock = new ManualClock(); + var throttle = new LogThrottle(TimeSpan.FromSeconds(30), clock); + + Assert.True(throttle.TryEnter("forwarded", out var held)); + Assert.Equal(0, held); + for (var i = 0; i < 99; i++) + { + clock.Advance(TimeSpan.FromMilliseconds(100)); + Assert.False(throttle.TryEnter("forwarded", out held)); + } + Assert.True(throttle.TryEnter("other kind", out _)); // kinds are counted apart + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(throttle.TryEnter("forwarded", out held)); + Assert.Equal(99, held); + Assert.Equal(" (99 more since the last one)", LogThrottle.HeldText(held)); + Assert.Equal("", LogThrottle.HeldText(0)); + } +} + +public class StartupDiagnosticsTests +{ + [Fact] + public void The_startup_line_names_version_path_kind_rights_os_and_argument_count() + { + var line = StartupDiagnostics.Describe("1.11.0", "/opt/filee/Filee", "portable", + new ElevationState(IntegrityLevel.High, IntegrityLevel.Medium, true), "Microsoft Windows 10.0.26200 X64", 3); + Assert.Equal("Filee 1.11.0 starting: /opt/filee/Filee (portable), integrity High (elevated), Explorer Medium, " + + "Microsoft Windows 10.0.26200 X64, 3 argument(s)", line); + } + + [Fact] + public void An_unknown_path_and_state_still_give_a_line() + { + var line = StartupDiagnostics.Describe("1.0.0", null, "development build", ElevationState.Unknown, "Linux", 0); + Assert.Equal("Filee 1.0.0 starting: (unknown path) (development build), integrity unknown, Linux, 0 argument(s)", line); + } + + [Theory] + [InlineData(true, true, "installed")] + [InlineData(true, false, "installed")] + [InlineData(false, true, "portable")] + [InlineData(false, false, "development build")] + public void Install_kind(bool installed, bool release, string expected) => + Assert.Equal(expected, StartupDiagnostics.InstallKind(installed, release)); + + [Fact] + public void The_operating_system_text_is_not_empty() => + Assert.False(string.IsNullOrWhiteSpace(StartupDiagnostics.OperatingSystemText())); + + [Fact] + public void The_early_logger_writes_to_the_daily_file() + { + var folder = Directory.CreateTempSubdirectory("filee-early-log-").FullName; + try + { + var log = StartupDiagnostics.CreateEarlyLogger(folder); + log.LogInformation("{Startup}", "Filee test starting"); + var file = Assert.Single(Directory.GetFiles(folder, "filee-*.log")); + Assert.Contains("Program: Filee test starting", File.ReadAllText(file)); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + [Fact] + public void Launch_problems_are_shown_in_the_system_language_without_avalonia() + { + // Program shows these before Avalonia starts, so they are read without Avalonia's asset service. + foreach (var key in new[] { "startup.elevated_other", "startup.no_answer" }) + { + var text = Services.LocalizationService.EarlyText(key); + Assert.NotEqual(key, text); + Assert.False(string.IsNullOrWhiteSpace(text)); + } + } +} + +public class ElevationServiceTests +{ + private readonly List _calls = []; + + private ElevationService Create(string? relaunchError) => new(NullLogger.Instance, + new ElevationState(IntegrityLevel.High, IntegrityLevel.Medium, true), + exe => + { + _calls.Add("relaunch " + exe); + return relaunchError; + }, + () => _calls.Add("release"), () => _calls.Add("reacquire"), () => _calls.Add("shutdown")); + + [Fact] + public void The_restart_lets_go_of_the_lock_before_starting_the_new_filee_then_quits() + { + var elevation = Create(relaunchError: null); + Assert.True(elevation.ShellCannotReachFilee); + + Assert.True(elevation.RestartWithoutAdministratorRights("/apps/Filee")); + + Assert.Equal(["release", "relaunch /apps/Filee", "shutdown"], _calls); + Assert.False(elevation.RestartFailed); + } + + [Fact] + public void A_failed_restart_takes_the_lock_back_keeps_running_and_says_so() + { + var elevation = Create(relaunchError: "Explorer's desktop window was not found."); + var changed = new List(); + elevation.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + Assert.False(elevation.RestartWithoutAdministratorRights("/apps/Filee")); + + Assert.Equal(["release", "relaunch /apps/Filee", "reacquire"], _calls); + Assert.True(elevation.RestartFailed); + Assert.Contains(nameof(ElevationService.RestartFailed), changed); + } + + [Fact] + public void Not_elevated_shows_no_warning() + { + var elevation = new ElevationService(NullLogger.Instance, + new ElevationState(IntegrityLevel.Medium, IntegrityLevel.Medium, false), _ => null, () => { }, () => { }, () => { }); + Assert.False(elevation.ShellCannotReachFilee); + } + + [AvaloniaFact] + public void The_home_page_warns_and_shows_a_failed_restart() + { + TestServices.EnsureInitialized(); + var elevation = Create(relaunchError: "Explorer did not answer."); + using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), + AppHost.Get(), AppHost.Get(), elevation: elevation); + var changed = new List(); + page.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + Assert.True(page.IsElevated); + Assert.False(page.ElevationRestartFailed); + + page.RestartWithoutAdministratorRightsCommand.Execute(null); + + Assert.True(page.ElevationRestartFailed); + Assert.Contains(nameof(page.ElevationRestartFailed), changed); + Assert.DoesNotContain("shutdown", _calls); + } + + [AvaloniaFact] + public void The_home_page_has_no_warning_by_default() + { + TestServices.EnsureInitialized(); + using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), + AppHost.Get(), AppHost.Get(), + elevation: AppHost.Get()); // tests never run Program: the state stays unknown + Assert.False(page.IsElevated); + } +} diff --git a/tests/Filee.App.Tests/SingleInstanceTests.cs b/tests/Filee.App.Tests/SingleInstanceTests.cs index dec9a087..d83b8f56 100644 --- a/tests/Filee.App.Tests/SingleInstanceTests.cs +++ b/tests/Filee.App.Tests/SingleInstanceTests.cs @@ -44,4 +44,73 @@ public async Task Forwarding_gives_up_when_nobody_listens() Assert.False(delivered); } + + [Fact] + public async Task Forwarding_tells_that_nobody_answered() + { + var name = "Filee-test-" + Guid.NewGuid().ToString("N")[..8]; + using var first = SingleInstance.Acquire(name); + + var result = await Task.Run(() => + { + using var instance = SingleInstance.Acquire(name); + return instance.Forward(["--convert", "a.txt"], TimeSpan.FromSeconds(1)); + }, TestContext.Current.CancellationToken); + + Assert.Equal(ForwardResult.NoAnswer, result); + } + + [Fact] + public void Releasing_twice_is_harmless() + { + // A restart without administrator rights releases the lock early; Program releases it again at exit. + var name = "Filee-test-" + Guid.NewGuid().ToString("N")[..8]; + var first = SingleInstance.Acquire(name); + first.Dispose(); + first.Dispose(); + + using var next = SingleInstance.Acquire(name); + Assert.True(next.IsFirst); + } +} + +/// Windows: a running Filee with other rights (as administrator) refuses a normal launch. +[System.Runtime.Versioning.SupportedOSPlatform("windows")] +public class SingleInstanceAccessTests +{ + [Fact] + public async Task A_pipe_that_refuses_this_process_is_given_up_on_quickly() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows pipe security."); + var name = "Filee-test-" + Guid.NewGuid().ToString("N")[..8]; + using var first = SingleInstance.Acquire(name); + // Only SYSTEM may write: what a pipe of an elevated Filee looks like to a normal process. + var security = new System.IO.Pipes.PipeSecurity(); + security.AddAccessRule(new System.IO.Pipes.PipeAccessRule( + new System.Security.Principal.SecurityIdentifier(System.Security.Principal.WellKnownSidType.LocalSystemSid, null), + System.IO.Pipes.PipeAccessRights.FullControl, System.Security.AccessControl.AccessControlType.Allow)); + await using var server = System.IO.Pipes.NamedPipeServerStreamAcl.Create(name, System.IO.Pipes.PipeDirection.In, 1, + System.IO.Pipes.PipeTransmissionMode.Byte, System.IO.Pipes.PipeOptions.Asynchronous, 0, 0, security); + var watch = System.Diagnostics.Stopwatch.StartNew(); + + var result = await Task.Run(() => + { + using var second = SingleInstance.Acquire(name); + return second.Forward(["--convert", "a.txt"], TimeSpan.FromSeconds(30)); + }, TestContext.Current.CancellationToken); + + Assert.Equal(ForwardResult.AccessDenied, result); + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(10), $"took {watch.Elapsed}"); + } + + [Fact] + public void The_integrity_level_of_this_process_can_be_read() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows tokens."); + var state = Filee.Platform.Windows.Elevation.Probe(); + Assert.True(state.Process >= Filee.Core.Platform.IntegrityLevel.Medium, state.ToString()); + // Explorer may not run (CI): then its level is unknown and nothing is reported. + if (state.Shell == Filee.Core.Platform.IntegrityLevel.Unknown) + Assert.False(state.ShellCannotReachFilee); + } } diff --git a/tests/Filee.Core.Tests/ElevationAndCatalogTests.cs b/tests/Filee.Core.Tests/ElevationAndCatalogTests.cs new file mode 100644 index 00000000..fcc5c7fe --- /dev/null +++ b/tests/Filee.Core.Tests/ElevationAndCatalogTests.cs @@ -0,0 +1,111 @@ +// When an elevated Filee counts as cut off from Explorer, and engine statuses checked in the background. + +using Filee.Core.Conversion; +using Filee.Core.Platform; + +namespace Filee.Core.Tests; + +public class ElevationStateTests +{ + [Theory] + [InlineData(IntegrityLevel.High, IntegrityLevel.Medium, true)] // installer run as administrator started Filee + [InlineData(IntegrityLevel.System, IntegrityLevel.Medium, true)] + [InlineData(IntegrityLevel.Medium, IntegrityLevel.Medium, false)] // the normal case + [InlineData(IntegrityLevel.High, IntegrityLevel.High, false)] // UAC off: Explorer runs elevated too + [InlineData(IntegrityLevel.Medium, IntegrityLevel.High, false)] // Explorer above Filee: not what this warns about + [InlineData(IntegrityLevel.High, IntegrityLevel.Unknown, false)] // no Explorer (another shell) + [InlineData(IntegrityLevel.Unknown, IntegrityLevel.Medium, false)] + [InlineData(IntegrityLevel.MediumPlus, IntegrityLevel.Medium, false)] + public void Only_an_administrator_Filee_above_Explorer_is_cut_off(IntegrityLevel process, IntegrityLevel shell, bool cutOff) => + Assert.Equal(cutOff, new ElevationState(process, shell, Elevated: process >= IntegrityLevel.High).ShellCannotReachFilee); + + [Fact] + public void Unknown_state_is_never_cut_off() => Assert.False(ElevationState.Unknown.ShellCannotReachFilee); + + [Theory] + [InlineData(0x0000u, IntegrityLevel.Untrusted)] + [InlineData(0x1000u, IntegrityLevel.Low)] + [InlineData(0x2000u, IntegrityLevel.Medium)] + [InlineData(0x2100u, IntegrityLevel.MediumPlus)] + [InlineData(0x3000u, IntegrityLevel.High)] + [InlineData(0x3001u, IntegrityLevel.High)] + [InlineData(0x4000u, IntegrityLevel.System)] + [InlineData(0x5000u, IntegrityLevel.System)] // protected process + public void Mandatory_label_rids_map_to_levels(uint rid, IntegrityLevel level) => + Assert.Equal(level, ElevationState.FromRid(rid)); + + [Fact] + public void Describes_itself_for_the_log() + { + Assert.Equal("integrity High (elevated), Explorer Medium", + new ElevationState(IntegrityLevel.High, IntegrityLevel.Medium, true).ToString()); + Assert.Equal("integrity Medium, Explorer Medium", + new ElevationState(IntegrityLevel.Medium, IntegrityLevel.Medium, false).ToString()); + Assert.Equal("integrity unknown", ElevationState.Unknown.ToString()); + } +} + +public class CatalogBackgroundCheckTests +{ + /// An engine whose check waits for the test, like FFmpeg answering -version slowly. + private sealed class SlowEngine : IConverter + { + public ManualResetEventSlim Gate { get; } = new(false); + public ManualResetEventSlim Started { get; } = new(false); + public int Checks; + public volatile bool Available; + + public string Id => "slow"; + public string DisplayName => "Slow"; + public IReadOnlyList Edges => [new("png", "jpg")]; + public int MaxParallelism => 0; + + public EngineStatus GetStatus() + { + Interlocked.Increment(ref Checks); + var available = Available; // what the engine looked like when the check started + Started.Set(); + Gate.Wait(TimeSpan.FromSeconds(10)); + return available ? EngineStatus.Available() : EngineStatus.Unavailable("engine.reason.not_installed"); + } + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + [Fact] + public async Task A_status_asked_during_the_background_check_waits_for_it_instead_of_checking_again() + { + var engine = new SlowEngine { Available = true }; + var catalog = new ConverterCatalog([engine]); + var background = catalog.CheckInBackgroundAsync(); + Assert.True(engine.Started.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + var asked = Task.Run(() => catalog.StatusOf(engine), TestContext.Current.CancellationToken); + await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.False(asked.IsCompleted); + engine.Gate.Set(); + + Assert.True((await asked.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)).IsAvailable); + await background.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(1, engine.Checks); + } + + [Fact] + public async Task A_refresh_during_the_background_check_discards_its_result() + { + var engine = new SlowEngine { Available = false }; + var catalog = new ConverterCatalog([engine]); + var background = catalog.CheckInBackgroundAsync(); + Assert.True(engine.Started.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + + // The user installed the engine while the first check was still running. + engine.Available = true; + catalog.Refresh(); + engine.Gate.Set(); + await background.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(catalog.StatusOf(engine).IsAvailable); + Assert.Equal(2, engine.Checks); + } +} diff --git a/tests/Filee.Engines.Tests/EngineProbeTests.cs b/tests/Filee.Engines.Tests/EngineProbeTests.cs new file mode 100644 index 00000000..138b8fe0 --- /dev/null +++ b/tests/Filee.Engines.Tests/EngineProbeTests.cs @@ -0,0 +1,56 @@ +// Engine checks that must not hang: a network share on the PATH that doesn't answer, an FFmpeg that never exits. + +using System.Diagnostics; +using Filee.Engines.Infrastructure; +using Filee.Engines.Media; + +namespace Filee.Engines.Tests; + +public class EngineProbeTests +{ + [Theory] + [InlineData(@"\\server\share\tools\bin", @"\\server\share")] + [InlineData(@"\\server\share", @"\\server\share")] + [InlineData(@"\\server\share\", @"\\server\share")] + [InlineData(@"\\server", @"\\server")] + [InlineData(@"C:\Tools\bin", null)] + [InlineData(@"\\?\C:\Tools", null)] + [InlineData(@"\\.\pipe\x", null)] + [InlineData("/usr/local/bin", null)] + [InlineData("", null)] + public void Network_folders_on_the_path_are_recognised(string folder, string? share) => + Assert.Equal(share, EngineEnvironment.ShareOf(folder)); + + [Fact] + public void A_program_that_never_exits_is_given_up_on_and_killed() + { + // Both run for about half a minute (ping waits a second between replies). + using var process = OperatingSystem.IsWindows() ? Start("ping.exe", "-n 30 127.0.0.1") : Start("/bin/sleep", "30"); + var watch = Stopwatch.StartNew(); + + Assert.Null(FfmpegConverter.OutputWithin(process, TimeSpan.FromSeconds(1))); + + Assert.True(watch.Elapsed < TimeSpan.FromSeconds(10), $"took {watch.Elapsed}"); + Assert.True(process.WaitForExit(TimeSpan.FromSeconds(10))); + } + + [Fact] + public void Output_of_a_program_that_exits_is_returned() + { + using var process = OperatingSystem.IsWindows() + ? Start("cmd.exe", "/d /c echo filee-probe") + : Start("/bin/sh", "-c \"echo filee-probe\""); + + Assert.Contains("filee-probe", FfmpegConverter.OutputWithin(process, TimeSpan.FromSeconds(10))); + Assert.Equal(0, process.ExitCode); + } + + private static Process Start(string program, string arguments) => + Process.Start(new ProcessStartInfo(program, arguments) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }) ?? throw new InvalidOperationException(program + " did not start"); +} From c491712a46eae127995f225796007edb5cafba34 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sun, 18 Oct 2026 13:29:16 +0900 Subject: [PATCH 3/3] Combo boxes keep their choice on Ctrl+Space and Ctrl+click (#107) Avalonia toggles the selected item of a ComboBox or ListBox off when it is picked again with the command modifier (Ctrl+Space, Ctrl+Enter, Ctrl+click; Cmd on macOS). A combo box was left empty and pushed null into settings that always hold a value, which threw NullReferenceException in the view models (e.g. TriggerGestureViewModel) and showed it under the combo box. - SingleSelectionGuard (registered in App.Initialize) swallows that toggle on the selected item of combo boxes and single-selection list boxes; an open drop-down closes as after any pick. Multi-selection lists keep the toggle. - Choices.Restore: every Choice property (Triggers, Presets editor, General, Theme, Watch folders, rename rules) and the navigation selection ignore null and keep their previous value; the control then reads the bound value again. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/App.axaml.cs | 7 +- .../Controls/SingleSelectionGuard.cs | 76 +++++ .../ViewModels/MainWindowViewModel.cs | 9 +- .../ViewModels/Pages/GeneralPageViewModel.cs | 12 +- .../ViewModels/Pages/ThemePageViewModel.cs | 6 +- .../ViewModels/Pages/TriggersPageViewModel.cs | 5 + .../Pages/WatchFoldersPageViewModel.cs | 13 +- .../ViewModels/PresetEditorViewModel.cs | 53 +++- .../ViewModels/RenameToolViewModel.cs | 6 +- tests/Filee.App.Tests/SelectionGuardTests.cs | 282 ++++++++++++++++++ 10 files changed, 456 insertions(+), 13 deletions(-) create mode 100644 src/Filee.App/Controls/SingleSelectionGuard.cs create mode 100644 tests/Filee.App.Tests/SelectionGuardTests.cs diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 9ad8ebec..74ac774f 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using Filee.App.Controls; using Filee.App.Services; using Filee.App.Services.Triggers; using Filee.Core.Conversion; @@ -22,7 +23,11 @@ public partial class App : Application private IDisposable? _convertDebounce; private bool _selectionPending; - public override void Initialize() => AvaloniaXamlLoader.Load(this); + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + SingleSelectionGuard.Register(); + } public override void OnFrameworkInitializationCompleted() { diff --git a/src/Filee.App/Controls/SingleSelectionGuard.cs b/src/Filee.App/Controls/SingleSelectionGuard.cs new file mode 100644 index 00000000..6b48ef51 --- /dev/null +++ b/src/Filee.App/Controls/SingleSelectionGuard.cs @@ -0,0 +1,76 @@ +// Keeps combo boxes and single-selection lists from losing their selection to the toggle modifier. Avalonia treats +// Ctrl+Space, Ctrl+Enter and Ctrl+click (Cmd on macOS) on the selected item as "toggle", which leaves a combo box +// empty and pushes null into a setting that always has a value (#107). Multi-selection lists keep the toggle. +// +// A selection cleared some other way is refused by the view models (Choices.Restore); the control then shows the +// bound value again. + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace Filee.App.Controls; + +public static class SingleSelectionGuard +{ + private static bool _registered; + + /// Installs the class handlers once for every combo box and list box of the application. + public static void Register() + { + if (_registered) + return; + _registered = true; + // Tunnel: the item's own handlers (which toggle) run in the bubble phase. + InputElement.KeyDownEvent.AddClassHandler(OnKeyDown, RoutingStrategies.Tunnel); + InputElement.PointerPressedEvent.AddClassHandler(OnPointer, RoutingStrategies.Tunnel); + InputElement.PointerReleasedEvent.AddClassHandler(OnPointer, RoutingStrategies.Tunnel); + SelectingItemsControl.SelectedItemProperty.Changed.AddClassHandler(OnSelectedItemChanged); + } + + /// + /// The view model puts its value back while the control is still clearing its selection, which the control misses: + /// once it is done, it reads the bound value again (still null when the view model took the null). + /// + private static void OnSelectedItemChanged(SelectingItemsControl control, AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is null && e.OldValue is not null && control is ComboBox or ListBox + && BindingOperations.GetBindingExpressionBase(control, SelectingItemsControl.SelectedItemProperty) is { } binding) + Dispatcher.UIThread.Post(binding.UpdateTarget); + } + + private static void OnKeyDown(ListBoxItem item, KeyEventArgs e) + { + if (ItemSelectionEventTriggers.ShouldTriggerSelection(item, e) && WouldDeselect(item, e)) + Keep(item, e); + } + + private static void OnPointer(ListBoxItem item, PointerEventArgs e) + { + if (e.GetCurrentPoint(item).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed or PointerUpdateKind.LeftButtonReleased + && WouldDeselect(item, e)) + Keep(item, e); + } + + /// The selected item of a single-selection control, picked again with the toggle modifier held. + private static bool WouldDeselect(ListBoxItem item, RoutedEventArgs e) => + !e.Handled && item.IsSelected && ItemSelectionEventTriggers.HasToggleSelectionModifier(item, e) + && ItemsControl.ItemsControlFromItemContainer(item) switch + { + ComboBox => true, + ListBox list => (list.SelectionMode & (SelectionMode.Multiple | SelectionMode.Toggle)) == 0, + _ => false, + }; + + /// Picking the item that is already selected: nothing changes, an open combo box closes as after any pick. + private static void Keep(ListBoxItem item, RoutedEventArgs e) + { + e.Handled = true; + if (e.RoutedEvent != InputElement.PointerPressedEvent && ItemsControl.ItemsControlFromItemContainer(item) is ComboBox combo) + combo.SetCurrentValue(ComboBox.IsDropDownOpenProperty, false); + } +} diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs index c64dcc4f..629d9a81 100644 --- a/src/Filee.App/ViewModels/MainWindowViewModel.cs +++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs @@ -109,8 +109,13 @@ public void Navigate(string key) SelectedItem = item; } - // Pages are recreated on every visit so they always reflect the latest settings. - partial void OnSelectedItemChanged(NavItem value) => CurrentPage = CreatePage(value); + // Pages are recreated on every visit so they always reflect the latest settings. A navigation list that loses its + // selection keeps the open page (Choices.Restore). + partial void OnSelectedItemChanged(NavItem? oldValue, NavItem newValue) + { + if (!Choices.Restore(oldValue, newValue, v => SelectedItem = v)) + CurrentPage = CreatePage(newValue); + } // Old pages may hold event subscriptions on long-lived services. partial void OnCurrentPageChanged(ObservableObject? oldValue, ObservableObject newValue) => (oldValue as IDisposable)?.Dispose(); diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index cd03b1fe..623d5b34 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -65,7 +65,11 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic public bool ShowDefaultSubfolder => DefaultLocation.Value == OutputLocation.Subfolder; public bool ShowDefaultFolder => DefaultLocation.Value == OutputLocation.CustomFolder; - partial void OnDefaultLocationChanged(Choice value) => Save(s => s.DefaultOutput.Location = value.Value); + partial void OnDefaultLocationChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => DefaultLocation = v)) + Save(s => s.DefaultOutput.Location = newValue.Value); + } partial void OnDefaultSubfolderChanged(string value) => Save(s => s.DefaultOutput.SubfolderName = string.IsNullOrWhiteSpace(value) ? "converted" : value.Trim()); @@ -96,7 +100,11 @@ partial void OnDefaultSubfolderChanged(string value) => public bool CanAddModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.Off or ModernContextMenuState.Outdated; public bool CanRemoveModernMenu => !ModernMenuBusy && ModernMenuState is ModernContextMenuState.On or ModernContextMenuState.Outdated; - partial void OnLanguageChanged(Choice value) => Save(s => s.Language = value.Value); + partial void OnLanguageChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Language = v)) + Save(s => s.Language = newValue.Value); + } partial void OnStartWithSystemChanged(bool value) => Save(s => s.StartWithSystem = value); partial void OnContextMenuChanged(bool value) diff --git a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs index c93ecbd4..badc7bc3 100644 --- a/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs @@ -51,7 +51,11 @@ public ThemePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices [ObservableProperty] private double _cornerRadius; [ObservableProperty] private bool _reduceMotion; - partial void OnModeChanged(Choice value) => Save(t => t.Mode = value.Value); + partial void OnModeChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Mode = v)) + Save(t => t.Mode = newValue.Value); + } partial void OnAccentChanged(Color value) { var hex = $"#{value.R:X2}{value.G:X2}{value.B:X2}"; diff --git a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs index cf1c8bd2..9e5c8ae1 100644 --- a/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/TriggersPageViewModel.cs @@ -85,6 +85,11 @@ public TriggerGestureViewModel(TriggerGesture gesture, ILocalizer loc, Action sa public string ThresholdText => DragThreshold == 0 ? SystemLabel : $"{DragThreshold}px"; public string Summary => GestureText.Describe(_gesture); + // Runs before OnPropertyChanged below, which reads every choice. + partial void OnKindChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Kind = v); + partial void OnButtonChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Button = v); + partial void OnScopeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Scope = v); + protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) { base.OnPropertyChanged(e); diff --git a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs index 8e60dd37..e32635cd 100644 --- a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs @@ -333,10 +333,19 @@ internal void RefreshResults() partial void OnEnabledChanged(bool value) => Change(r => r.Enabled = value); partial void OnFolderChanged(string value) => Change(r => r.Folder = value.Trim()); - partial void OnPresetChanged(Choice? value) => Change(r => r.PresetId = value?.Value ?? ""); + // Null only while no preset was found for the rule (deleted); the combo box clearing itself doesn't forget the preset. + partial void OnPresetChanged(Choice? oldValue, Choice? newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Preset = v)) + Change(r => r.PresetId = newValue?.Value ?? ""); + } partial void OnIncludeSubfoldersChanged(bool value) => Change(r => r.IncludeSubfolders = value); partial void OnOutputFolderChanged(string value) => Change(r => r.OutputFolder = value.Trim()); - partial void OnOriginalsChanged(Choice value) => Change(r => r.Originals = value.Value); + partial void OnOriginalsChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Originals = v)) + Change(r => r.Originals = newValue.Value); + } partial void OnIncludeTextChanged(string value) { diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 45382072..2541b32a 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -25,6 +25,23 @@ public sealed record Choice(T Value, string Label, string? Detail = null) : I public override string ToString() => Label; } +/// Selections bound to properties that always hold a value (a choice, the open page). +public static class Choices +{ + /// + /// For the OnXxxChanged(oldValue, newValue) hook of such a property. A combo box or list box sets null when it + /// loses its selection (the selected item toggled off, its items replaced): the previous value is put back through + /// , which also shows it in the control again. Returns true then, so the hook stops. + /// + public static bool Restore(T? oldValue, T? newValue, Action restore) where T : class + { + if (newValue is not null || oldValue is null) + return false; + restore(oldValue); + return true; + } +} + public sealed partial class PresetEditorViewModel : ObservableObject { private static readonly HashSet LossyTargets = ["jpg", "webp", "avif", "heic"]; @@ -181,8 +198,14 @@ public Choice VideoCodecChoice get => VideoCodecs.FirstOrDefault(c => c.Value == _videoCodecValue) ?? VideoCodecs[0]; set { - // The combo box clears its selection while its list is replaced. - if (value is null || value.Value == _videoCodecValue) + // The combo box clears its selection while its list is replaced (or the selected item is toggled off): + // announcing the value shows it again. + if (value is null) + { + OnPropertyChanged(); + return; + } + if (value.Value == _videoCodecValue) return; _videoCodecValue = value.Value; OnPropertyChanged(); @@ -199,7 +222,12 @@ public Choice AudioCodecChoice get => AudioCodecs.FirstOrDefault(c => c.Value == _audioCodecValue) ?? AudioCodecs[0]; set { - if (value is null || value.Value == _audioCodecValue) + if (value is null) + { + OnPropertyChanged(); + return; + } + if (value.Value == _audioCodecValue) return; _audioCodecValue = value.Value; OnPropertyChanged(); @@ -290,6 +318,25 @@ public bool ColorProfileChecked [ObservableProperty] private int _traceColors; [ObservableProperty] private int _traceDetail; + // A combo box that loses its selection sets null: every choice keeps its value (see Choices.Restore). + partial void OnTargetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Target = v); + partial void OnResizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Resize = v); + partial void OnRotationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Rotation = v); + partial void OnFlipChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Flip = v); + partial void OnPageSizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => PageSize = v); + partial void OnLocationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Location = v); + partial void OnConflictChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Conflict = v); + partial void OnTiffCompressionChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => TiffCompression = v); + partial void OnMediaQualityChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MediaQuality = v); + partial void OnMaxHeightChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => MaxHeight = v); + partial void OnAudioBitrateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioBitrate = v); + partial void OnAudioChannelsChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AudioChannels = v); + partial void OnSampleRateChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => SampleRate = v); + partial void OnWavFormatChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => WavFormat = v); + partial void OnArchiveLevelChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => ArchiveLevel = v); + partial void OnTraceModeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => TraceMode = v); + private string TargetId => Target.Value; private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category; private bool TargetIsImage => TargetId == BuiltInData.SameAsSource || TargetCategory == FormatCategory.Image; diff --git a/src/Filee.App/ViewModels/RenameToolViewModel.cs b/src/Filee.App/ViewModels/RenameToolViewModel.cs index c26b5d32..adc373aa 100644 --- a/src/Filee.App/ViewModels/RenameToolViewModel.cs +++ b/src/Filee.App/ViewModels/RenameToolViewModel.cs @@ -187,9 +187,11 @@ public RenameStepViewModel(RenameStep step, RenameToolViewModel tool) _ => "watch.ph.replace", }); - partial void OnKindChanged(Choice value) + partial void OnKindChanged(Choice? oldValue, Choice newValue) { - Step.Kind = value.Value; + if (Choices.Restore(oldValue, newValue, v => Kind = v)) + return; + Step.Kind = newValue.Value; OnPropertyChanged(nameof(ShowFind)); OnPropertyChanged(nameof(ShowReplace)); OnPropertyChanged(nameof(ShowArrow)); diff --git a/tests/Filee.App.Tests/SelectionGuardTests.cs b/tests/Filee.App.Tests/SelectionGuardTests.cs new file mode 100644 index 00000000..5ecc80da --- /dev/null +++ b/tests/Filee.App.Tests/SelectionGuardTests.cs @@ -0,0 +1,282 @@ +// Combo boxes and the navigation list keep their selection when the selected item is picked again with the toggle +// modifier (Ctrl+Space / Ctrl+click, Cmd on macOS), and the settings behind them never become null (#107). + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using Filee.App.Services; +using Filee.App.ViewModels; +using Filee.App.ViewModels.Pages; +using Filee.App.Views; +using Filee.App.Views.Pages; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.App.Tests; + +public class SelectionGuardTests +{ + private static void Pump() + { + for (var i = 0; i < 10; i++) + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + } + + /// Ctrl on Windows and Linux, Cmd on macOS: what Avalonia reads as "toggle the selection". + private static RawInputModifiers Toggle(TopLevel top) => + (RawInputModifiers)(top.GetPlatformSettings() ?? Application.Current!.PlatformSettings)!.HotkeyConfiguration.CommandModifiers; + + private static void CtrlSpace(Window window) + { + window.KeyPress(Key.Space, Toggle(window), PhysicalKey.Space, " "); + window.KeyRelease(Key.Space, Toggle(window), PhysicalKey.Space, " "); + Pump(); + } + + private static Point Middle(Control control, Visual to) => + control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), to)!.Value; + + private static (Window Window, TriggerGestureViewModel Gesture, ComboBox Kind) ShowTriggers() + { + TestServices.EnsureInitialized("en"); + var vm = new TriggersPageViewModel(AppHost.Get(), AppHost.Get()); + var window = new Window { Content = new TriggersPage { DataContext = vm }, Width = 900, Height = 900 }; + window.Show(); + Pump(); + var gesture = vm.Gestures[0]; + var kind = window.GetVisualDescendants().OfType().First(c => ReferenceEquals(c.ItemsSource, gesture.Kinds)); + return (window, gesture, kind); + } + + [AvaloniaFact] + public void Ctrl_space_twice_on_a_combo_box_keeps_its_choice() + { + var (window, gesture, kind) = ShowTriggers(); + try + { + var before = gesture.Kind; + var changes = 0; + kind.SelectionChanged += (_, _) => changes++; + Assert.True(kind.Focus(NavigationMethod.Tab)); + + CtrlSpace(window); // opens the drop-down on the selected item + CtrlSpace(window); // picks that item again: used to clear the selection + CtrlSpace(window); + CtrlSpace(window); + + Assert.Same(before, gesture.Kind); + Assert.Same(before, kind.SelectedItem); + Assert.Equal(0, changes); // never cleared, not just put back + Assert.False(kind.IsDropDownOpen); + Assert.Equal(before.Value, gesture.Gesture.Kind); + Assert.Empty(DataValidationErrors.GetErrors(kind) ?? []); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Ctrl_clicking_the_selected_item_of_an_open_combo_box_keeps_it_and_closes_the_drop_down() + { + var (window, gesture, kind) = ShowTriggers(); + try + { + var before = gesture.Kind; + kind.IsDropDownOpen = true; + Pump(); + var changes = 0; + kind.SelectionChanged += (_, _) => changes++; + var item = (ComboBoxItem)kind.ContainerFromItem(before)!; + var root = TopLevel.GetTopLevel(item)!; + var at = Middle(item, root); + root.MouseDown(at, MouseButton.Left, Toggle(root)); + root.MouseUp(at, MouseButton.Left, Toggle(root)); + Pump(); + + Assert.Same(before, gesture.Kind); + Assert.Same(before, kind.SelectedItem); + Assert.Equal(0, changes); + Assert.False(kind.IsDropDownOpen); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void A_combo_box_cleared_anyway_shows_its_choice_again() + { + var (window, gesture, kind) = ShowTriggers(); + try + { + var before = gesture.Kind; + kind.SelectedItem = null; // e.g. an Avalonia path the guard does not know + Pump(); + + Assert.Same(before, gesture.Kind); + Assert.Same(before, kind.SelectedItem); + Assert.Empty(DataValidationErrors.GetErrors(kind) ?? []); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Ctrl_clicking_the_open_page_in_the_navigation_keeps_it_open() + { + TestServices.EnsureInitialized("en"); + var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get()); + var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 }; + window.Show(); + Pump(); + try + { + var nav = window.GetVisualDescendants().OfType().First(l => l.Classes.Contains("nav")); + var home = vm.SelectedItem; + var page = vm.CurrentPage; + var changes = 0; + nav.SelectionChanged += (_, _) => changes++; + var at = Middle(nav.ContainerFromItem(home)!, window); + window.MouseDown(at, MouseButton.Left, Toggle(window)); + window.MouseUp(at, MouseButton.Left, Toggle(window)); + Pump(); + Assert.Same(home, vm.SelectedItem); + Assert.Same(page, vm.CurrentPage); + Assert.Equal(0, changes); + + // Ctrl+clicking another page still opens it. + at = Middle(nav.ContainerFromItem(vm.Items[1])!, window); + window.MouseDown(at, MouseButton.Left, Toggle(window)); + window.MouseUp(at, MouseButton.Left, Toggle(window)); + Pump(); + Assert.Same(vm.Items[1], vm.SelectedItem); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Multi_selection_lists_still_toggle() + { + TestServices.EnsureInitialized("en"); + var list = new ListBox { ItemsSource = new[] { "a", "b", "c" }, SelectionMode = SelectionMode.Multiple }; + var window = new Window { Content = list, Width = 300, Height = 300 }; + window.Show(); + Pump(); + try + { + list.SelectedIndex = 0; + Pump(); + Assert.True(list.ContainerFromIndex(0)!.Focus(NavigationMethod.Tab)); + CtrlSpace(window); + Assert.Empty(list.SelectedItems!); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Choice_properties_ignore_null_and_announce_their_value_again() + { + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var store = AppHost.Get(); + + var gesture = new TriggerGestureViewModel(new TriggerGesture { Button = TriggerMouseButton.Left }, loc, () => { }); + AssertKeeps(gesture, nameof(gesture.Kind), () => gesture.Kind, v => gesture.Kind = v); + AssertKeeps(gesture, nameof(gesture.Button), () => gesture.Button, v => gesture.Button = v); + AssertKeeps(gesture, nameof(gesture.Scope), () => gesture.Scope, v => gesture.Scope = v); + + var theme = new ThemePageViewModel(store, loc, AppHost.Get()); + AssertKeeps(theme, nameof(theme.Mode), () => theme.Mode, v => theme.Mode = v); + + var general = ActivatorUtilitiesCreate(); + AssertKeeps(general, nameof(general.Language), () => general.Language, v => general.Language = v); + AssertKeeps(general, nameof(general.DefaultLocation), () => general.DefaultLocation, v => general.DefaultLocation = v); + + var editor = new PresetEditorViewModel(store.FindPreset("to-png")!, loc); + AssertKeeps(editor, nameof(editor.Target), () => editor.Target, v => editor.Target = v); + AssertKeeps(editor, nameof(editor.Resize), () => editor.Resize, v => editor.Resize = v); + AssertKeeps(editor, nameof(editor.Rotation), () => editor.Rotation, v => editor.Rotation = v); + AssertKeeps(editor, nameof(editor.Flip), () => editor.Flip, v => editor.Flip = v); + AssertKeeps(editor, nameof(editor.PageSize), () => editor.PageSize, v => editor.PageSize = v); + AssertKeeps(editor, nameof(editor.Location), () => editor.Location, v => editor.Location = v); + AssertKeeps(editor, nameof(editor.Conflict), () => editor.Conflict, v => editor.Conflict = v); + AssertKeeps(editor, nameof(editor.TiffCompression), () => editor.TiffCompression, v => editor.TiffCompression = v); + AssertKeeps(editor, nameof(editor.MediaQuality), () => editor.MediaQuality, v => editor.MediaQuality = v); + AssertKeeps(editor, nameof(editor.MaxHeight), () => editor.MaxHeight, v => editor.MaxHeight = v); + AssertKeeps(editor, nameof(editor.AudioBitrate), () => editor.AudioBitrate, v => editor.AudioBitrate = v); + AssertKeeps(editor, nameof(editor.AudioChannels), () => editor.AudioChannels, v => editor.AudioChannels = v); + AssertKeeps(editor, nameof(editor.SampleRate), () => editor.SampleRate, v => editor.SampleRate = v); + AssertKeeps(editor, nameof(editor.WavFormat), () => editor.WavFormat, v => editor.WavFormat = v); + AssertKeeps(editor, nameof(editor.ArchiveLevel), () => editor.ArchiveLevel, v => editor.ArchiveLevel = v); + AssertKeeps(editor, nameof(editor.TraceMode), () => editor.TraceMode, v => editor.TraceMode = v); + AssertKeeps(editor, nameof(editor.VideoCodecChoice), () => editor.VideoCodecChoice, v => editor.VideoCodecChoice = v); + AssertKeeps(editor, nameof(editor.AudioCodecChoice), () => editor.AudioCodecChoice, v => editor.AudioCodecChoice = v); + Assert.True(editor.Apply()); + + var nav = new MainWindowViewModel(AppHost.Services, loc, AppHost.Get()); + AssertKeeps(nav, nameof(nav.SelectedItem), () => nav.SelectedItem, v => nav.SelectedItem = v); + + var rename = new RenameToolViewModel("", "{name}", [new RenameStep()], [], (_, _, name) => name, loc); + var step = rename.Steps[0]; + AssertKeeps(step, nameof(step.Kind), () => step.Kind, v => step.Kind = v); + + var watch = ActivatorUtilitiesCreate(); + watch.AddCommand.Execute(null); + var rule = watch.Rules[^1]; + try + { + AssertKeeps(rule, nameof(rule.Preset), () => rule.Preset!, v => rule.Preset = v); + AssertKeeps(rule, nameof(rule.Originals), () => rule.Originals, v => rule.Originals = v); + Assert.Equal(rule.Preset!.Value, rule.Rule.PresetId); + } + finally + { + store.Settings.WatchFolders.Remove(rule.Rule); + store.SaveSettings(); + watch.Dispose(); + } + } + + private static T ActivatorUtilitiesCreate() => + Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(AppHost.Services); + + private static void AssertKeeps(System.ComponentModel.INotifyPropertyChanged vm, string property, Func get, Action set) + where T : class + { + var before = get(); + Assert.NotNull(before); + var announced = false; + void OnChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => announced |= e.PropertyName == property; + vm.PropertyChanged += OnChanged; + try + { + set(null!); + } + finally + { + vm.PropertyChanged -= OnChanged; + } + Assert.Equal(before, get()); // the codec choices are rebuilt on every read: equal, not the same + Assert.True(announced, $"{property} did not announce its value again"); + } +}