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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs b/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs
new file mode 100644
index 000000000..76dd5cd45
--- /dev/null
+++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml.cs
@@ -0,0 +1,51 @@
+// Bottom-right card saying Filee runs as administrator, with the restart that fixes it (ElevationService).
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Filee.App.Services;
+
+namespace Filee.App.Views;
+
+/// One-time notice for an elevated Filee. "Restart" starts it again through Explorer and quits this one.
+public partial class ElevationNoticeWindow : Window
+{
+ private const int ScreenMargin = 12;
+ private readonly ElevationService? _elevation;
+
+ /// For the XAML previewer.
+ public ElevationNoticeWindow() : this(null)
+ {
+ }
+
+ public ElevationNoticeWindow(ElevationService? elevation)
+ {
+ _elevation = elevation;
+ InitializeComponent();
+ Motion.Track(this);
+ SizeChanged += (_, _) => PlaceBottomRight();
+ }
+
+ private void OnRestart(object? sender, RoutedEventArgs e)
+ {
+ if (_elevation is null)
+ return;
+ if (_elevation.RestartWithoutAdministratorRights())
+ Close();
+ else
+ FailedText.IsVisible = true;
+ }
+
+ private void OnClose(object? sender, RoutedEventArgs e) => Close();
+
+ private void PlaceBottomRight()
+ {
+ var screen = Screens.Primary;
+ if (screen is null)
+ return;
+ var area = screen.WorkingArea;
+ var width = (int)Math.Ceiling(Bounds.Width * screen.Scaling);
+ var height = (int)Math.Ceiling(Bounds.Height * screen.Scaling);
+ Position = new PixelPoint(area.Right - width - ScreenMargin, area.Bottom - height - ScreenMargin);
+ }
+}
diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml
index 4eab3558f..65434a922 100644
--- a/src/Filee.App/Views/Pages/HomePage.axaml
+++ b/src/Filee.App/Views/Pages/HomePage.axaml
@@ -39,6 +39,21 @@
+
+
+
+
+
+
+
+
+
+
+
+ /// The first drag over the drop zone of this run is logged: a log without it after "drops don't work" means the
+ /// drag never reached Filee (Windows refuses drops from Explorer into a Filee running as administrator).
+ ///
+ 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