diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 9ad8ebecc..ea70f0a1f 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,14 @@ public partial class App : Application private IDisposable? _convertDebounce; private bool _selectionPending; - public override void Initialize() => AvaloniaXamlLoader.Load(this); + /// 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); + SingleSelectionGuard.Register(); + } public override void OnFrameworkInitializationCompleted() { @@ -34,6 +42,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 +53,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 +85,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 +128,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 +141,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 +231,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 df5148162..a9e58ed27 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 911d0f3ba..2fca389d8 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 d6f4f5b8d..04414b378 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/Controls/SingleSelectionGuard.cs b/src/Filee.App/Controls/SingleSelectionGuard.cs new file mode 100644 index 000000000..6b48ef51b --- /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/Program.cs b/src/Filee.App/Program.cs index 45d9e23ce..bcd7fa63e 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 5b03168fe..f5bbd3807 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 000000000..33291e8cb --- /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 4de17498a..6a26a98e1 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 f09685a04..9d1dbf831 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 000000000..d539fecbf --- /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 202d9bc4a..789759aa9 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 64285de37..4f1c12479 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 000000000..aa6df5519 --- /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 000000000..8d397c180 --- /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 1c23eec7b..be5014526 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/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs index c64dcc4f9..629d9a810 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 cd03b1fec..623d5b346 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/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index 274e7c6df..189d3d26e 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/ViewModels/Pages/ThemePageViewModel.cs b/src/Filee.App/ViewModels/Pages/ThemePageViewModel.cs index c93ecbd47..badc7bc38 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 cf1c8bd22..9e5c8ae17 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 8e60dd376..e32635cd2 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 45382072c..2541b32a6 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 c26b5d327..adc373aa5 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/src/Filee.App/Views/ElevationNoticeWindow.axaml b/src/Filee.App/Views/ElevationNoticeWindow.axaml new file mode 100644 index 000000000..6eaa795af --- /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 cf7b04072..0cfaab790 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 584765fc2..886923e14 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 000000000..26b6a94bb --- /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 7a440a48a..5a1b0d9fc 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 77db4ef38..4e28b23f5 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/Infrastructure/engines.json b/src/Filee.Engines/Infrastructure/engines.json index b26578051..a9bb55111 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" diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index 5bc2877a7..05ab40fdd 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 000000000..22f2ce53a --- /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 b4521c18a..e936aaf1c 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 103f25587..16c3bb6d1 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 000000000..036e85ca2 --- /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/SelectionGuardTests.cs b/tests/Filee.App.Tests/SelectionGuardTests.cs new file mode 100644 index 000000000..5ecc80daa --- /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"); + } +} diff --git a/tests/Filee.App.Tests/SingleInstanceTests.cs b/tests/Filee.App.Tests/SingleInstanceTests.cs index dec9a0874..d83b8f56d 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 000000000..fcc5c7fef --- /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 000000000..138b8fe01 --- /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"); +}