Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions src/Filee.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,7 +23,14 @@ public partial class App : Application
private IDisposable? _convertDebounce;
private bool _selectionPending;

public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <summary>Forwarded launches and empty selections can come in bursts: a few lines a minute are enough.</summary>
private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30));

public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
SingleSelectionGuard.Register();
}

public override void OnFrameworkInitializationCompleted()
{
Expand All @@ -34,6 +42,9 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}

/// <summary>Ends Filee as the tray's Quit does.</summary>
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
Expand All @@ -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<ILogger<App>>();
// 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<ILogger<UiWatchdog>>());
watchdog.Start();
var store = AppHost.Get<UserDataStore>();
store.Load();

var loc = AppHost.Get<LocalizationService>();
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<ConverterCatalog>().CheckInBackgroundAsync();

// Global gestures → donut toolbar
var triggers = AppHost.Get<TriggerService>();
Expand All @@ -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)
{
Expand Down Expand Up @@ -104,20 +128,34 @@ 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<TriggerService>().Dispose();
AppHost.Get<TrayService>().Dispose();
AppHost.Get<WatchFolderService>().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5));
};

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<ElevationService>().ShellCannotReachFilee)
DispatcherTimer.RunOnce(() => AppHost.Get<WindowService>().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();
}

/// <summary>
Expand Down Expand Up @@ -193,8 +231,13 @@ private static void ApplySettings(AppSettings settings)
AppHost.Get<SystemIntegrationService>().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<ILogger<App>>().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).
Expand Down
7 changes: 7 additions & 0 deletions src/Filee.App/Assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/Filee.App/Assets/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "폴더 열기",
Expand Down
7 changes: 7 additions & 0 deletions src/Filee.App/Assets/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "打开文件夹",
Expand Down
76 changes: 76 additions & 0 deletions src/Filee.App/Controls/SingleSelectionGuard.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Installs the class handlers once for every combo box and list box of the application.</summary>
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<ListBoxItem>(OnKeyDown, RoutingStrategies.Tunnel);
InputElement.PointerPressedEvent.AddClassHandler<ListBoxItem>(OnPointer, RoutingStrategies.Tunnel);
InputElement.PointerReleasedEvent.AddClassHandler<ListBoxItem>(OnPointer, RoutingStrategies.Tunnel);
SelectingItemsControl.SelectedItemProperty.Changed.AddClassHandler<SelectingItemsControl>(OnSelectedItemChanged);
}

/// <summary>
/// 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).
/// </summary>
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);
}

/// <summary>The selected item of a single-selection control, picked again with the toggle modifier held.</summary>
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,
};

/// <summary>Picking the item that is already selected: nothing changes, an open combo box closes as after any pick.</summary>
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);
}
}
Loading
Loading