From f0e5609f79c8c351aafdb98404feea5bbbef83ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 20:55:47 +0300 Subject: [PATCH 1/4] Isolate Windows-only actions and shell UX (P4-H) Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Co-Authored-By: Claude Sonnet 5.5 --- .../InstallCertificateAction.Windows.cs | 15 + .../Install/InstallCertificateAction.cs | 9 +- .../Content/Install/InstallFontAction.cs | 9 +- .../Install/InstallInfDriverAction.Windows.cs | 15 + .../Content/Install/InstallInfDriverAction.cs | 9 +- .../Content/Run/BaseRunAsAction.Windows.cs | 15 + .../Actions/Content/Run/BaseRunAsAction.cs | 11 +- .../Run/RunWithPowershellAction.Windows.cs | 20 + .../Content/Run/RunWithPowershellAction.cs | 12 +- .../Actions/Content/Share/ShareItemAction.cs | 4 + ...CreateAlternateDataStreamAction.Windows.cs | 83 ++++ .../CreateAlternateDataStreamAction.cs | 76 +--- .../FileSystem/CreateShortcutAction.cs | 4 + .../CreateShortcutFromDialogAction.cs | 4 + .../FileSystem/PasteItemAsShortcutAction.cs | 4 + .../Open/EditInNotepadAction.Windows.cs | 15 + .../Actions/Open/EditInNotepadAction.cs | 8 +- .../OpenClassicPropertiesAction.Windows.cs | 38 ++ .../Open/OpenClassicPropertiesAction.cs | 29 +- src/Files.App/Actions/Open/OpenInIDEAction.cs | 2 + .../Actions/Open/OpenRepoInIDEAction.cs | 11 + .../Actions/Open/OpenSettingsFileAction.cs | 4 + .../Open/OpenTerminalAction.Windows.cs | 90 +++++ .../Actions/Open/OpenTerminalAction.cs | 77 +--- .../Actions/Open/OpenTerminalAsAdminAction.cs | 2 + .../Actions/Start/PinToStartAction.cs | 4 + .../Actions/Start/UnpinFromStartAction.cs | 6 + ...vancedAccessControlItemFactory.Windows.cs} | 2 +- .../ShellContextFlyoutHelper.Windows.cs | 257 ++++++++++++ .../Factories/ShellContextFlyoutHelper.cs | 231 +---------- src/Files.App/Data/Items/ContextMenu.cs | 4 + src/Files.App/Data/Items/ShellLinkItem.cs | 4 +- .../Navigation/NavigationHelpers.Windows.cs | 349 +++++++++++++++++ .../Helpers/Navigation/NavigationHelpers.cs | 328 +--------------- .../UserControls/Toolbar.xaml.Windows.cs | 82 ++++ src/Files.App/UserControls/Toolbar.xaml.cs | 60 +-- .../Utils/Library/LibraryManager.Windows.cs | 363 +++++++++++++++++ src/Files.App/Utils/Library/LibraryManager.cs | 366 ++---------------- .../CreateShortcutDialogViewModel.Windows.cs | 66 ++++ .../Dialogs/CreateShortcutDialogViewModel.cs | 63 +-- 40 files changed, 1560 insertions(+), 1191 deletions(-) create mode 100644 src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs create mode 100644 src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs create mode 100644 src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs create mode 100644 src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs create mode 100644 src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs create mode 100644 src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs create mode 100644 src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs create mode 100644 src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs rename src/Files.App/Data/Factories/{SecurityAdvancedAccessControlItemFactory.cs => SecurityAdvancedAccessControlItemFactory.Windows.cs} (99%) create mode 100644 src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs create mode 100644 src/Files.App/Helpers/Navigation/NavigationHelpers.Windows.cs create mode 100644 src/Files.App/UserControls/Toolbar.xaml.Windows.cs create mode 100644 src/Files.App/Utils/Library/LibraryManager.Windows.cs create mode 100644 src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.Windows.cs diff --git a/src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs b/src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs new file mode 100644 index 000000000000..d714f5a0e590 --- /dev/null +++ b/src/Files.App/Actions/Content/Install/InstallCertificateAction.Windows.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; + +namespace Files.App.Actions +{ + internal sealed partial class InstallCertificateAction : ObservableObject, IAction + { + public async Task ExecuteAsync(object? parameter = null) + { + await ContextMenu.InvokeVerb("add", context.SelectedItems.Select(x => x.ItemPath).ToArray()); + } + } +} diff --git a/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs b/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs index 8663b0fe432c..7c23850cb87e 100644 --- a/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs +++ b/src/Files.App/Actions/Content/Install/InstallCertificateAction.cs @@ -36,10 +36,11 @@ public InstallCertificateAction() context.PropertyChanged += Context_PropertyChanged; } - public async Task ExecuteAsync(object? parameter = null) - { - await ContextMenu.InvokeVerb("add", context.SelectedItems.Select(x => x.ItemPath).ToArray()); - } +#if !WINDOWS + // LINUX-TODO(install): this Windows command is hidden on Linux. + public Task ExecuteAsync(object? parameter = null) + => Task.CompletedTask; +#endif private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/Content/Install/InstallFontAction.cs b/src/Files.App/Actions/Content/Install/InstallFontAction.cs index 8b080026b681..36e857ce2965 100644 --- a/src/Files.App/Actions/Content/Install/InstallFontAction.cs +++ b/src/Files.App/Actions/Content/Install/InstallFontAction.cs @@ -49,10 +49,11 @@ public async Task ExecuteAsync(object? parameter = null) var paths = context.SelectedItems.Select(item => item.ItemPath!).ToArray(); var outcome = ReturnResult.Success; var installed = (long)context.SelectedItems.Count; - if (OperatingSystem.IsWindows()) - await Win32Helper.InstallFontsAsync(paths, false); - else - (outcome, installed) = await InstallForCurrentUserAsync(paths); +#if WINDOWS + await Win32Helper.InstallFontsAsync(paths, false); +#else + (outcome, installed) = await InstallForCurrentUserAsync(paths); +#endif StatusCenterViewModel.RemoveItem(banner); var currentWorkingDirectory = context.ShellPage.GetRequiredShellViewModel().WorkingDirectory!; diff --git a/src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs new file mode 100644 index 000000000000..8337e323691b --- /dev/null +++ b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.Windows.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; + +namespace Files.App.Actions +{ + internal sealed partial class InstallInfDriverAction : ObservableObject, IAction + { + public async Task ExecuteAsync(object? parameter = null) + { + await Task.WhenAll(context.SelectedItems.Select(selectedItem => Win32Helper.InstallInf(selectedItem.ItemPath))); + } + } +} diff --git a/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs index e1e98b9dce9c..a21b475ac73c 100644 --- a/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs +++ b/src/Files.App/Actions/Content/Install/InstallInfDriverAction.cs @@ -36,10 +36,11 @@ public InstallInfDriverAction() context.PropertyChanged += Context_PropertyChanged; } - public async Task ExecuteAsync(object? parameter = null) - { - await Task.WhenAll(context.SelectedItems.Select(selectedItem => Win32Helper.InstallInf(selectedItem.ItemPath))); - } +#if !WINDOWS + // LINUX-TODO(install): this Windows command is hidden on Linux. + public Task ExecuteAsync(object? parameter = null) + => Task.CompletedTask; +#endif public void Context_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs b/src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs new file mode 100644 index 000000000000..04cf7f80f29c --- /dev/null +++ b/src/Files.App/Actions/Content/Run/BaseRunAsAction.Windows.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + + + +namespace Files.App.Actions +{ + internal abstract partial class BaseRunAsAction : ObservableObject, IAction + { + public async Task ExecuteAsync(object? parameter = null) + { + await ContextMenu.InvokeVerb(_verb, _context.SelectedItem!.ItemPath); + } + } +} diff --git a/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs b/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs index 9fcab5d96849..70ad7c51d1e8 100644 --- a/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs +++ b/src/Files.App/Actions/Content/Run/BaseRunAsAction.cs @@ -3,7 +3,7 @@ namespace Files.App.Actions { - internal abstract class BaseRunAsAction : ObservableObject, IAction + internal abstract partial class BaseRunAsAction : ObservableObject, IAction { private readonly IContentPageContext _context; @@ -28,10 +28,11 @@ public BaseRunAsAction(string verb) _context.PropertyChanged += Context_PropertyChanged; } - public async Task ExecuteAsync(object? parameter = null) - { - await ContextMenu.InvokeVerb(_verb, _context.SelectedItem!.ItemPath); - } +#if !WINDOWS + // LINUX-TODO(launching): Windows shell elevation verbs are hidden on Linux. + public Task ExecuteAsync(object? parameter = null) + => Task.CompletedTask; +#endif public void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs new file mode 100644 index 000000000000..5f27a9a5f12f --- /dev/null +++ b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.Windows.cs @@ -0,0 +1,20 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; + +namespace Files.App.Actions +{ + internal sealed partial class RunWithPowershellAction : ObservableObject, IAction + { + public Task ExecuteAsync(object? parameter = null) + { + var itemPath = context.ShellPage?.SlimContentPage?.SelectedItem?.ItemPath; + return Win32Helper.RunPowershellCommandAsync( + $"& {Win32Helper.ToPowerShellStringLiteral(itemPath)}", + PowerShellExecutionOptions.None, + context.Folder?.ItemPath + ); + } + } +} diff --git a/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs index b76e5e2b897d..83e77db69d73 100644 --- a/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs +++ b/src/Files.App/Actions/Content/Run/RunWithPowershellAction.cs @@ -35,15 +35,11 @@ public RunWithPowershellAction() context.PropertyChanged += Context_PropertyChanged; } +#if !WINDOWS + // LINUX-TODO(launching): this Windows command is hidden on Linux. public Task ExecuteAsync(object? parameter = null) - { - var itemPath = context.ShellPage?.SlimContentPage?.SelectedItem?.ItemPath; - return Win32Helper.RunPowershellCommandAsync( - $"& {Win32Helper.ToPowerShellStringLiteral(itemPath)}", - PowerShellExecutionOptions.None, - context.Folder?.ItemPath - ); - } + => Task.CompletedTask; +#endif private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/Content/Share/ShareItemAction.cs b/src/Files.App/Actions/Content/Share/ShareItemAction.cs index 9cee9a7bab80..0c42a1327d13 100644 --- a/src/Files.App/Actions/Content/Share/ShareItemAction.cs +++ b/src/Files.App/Actions/Content/Share/ShareItemAction.cs @@ -29,6 +29,7 @@ public string AccessKey => "H"; public bool IsExecutable => + OperatingSystem.IsWindows() && IsContextPageTypeAdaptedToCommand() && ShareItemHelpers.IsSupported() && context.SelectedItems.Any() && @@ -43,6 +44,9 @@ public ShareItemAction() public Task ExecuteAsync(object? parameter = null) { + if (!IsExecutable) + return Task.CompletedTask; + return ShareItemHelpers.ShareItemsAsync(context.SelectedItems); } diff --git a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs new file mode 100644 index 000000000000..3e4030c2da29 --- /dev/null +++ b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.Windows.cs @@ -0,0 +1,83 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation.Metadata; + +namespace Files.App.Actions +{ + internal sealed partial class CreateAlternateDataStreamAction : BaseUIAction, IAction + { + public async Task ExecuteAsync(object? parameter = null) + { + var nameDialog = DynamicDialogFactory.GetFor_CreateAlternateDataStreamDialog(); + await nameDialog.TryShowAsync(); + + if (nameDialog.DynamicResult != DynamicDialogResult.Primary) + return; + + var userInput = nameDialog.ViewModel.AdditionalData as string; + await Task.WhenAll(context.SelectedItems.Select(async selectedItem => + { + var itemPath = selectedItem.ItemPath!; + var isDateOk = Win32Helper.GetFileDateModified(itemPath, out var dateModified); + var isReadOnly = Win32Helper.HasFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly); + + // Unset read-only attribute (#7534) + if (isReadOnly) + Win32Helper.UnsetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly); + + if (!Win32Helper.WriteStringToFile($"{itemPath}:{userInput}", "")) + { + var dialog = new ContentDialog + { + Title = Strings.ErrorCreatingDataStreamTitle.GetLocalizedResource(), + Content = Strings.ErrorCreatingDataStreamDescription.GetLocalizedResource(), + PrimaryButtonText = "Ok".GetLocalizedResource() + }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + await dialog.TryShowAsync(); + } + + // Restore read-only attribute (#7534) + if (isReadOnly) + Win32Helper.SetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly); + + // Restore date modified + if (isDateOk) + Win32Helper.SetFileDateModified(itemPath, dateModified); + })); + + if (context.ShellPage is null) + return; + + if (FoldersSettingsService.AreAlternateStreamsVisible) + await context.ShellPage.Refresh_Click(); + else if (ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt) + { + var dialog = new ContentDialog + { + Title = Strings.DataStreamsAreHiddenTitle.GetLocalizedResource(), + Content = Strings.DataStreamsAreHiddenDescription.GetLocalizedResource(), + PrimaryButtonText = Strings.Yes.GetLocalizedResource(), + SecondaryButtonText = Strings.DontShowAgain.GetLocalizedResource() + }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + var result = await dialog.TryShowAsync(); + if (result == ContentDialogResult.Primary) + { + FoldersSettingsService.AreAlternateStreamsVisible = true; + await context.ShellPage.Refresh_Click(); + } + else + ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt = false; + } + } + } +} diff --git a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs index d38d572b6326..8221b90df10c 100644 --- a/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs +++ b/src/Files.App/Actions/FileSystem/CreateAlternateDataStreamAction.cs @@ -39,77 +39,11 @@ public CreateAlternateDataStreamAction() context.PropertyChanged += Context_PropertyChanged; } - public async Task ExecuteAsync(object? parameter = null) - { - var nameDialog = DynamicDialogFactory.GetFor_CreateAlternateDataStreamDialog(); - await nameDialog.TryShowAsync(); - - if (nameDialog.DynamicResult != DynamicDialogResult.Primary) - return; - - var userInput = nameDialog.ViewModel.AdditionalData as string; - await Task.WhenAll(context.SelectedItems.Select(async selectedItem => - { - var itemPath = selectedItem.ItemPath!; - var isDateOk = Win32Helper.GetFileDateModified(itemPath, out var dateModified); - var isReadOnly = Win32Helper.HasFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly); - - // Unset read-only attribute (#7534) - if (isReadOnly) - Win32Helper.UnsetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly); - - if (!Win32Helper.WriteStringToFile($"{itemPath}:{userInput}", "")) - { - var dialog = new ContentDialog - { - Title = Strings.ErrorCreatingDataStreamTitle.GetLocalizedResource(), - Content = Strings.ErrorCreatingDataStreamDescription.GetLocalizedResource(), - PrimaryButtonText = "Ok".GetLocalizedResource() - }; - - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; - - await dialog.TryShowAsync(); - } - - // Restore read-only attribute (#7534) - if (isReadOnly) - Win32Helper.SetFileAttribute(itemPath, System.IO.FileAttributes.ReadOnly); - - // Restore date modified - if (isDateOk) - Win32Helper.SetFileDateModified(itemPath, dateModified); - })); - - if (context.ShellPage is null) - return; - - if (FoldersSettingsService.AreAlternateStreamsVisible) - await context.ShellPage.Refresh_Click(); - else if (ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt) - { - var dialog = new ContentDialog - { - Title = Strings.DataStreamsAreHiddenTitle.GetLocalizedResource(), - Content = Strings.DataStreamsAreHiddenDescription.GetLocalizedResource(), - PrimaryButtonText = Strings.Yes.GetLocalizedResource(), - SecondaryButtonText = Strings.DontShowAgain.GetLocalizedResource() - }; - - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - dialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; - - var result = await dialog.TryShowAsync(); - if (result == ContentDialogResult.Primary) - { - FoldersSettingsService.AreAlternateStreamsVisible = true; - await context.ShellPage.Refresh_Click(); - } - else - ApplicationSettingsService.ShowDataStreamsAreHiddenPrompt = false; - } - } +#if !WINDOWS + // LINUX-TODO(streams): this Windows command is hidden on Linux. + public Task ExecuteAsync(object? parameter = null) + => Task.CompletedTask; +#endif private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs b/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs index bd632e1acf2d..97e3fdab0833 100644 --- a/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs +++ b/src/Files.App/Actions/FileSystem/CreateShortcutAction.cs @@ -24,6 +24,7 @@ public RichGlyph Glyph => new(themedIconStyle: "App.ThemedIcons.URL"); public override bool IsExecutable => + Ioc.Default.GetRequiredService().SupportsShortcutFiles && context.HasSelection && context.CanCreateItem && UIHelpers.CanShowDialog; @@ -37,6 +38,9 @@ public CreateShortcutAction() public Task ExecuteAsync(object? parameter = null) { + if (!IsExecutable) + return Task.CompletedTask; + return UIFilesystemHelpers.CreateShortcutAsync(context.ShellPage, context.SelectedItems); } diff --git a/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs b/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs index 8b1af64ada0d..e34eddf4ccd4 100644 --- a/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs +++ b/src/Files.App/Actions/FileSystem/CreateShortcutFromDialogAction.cs @@ -30,6 +30,7 @@ public RichGlyph Glyph => new("\uE71B"); public override bool IsExecutable => + Ioc.Default.GetRequiredService().SupportsShortcutFiles && context.CanCreateItem && UIHelpers.CanShowDialog; @@ -42,6 +43,9 @@ public CreateShortcutFromDialogAction() public Task ExecuteAsync(object? parameter = null) { + if (!IsExecutable) + return Task.CompletedTask; + return UIFilesystemHelpers.CreateShortcutFromDialogAsync(context.ShellPage!); } diff --git a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs index 7cfcc9f47adb..a0c689340b32 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs @@ -33,6 +33,9 @@ public PasteItemAsShortcutAction() public Task ExecuteAsync(object? parameter = null) { + if (!IsExecutable) + return Task.CompletedTask; + if (context.ShellPage is not { } shellPage) return Task.CompletedTask; @@ -44,6 +47,7 @@ public Task ExecuteAsync(object? parameter = null) public bool GetIsExecutable() { return + Ioc.Default.GetRequiredService().SupportsShortcutFiles && App.AppModel.IsPasteEnabled && context.PageType != ContentPageTypes.Home && context.PageType != ContentPageTypes.RecycleBin && diff --git a/src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs b/src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs new file mode 100644 index 000000000000..08f864806a27 --- /dev/null +++ b/src/Files.App/Actions/Open/EditInNotepadAction.Windows.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; + +namespace Files.App.Actions +{ + internal sealed partial class EditInNotepadAction : ObservableObject, IAction + { + public Task ExecuteAsync(object? parameter = null) + { + return Task.WhenAll(context.SelectedItems.Select(item => Win32Helper.RunPowershellCommandAsync($"& 'notepad.exe' {Win32Helper.ToPowerShellStringLiteral(item.ItemPath)}", PowerShellExecutionOptions.Hidden))); + } + } +} diff --git a/src/Files.App/Actions/Open/EditInNotepadAction.cs b/src/Files.App/Actions/Open/EditInNotepadAction.cs index b785df60d946..25b5061b4d91 100644 --- a/src/Files.App/Actions/Open/EditInNotepadAction.cs +++ b/src/Files.App/Actions/Open/EditInNotepadAction.cs @@ -23,6 +23,7 @@ public RichGlyph Glyph => new("\uE70F"); public bool IsExecutable => + OperatingSystem.IsWindows() && context.SelectedItems.Any() && context.PageType != ContentPageTypes.RecycleBin && context.PageType != ContentPageTypes.ZipFolder && @@ -35,10 +36,11 @@ public EditInNotepadAction() context.PropertyChanged += Context_PropertyChanged; } +#if !WINDOWS + // LINUX-TODO(launching): this Windows command is hidden on Linux. public Task ExecuteAsync(object? parameter = null) - { - return Task.WhenAll(context.SelectedItems.Select(item => Win32Helper.RunPowershellCommandAsync($"& 'notepad.exe' {Win32Helper.ToPowerShellStringLiteral(item.ItemPath)}", PowerShellExecutionOptions.Hidden))); - } + => Task.CompletedTask; +#endif private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs new file mode 100644 index 000000000000..52f9d1551189 --- /dev/null +++ b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.Windows.cs @@ -0,0 +1,38 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.UI.Shell; + +namespace Files.App.Actions +{ + internal sealed partial class OpenClassicPropertiesAction : ObservableObject, IAction + { + public Task ExecuteAsync(object? parameter = null) + { + if (context.HasSelection && context?.SelectedItem?.ItemPath is not null) + ExecuteShellCommand(context.SelectedItem.ItemPath); + else if (context?.Folder?.ItemPath is not null) + ExecuteShellCommand(context.Folder.ItemPath); + + return Task.CompletedTask; + } + + private unsafe void ExecuteShellCommand(string itemPath) + { + SHELLEXECUTEINFOW info = default; + info.cbSize = (uint)Marshal.SizeOf(info); + info.nShow = 5; // SW_SHOW + info.fMask = 0x0000000C; // SEE_MASK_INVOKEIDLIST + + fixed (char* cVerb = "properties", lpFile = itemPath) + { + info.lpVerb = cVerb; + info.lpFile = lpFile; + + PInvoke.ShellExecuteEx(ref info); + } + } + } +} diff --git a/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs index f2a139a1e7da..f52f3d13bea8 100644 --- a/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs +++ b/src/Files.App/Actions/Open/OpenClassicPropertiesAction.cs @@ -2,8 +2,6 @@ // SPDX-License-Identifier: MPL-2.0 using System.Runtime.InteropServices; -using Windows.Win32; -using Windows.Win32.UI.Shell; namespace Files.App.Actions { @@ -43,31 +41,12 @@ public OpenClassicPropertiesAction() context.PropertyChanged += Context_PropertyChanged; } +#if !WINDOWS + // LINUX-TODO(properties): the Windows shell properties command is hidden on Linux. public Task ExecuteAsync(object? parameter = null) - { - if (context.HasSelection && context?.SelectedItem?.ItemPath is not null) - ExecuteShellCommand(context.SelectedItem.ItemPath); - else if (context?.Folder?.ItemPath is not null) - ExecuteShellCommand(context.Folder.ItemPath); - - return Task.CompletedTask; - } - - private unsafe void ExecuteShellCommand(string itemPath) - { - SHELLEXECUTEINFOW info = default; - info.cbSize = (uint)Marshal.SizeOf(info); - info.nShow = 5; // SW_SHOW - info.fMask = 0x0000000C; // SEE_MASK_INVOKEIDLIST + => Task.CompletedTask; +#endif - fixed (char* cVerb = "properties", lpFile = itemPath) - { - info.lpVerb = cVerb; - info.lpFile = lpFile; - - PInvoke.ShellExecuteEx(ref info); - } - } private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { diff --git a/src/Files.App/Actions/Open/OpenInIDEAction.cs b/src/Files.App/Actions/Open/OpenInIDEAction.cs index 7a548b55f7b6..6a6ba511aa0c 100644 --- a/src/Files.App/Actions/Open/OpenInIDEAction.cs +++ b/src/Files.App/Actions/Open/OpenInIDEAction.cs @@ -51,6 +51,7 @@ public async Task ExecuteAsync(object? parameter = null) return; } +#if WINDOWS var res = await Win32Helper.RunPowershellCommandAsync( $"& {Win32Helper.ToPowerShellStringLiteral(_devToolsSettingsService.IDEPath)} {Win32Helper.ToPowerShellStringLiteral(workingDirectory)}", PowerShellExecutionOptions.Hidden @@ -58,6 +59,7 @@ public async Task ExecuteAsync(object? parameter = null) if (!res) await DynamicDialogFactory.ShowFor_IDEErrorDialog(_devToolsSettingsService.IDEName); +#endif } private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs b/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs index d62ebbdbc175..e7653a00be76 100644 --- a/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs +++ b/src/Files.App/Actions/Open/OpenRepoInIDEAction.cs @@ -34,6 +34,7 @@ public OpenRepoInIDEAction() public async Task ExecuteAsync(object? parameter = null) { +#if WINDOWS var res = await Win32Helper.RunPowershellCommandAsync( $"& {Win32Helper.ToPowerShellStringLiteral(_devToolsSettingsService.IDEPath)} {Win32Helper.ToPowerShellStringLiteral(_context.ShellPage!.InstanceViewModel.GitRepositoryPath)}", PowerShellExecutionOptions.Hidden @@ -41,6 +42,16 @@ public async Task ExecuteAsync(object? parameter = null) if (!res) await DynamicDialogFactory.ShowFor_IDEErrorDialog(_devToolsSettingsService.IDEName); +#else + var path = _context.ShellPage?.InstanceViewModel.GitRepositoryPath; + if (!SystemIO.Path.IsPathRooted(path)) + return; + + var launched = await Ioc.Default.GetRequiredService() + .StartAsync(_devToolsSettingsService.IDEPath, [path]); + if (!launched) + await DynamicDialogFactory.ShowFor_IDEErrorDialog(_devToolsSettingsService.IDEName); +#endif } private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) diff --git a/src/Files.App/Actions/Open/OpenSettingsFileAction.cs b/src/Files.App/Actions/Open/OpenSettingsFileAction.cs index 629311c80bc7..567fad8b7732 100644 --- a/src/Files.App/Actions/Open/OpenSettingsFileAction.cs +++ b/src/Files.App/Actions/Open/OpenSettingsFileAction.cs @@ -32,8 +32,12 @@ public async Task ExecuteAsync(object? parameter = null) try { var settingsJsonFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri($"ms-appdata:///local/{Constants.LocalSettings.SettingsFolderName}/{Constants.LocalSettings.UserSettingsFileName}")); +#if WINDOWS if (!await Launcher.LaunchFileAsync(settingsJsonFile)) await ContextMenu.InvokeVerb("open", settingsJsonFile.Path); +#else + await NavigationHelpers.OpenFileLinuxAsync(settingsJsonFile.Path); +#endif } catch (Exception ex) { diff --git a/src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs b/src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs new file mode 100644 index 000000000000..0915dcca46f9 --- /dev/null +++ b/src/Files.App/Actions/Open/OpenTerminalAction.Windows.cs @@ -0,0 +1,90 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Win32; +using Windows.Storage; +using Windows.Win32; +using Windows.Win32.System.Com; + +namespace Files.App.Actions +{ + internal partial class OpenTerminalAction : ObservableObject, IAction + { + // DelegationTerminal CLSIDs registered by Windows Terminal. When one of these + // is the user's default, launching wt.exe gives the user's chosen profile and + // supports multi-tab. Source: microsoft/terminal policies/WindowsTerminal.admx. + private static readonly Guid[] WindowsTerminalDelegationClsids = + [ + new([0x52, 0xFF, 0x2C, 0xE1, 0x66, 0xA8, 0x77, 0x4C, 0x9A, 0x90, 0xF5, 0x70, 0xA7, 0xAA, 0x2C, 0x6B]), // Windows Terminal (stable) + new([0x1F, 0x3F, 0x63, 0x86, 0x54, 0x64, 0xEC, 0x40, 0x89, 0xCE, 0xDA, 0x4E, 0xBA, 0x97, 0x7E, 0xE2]), // Windows Terminal Preview + ]; + + + protected virtual ProcessStartInfo? GetProcessStartInfo(string[] paths) + { + if (paths.Length is 0) + return null; + + if (IsWindowsTerminalDefault()) + { + var startInfo = new ProcessStartInfo + { + FileName = "wt.exe", + UseShellExecute = false, + ArgumentList = { "-d", paths[0] } + }; + + for (int i = 1; i < paths.Length; i++) + { + startInfo.ArgumentList.Add(";"); + startInfo.ArgumentList.Add("nt"); + startInfo.ArgumentList.Add("-d"); + startInfo.ArgumentList.Add(paths[i]); + } + + return startInfo; + } + + // Launch cmd.exe when Windows Terminal is not the effective default host. + return new() + { + FileName = "cmd.exe", + WorkingDirectory = paths[0], + UseShellExecute = true + }; + } + + private static unsafe bool IsWindowsTerminalDefault() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(@"Console\%%Startup"); + var consoleClsid = Guid.TryParse(key?.GetValue("DelegationConsole") as string, out var consoleClsidResult) ? consoleClsidResult : Guid.Empty; + var terminalClsid = Guid.TryParse(key?.GetValue("DelegationTerminal") as string, out var terminalClsidResult) ? terminalClsidResult : Guid.Empty; + + // Windows treats either missing or zero CLSID as "Let Windows decide". + if (consoleClsid == Guid.Empty || terminalClsid == Guid.Empty) + { + // Windows 11 22H2 introduced Terminal as the automatic default. + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22621)) + return false; + + // Match conhost's IDefaultTerminalMarker probe on the stable console server. + // Source: microsoft/terminal src/server/IoDispatchers.cpp. + consoleClsid = new([0x47, 0xA9, 0xAC, 0x2E, 0x5F, 0x7F, 0xFA, 0x4C, 0xBA, 0x87, 0x8F, 0x7F, 0xBE, 0xEF, 0xBE, 0x69]); + Guid markerIid = new([0xC0, 0x6B, 0x6E, 0x74, 0x05, 0xAB, 0x38, 0x4E, 0xAB, 0x14, 0x71, 0xE8, 0x67, 0x63, 0x14, 0x1F]); + var result = PInvoke.CoCreateInstance(&consoleClsid, null, CLSCTX.CLSCTX_LOCAL_SERVER, &markerIid, out var marker); + return result.Succeeded && marker is not null; + } + + return consoleClsid != new Guid([0xC0, 0x10, 0x3D, 0xB2, 0x2E, 0xE5, 0x1E, 0x41, 0x9D, 0x5B, 0xC0, 0x9F, 0xDF, 0x70, 0x9C, 0x7D]) + && WindowsTerminalDelegationClsids.Contains(terminalClsid); + } + catch + { + } + + return false; + } + } +} diff --git a/src/Files.App/Actions/Open/OpenTerminalAction.cs b/src/Files.App/Actions/Open/OpenTerminalAction.cs index 3321a0659a97..ed64ef6a2804 100644 --- a/src/Files.App/Actions/Open/OpenTerminalAction.cs +++ b/src/Files.App/Actions/Open/OpenTerminalAction.cs @@ -1,10 +1,7 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Microsoft.Win32; using Windows.Storage; -using Windows.Win32; -using Windows.Win32.System.Com; namespace Files.App.Actions { @@ -13,14 +10,7 @@ internal partial class OpenTerminalAction : ObservableObject, IAction { private readonly IContentPageContext context; - // DelegationTerminal CLSIDs registered by Windows Terminal. When one of these - // is the user's default, launching wt.exe gives the user's chosen profile and - // supports multi-tab. Source: microsoft/terminal policies/WindowsTerminal.admx. - private static readonly Guid[] WindowsTerminalDelegationClsids = - [ - new([0x52, 0xFF, 0x2C, 0xE1, 0x66, 0xA8, 0x77, 0x4C, 0x9A, 0x90, 0xF5, 0x70, 0xA7, 0xAA, 0x2C, 0x6B]), // Windows Terminal (stable) - new([0x1F, 0x3F, 0x63, 0x86, 0x54, 0x64, 0xEC, 0x40, 0x89, 0xCE, 0xDA, 0x4E, 0xBA, 0x97, 0x7E, 0xE2]), // Windows Terminal Preview - ]; + public virtual string Label #if WINDOWS @@ -91,72 +81,7 @@ private static async Task OpenLinuxTerminalsAsync(string[] paths) } #endif - protected virtual ProcessStartInfo? GetProcessStartInfo(string[] paths) - { - if (paths.Length is 0) - return null; - if (IsWindowsTerminalDefault()) - { - var startInfo = new ProcessStartInfo - { - FileName = "wt.exe", - UseShellExecute = false, - ArgumentList = { "-d", paths[0] } - }; - - for (int i = 1; i < paths.Length; i++) - { - startInfo.ArgumentList.Add(";"); - startInfo.ArgumentList.Add("nt"); - startInfo.ArgumentList.Add("-d"); - startInfo.ArgumentList.Add(paths[i]); - } - - return startInfo; - } - - // Launch cmd.exe when Windows Terminal is not the effective default host. - return new() - { - FileName = "cmd.exe", - WorkingDirectory = paths[0], - UseShellExecute = true - }; - } - - private static unsafe bool IsWindowsTerminalDefault() - { - try - { - using var key = Registry.CurrentUser.OpenSubKey(@"Console\%%Startup"); - var consoleClsid = Guid.TryParse(key?.GetValue("DelegationConsole") as string, out var consoleClsidResult) ? consoleClsidResult : Guid.Empty; - var terminalClsid = Guid.TryParse(key?.GetValue("DelegationTerminal") as string, out var terminalClsidResult) ? terminalClsidResult : Guid.Empty; - - // Windows treats either missing or zero CLSID as "Let Windows decide". - if (consoleClsid == Guid.Empty || terminalClsid == Guid.Empty) - { - // Windows 11 22H2 introduced Terminal as the automatic default. - if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22621)) - return false; - - // Match conhost's IDefaultTerminalMarker probe on the stable console server. - // Source: microsoft/terminal src/server/IoDispatchers.cpp. - consoleClsid = new([0x47, 0xA9, 0xAC, 0x2E, 0x5F, 0x7F, 0xFA, 0x4C, 0xBA, 0x87, 0x8F, 0x7F, 0xBE, 0xEF, 0xBE, 0x69]); - Guid markerIid = new([0xC0, 0x6B, 0x6E, 0x74, 0x05, 0xAB, 0x38, 0x4E, 0xAB, 0x14, 0x71, 0xE8, 0x67, 0x63, 0x14, 0x1F]); - var result = PInvoke.CoCreateInstance(&consoleClsid, null, CLSCTX.CLSCTX_LOCAL_SERVER, &markerIid, out var marker); - return result.Succeeded && marker is not null; - } - - return consoleClsid != new Guid([0xC0, 0x10, 0x3D, 0xB2, 0x2E, 0xE5, 0x1E, 0x41, 0x9D, 0x5B, 0xC0, 0x9F, 0xDF, 0x70, 0x9C, 0x7D]) - && WindowsTerminalDelegationClsids.Contains(terminalClsid); - } - catch - { - } - - return false; - } protected virtual string[] GetPaths() { diff --git a/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs b/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs index 936a560c8845..4cd69845f982 100644 --- a/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs +++ b/src/Files.App/Actions/Open/OpenTerminalAsAdminAction.cs @@ -22,6 +22,7 @@ public override HotKey HotKey public override bool IsExecutable => OperatingSystem.IsWindows() && base.IsExecutable; +#if WINDOWS protected override ProcessStartInfo? GetProcessStartInfo(string[] paths) { var startInfo = base.GetProcessStartInfo(paths); @@ -33,5 +34,6 @@ public override bool IsExecutable return startInfo; } +#endif } } diff --git a/src/Files.App/Actions/Start/PinToStartAction.cs b/src/Files.App/Actions/Start/PinToStartAction.cs index b138f8aed858..1c2b2b9c2ac8 100644 --- a/src/Files.App/Actions/Start/PinToStartAction.cs +++ b/src/Files.App/Actions/Start/PinToStartAction.cs @@ -25,6 +25,7 @@ public ActionCategory Category => ActionCategory.Start; public bool IsExecutable => + OperatingSystem.IsWindows() && context.ShellPage is not null; public PinToStartAction() @@ -34,6 +35,9 @@ public PinToStartAction() public async Task ExecuteAsync(object? parameter = null) { + if (!IsExecutable) + return; + if (context.SelectedItems.Count > 0 && context.ShellPage?.SlimContentPage?.SelectedItems is not null) { foreach (ListedItem listedItem in context.ShellPage.SlimContentPage.SelectedItems) diff --git a/src/Files.App/Actions/Start/UnpinFromStartAction.cs b/src/Files.App/Actions/Start/UnpinFromStartAction.cs index 7166d920ac35..06d819b860ba 100644 --- a/src/Files.App/Actions/Start/UnpinFromStartAction.cs +++ b/src/Files.App/Actions/Start/UnpinFromStartAction.cs @@ -24,6 +24,9 @@ public RichGlyph Glyph public ActionCategory Category => ActionCategory.Start; + public bool IsExecutable + => OperatingSystem.IsWindows() && context.ShellPage is not null; + public UnpinFromStartAction() { context = Ioc.Default.GetRequiredService(); @@ -31,6 +34,9 @@ public UnpinFromStartAction() public async Task ExecuteAsync(object? parameter = null) { + if (!IsExecutable) + return; + if (context.SelectedItems.Count > 0) { var selectedItems = context.ShellPage?.SlimContentPage?.SelectedItems diff --git a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.Windows.cs similarity index 99% rename from src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs rename to src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.Windows.cs index 5b91b8c85878..b138a0b106f8 100644 --- a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs +++ b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.Windows.cs @@ -1,4 +1,4 @@ -// Copyright (c) Files Community +// Copyright (c) Files Community // SPDX-License-Identifier: MPL-2.0 namespace Files.App.Data.Factories diff --git a/src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs b/src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs new file mode 100644 index 000000000000..e369c7a4fa2e --- /dev/null +++ b/src/Files.App/Data/Factories/ShellContextFlyoutHelper.Windows.cs @@ -0,0 +1,257 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using CommunityToolkit.WinUI; +using Files.App.Helpers.ContextFlyouts; +using Files.Shared.Helpers; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using System.IO; +using System.Text; +using Windows.System; +using Windows.UI.Core; +using Windows.Win32; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Files.App.Helpers +{ + public static partial class ShellContextFlyoutFactory + { + private static (string Label, string AccessKey) ExtractLabelAndAccessKey(string rawLabel) + { + if (string.IsNullOrEmpty(rawLabel)) + return (string.Empty, string.Empty); + + string accessKey = string.Empty; + var labelBuilder = new StringBuilder(rawLabel.Length); + + for (int i = 0; i < rawLabel.Length; i++) + { + char current = rawLabel[i]; + if (current != '&') + { + labelBuilder.Append(current); + continue; + } + + if (i + 1 >= rawLabel.Length) + { + labelBuilder.Append('&'); + continue; + } + + char next = rawLabel[++i]; + if (next == '&') + { + labelBuilder.Append('&'); + continue; + } + + if (string.IsNullOrEmpty(accessKey) && !char.IsWhiteSpace(next)) + accessKey = char.ToUpperInvariant(next).ToString(); + + labelBuilder.Append(next); + } + + return (labelBuilder.ToString(), accessKey); + } + + private static ContextMenuFlyoutItemViewModel CreateShellMenuItem(Win32ContextMenuItem menuFlyoutItem, BitmapImage? image) + { + var (label, accessKey) = ExtractLabelAndAccessKey(menuFlyoutItem.Label ?? string.Empty); + return new ContextMenuFlyoutItemViewModel + { + Text = label, + AccessKey = accessKey, + Tag = menuFlyoutItem, + BitmapIcon = image, + }; + } + + private static async Task LoadMenuFlyoutItemAsync( + List menuItemsListLocal, + ContextMenu contextMenu, + IEnumerable menuFlyoutItems, + CancellationToken cancellationToken, + bool showIcons = true, + int itemsBeforeOverflow = int.MaxValue) + { + if (cancellationToken.IsCancellationRequested) + return; + + var itemsCount = 0; // Separators do not count for reaching the overflow threshold + var menuItems = menuFlyoutItems.TakeWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR || ++itemsCount <= itemsBeforeOverflow).ToList(); + var overflowItems = menuFlyoutItems.Except(menuItems).ToList(); + + if (overflowItems.Any(x => x.Type != MENU_ITEM_TYPE.MFT_SEPARATOR)) + { + var moreItem = menuItemsListLocal.FirstOrDefault(x => x.ID == "ItemOverflow"); + if (moreItem is null) + { + var menuLayoutSubItem = new ContextMenuFlyoutItemViewModel() + { + Text = Strings.ShowMoreOptions.GetLocalizedResource(), + Glyph = "\xE712", + }; + await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items + ?? throw new InvalidOperationException("The shell overflow menu has not been initialized."), contextMenu, overflowItems, cancellationToken, showIcons); + menuItemsListLocal.Insert(0, menuLayoutSubItem); + } + else + { + await LoadMenuFlyoutItemAsync(moreItem.Items + ?? throw new InvalidOperationException("The shell overflow menu has not been initialized."), contextMenu, overflowItems, cancellationToken, showIcons); + } + } + + foreach (var menuFlyoutItem in menuItems + .SkipWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR) // Remove leading separators + .Reverse() + .SkipWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR)) // Remove trailing separators + { + if (cancellationToken.IsCancellationRequested) + break; + + // Avoid duplicate separators + if ((menuFlyoutItem.Type == MENU_ITEM_TYPE.MFT_SEPARATOR) && (menuItemsListLocal.FirstOrDefault()?.ItemType == ContextMenuFlyoutItemType.Separator)) + continue; + + BitmapImage? image = null; + if (showIcons && menuFlyoutItem.Icon is { Length: > 0 }) + { + image = new BitmapImage(); + using var ms = new MemoryStream(menuFlyoutItem.Icon); + await image.SetSourceAsync(ms.AsRandomAccessStream()); + } + + if (menuFlyoutItem.Type is MENU_ITEM_TYPE.MFT_SEPARATOR) + { + var menuLayoutItem = new ContextMenuFlyoutItemViewModel() + { + ItemType = ContextMenuFlyoutItemType.Separator, + Tag = menuFlyoutItem + }; + menuItemsListLocal.Insert(0, menuLayoutItem); + } + else if (!string.IsNullOrEmpty(menuFlyoutItem.Label) && menuFlyoutItem.SubItems is not null) + { + if (string.Equals(menuFlyoutItem.Label, Win32Helper.ExtractStringFromDLL("shell32.dll", 30312))) + menuFlyoutItem.CommandString = "sendto"; + + var menuLayoutSubItem = CreateShellMenuItem(menuFlyoutItem, image); + menuLayoutSubItem.Items = []; + + if (menuFlyoutItem.SubItems.Any()) + { + await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items, contextMenu, menuFlyoutItem.SubItems, cancellationToken, showIcons); + } + else + { + menuLayoutSubItem.LoadSubMenuAction = async () => + { + if (await contextMenu.LoadSubMenu(menuFlyoutItem.SubItems)) + await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items, contextMenu, menuFlyoutItem.SubItems, cancellationToken, showIcons); + }; + } + + menuItemsListLocal.Insert(0, menuLayoutSubItem); + } + else if (!string.IsNullOrEmpty(menuFlyoutItem.Label)) + { + var menuLayoutItem = CreateShellMenuItem(menuFlyoutItem, image); + menuLayoutItem.Command = new AsyncRelayCommand(x => InvokeShellMenuItemAsync(contextMenu, x)); + menuLayoutItem.CommandParameter = menuFlyoutItem; + menuItemsListLocal.Insert(0, menuLayoutItem); + } + } + + async Task InvokeShellMenuItemAsync(ContextMenu contextMenu, object? tag) + { + if (tag is not Win32ContextMenuItem menuItem) + return; + + var menuId = menuItem.ID; + var isFont = FileExtensionHelpers.IsFontFile(contextMenu.ItemsPath[0]); + var verb = menuItem.CommandString; + switch (verb) + { + case "install" when isFont: + await Win32Helper.InstallFontsAsync([.. contextMenu.ItemsPath], false); + break; + + case "installAllUsers" when isFont: + await Win32Helper.InstallFontsAsync([.. contextMenu.ItemsPath], true); + break; + + case "mount": + var vhdPath = contextMenu.ItemsPath[0]; + await Win32Helper.MountVhdDisk(vhdPath); + break; + + case "format": + var drivePath = contextMenu.ItemsPath[0]; + await Win32Helper.OpenFormatDriveDialog(drivePath); + break; + + case "Windows.PowerShell.Run": + await contextMenu.InvokeItem( + menuId, + contextMenu.ItemsPath[0].EndsWith(".ps1") ? Path.GetDirectoryName(contextMenu.ItemsPath[0]) : null + ); + break; + + default: + await contextMenu.InvokeItem(menuId); + break; + } + + //contextMenu.Dispose(); // Prevents some menu items from working (TBC) + } + } + + private static async Task> GetWindowsContextMenuAsync(bool showOpenMenu, bool shiftPressed, string? workingDirectory, List? selectedItems, CancellationToken cancellationToken) + { + var menuItemsList = new List(); + var filePaths = selectedItems is { Count: > 0 } + ? selectedItems.Select(x => x.ItemPath!).ToArray() + : [workingDirectory ?? throw new ArgumentException("A working directory is required when no items are selected.", nameof(workingDirectory))]; + + Func FilterMenuItems(bool showOpenMenu) + { + var knownItems = new HashSet() + { + "opennew", "opencontaining", "opennewprocess", + "runas", "runasuser", "pintohome", "PinToStartScreen", + "cut", "copy", "paste", "delete", "properties", "link", + "Windows.ModernShare", "setdesktopwallpaper", + "eject", "rename", "explore", "openinfiles", "extract", + "copyaspath", "undelete", "empty", "format", "rotate90", "rotate270", + Win32Helper.ExtractStringFromDLL("shell32.dll", 34593), // Add to collection + Win32Helper.ExtractStringFromDLL("shell32.dll", 5384), // Pin to Start + Win32Helper.ExtractStringFromDLL("shell32.dll", 5385), // Unpin from Start + Win32Helper.ExtractStringFromDLL("shell32.dll", 5386), // Pin to taskbar + Win32Helper.ExtractStringFromDLL("shell32.dll", 5387), // Unpin from taskbar + "{9F156763-7844-4DC4-B2B1-901F640F5155}", // Open in Terminal + }; + + bool filterMenuItemsImpl(string? menuItem) => !string.IsNullOrEmpty(menuItem) + && (knownItems.Contains(menuItem) || (!showOpenMenu && menuItem.Equals("open", StringComparison.OrdinalIgnoreCase))); + + return filterMenuItemsImpl; + } + + var contextMenu = await ContextMenu.GetContextMenuForFiles(filePaths, + shiftPressed ? PInvoke.CMF_EXTENDEDVERBS : PInvoke.CMF_NORMAL, FilterMenuItems(showOpenMenu)); + + if (contextMenu is not null) + await LoadMenuFlyoutItemAsync(menuItemsList, contextMenu, contextMenu.Items!, cancellationToken, true); + + if (cancellationToken.IsCancellationRequested) + menuItemsList.Clear(); + + return menuItemsList; + } + } +} diff --git a/src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs b/src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs index d37f2d0b99f6..6d377a728987 100644 --- a/src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs +++ b/src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs @@ -12,8 +12,6 @@ using System.Text; using Windows.System; using Windows.UI.Core; -using Windows.Win32; -using Windows.Win32.UI.WindowsAndMessaging; namespace Files.App.Helpers { @@ -21,56 +19,7 @@ public static partial class ShellContextFlyoutFactory { public static IUserSettingsService UserSettingsService { get; } = Ioc.Default.GetRequiredService(); - private static (string Label, string AccessKey) ExtractLabelAndAccessKey(string rawLabel) - { - if (string.IsNullOrEmpty(rawLabel)) - return (string.Empty, string.Empty); - - string accessKey = string.Empty; - var labelBuilder = new StringBuilder(rawLabel.Length); - - for (int i = 0; i < rawLabel.Length; i++) - { - char current = rawLabel[i]; - if (current != '&') - { - labelBuilder.Append(current); - continue; - } - - if (i + 1 >= rawLabel.Length) - { - labelBuilder.Append('&'); - continue; - } - char next = rawLabel[++i]; - if (next == '&') - { - labelBuilder.Append('&'); - continue; - } - - if (string.IsNullOrEmpty(accessKey) && !char.IsWhiteSpace(next)) - accessKey = char.ToUpperInvariant(next).ToString(); - - labelBuilder.Append(next); - } - - return (labelBuilder.ToString(), accessKey); - } - - private static ContextMenuFlyoutItemViewModel CreateShellMenuItem(Win32ContextMenuItem menuFlyoutItem, BitmapImage? image) - { - var (label, accessKey) = ExtractLabelAndAccessKey(menuFlyoutItem.Label ?? string.Empty); - return new ContextMenuFlyoutItemViewModel - { - Text = label, - AccessKey = accessKey, - Tag = menuFlyoutItem, - BitmapIcon = image, - }; - } public static async Task> GetShellContextmenuAsync(bool showOpenMenu, bool shiftPressed, string? workingDirectory, List? selectedItems, CancellationToken cancellationToken) { @@ -78,188 +27,10 @@ public static async Task> GetShellContextme // No shell extensions on Linux: skip the Win32 shell menu entirely (no COM, no delay) return await GetLinuxContextMenuAsync(selectedItems, cancellationToken); #else - var menuItemsList = new List(); - var filePaths = selectedItems is { Count: > 0 } - ? selectedItems.Select(x => x.ItemPath!).ToArray() - : [workingDirectory ?? throw new ArgumentException("A working directory is required when no items are selected.", nameof(workingDirectory))]; - - Func FilterMenuItems(bool showOpenMenu) - { - var knownItems = new HashSet() - { - "opennew", "opencontaining", "opennewprocess", - "runas", "runasuser", "pintohome", "PinToStartScreen", - "cut", "copy", "paste", "delete", "properties", "link", - "Windows.ModernShare", "setdesktopwallpaper", - "eject", "rename", "explore", "openinfiles", "extract", - "copyaspath", "undelete", "empty", "format", "rotate90", "rotate270", - Win32Helper.ExtractStringFromDLL("shell32.dll", 34593), // Add to collection - Win32Helper.ExtractStringFromDLL("shell32.dll", 5384), // Pin to Start - Win32Helper.ExtractStringFromDLL("shell32.dll", 5385), // Unpin from Start - Win32Helper.ExtractStringFromDLL("shell32.dll", 5386), // Pin to taskbar - Win32Helper.ExtractStringFromDLL("shell32.dll", 5387), // Unpin from taskbar - "{9F156763-7844-4DC4-B2B1-901F640F5155}", // Open in Terminal - }; - - bool filterMenuItemsImpl(string? menuItem) => !string.IsNullOrEmpty(menuItem) - && (knownItems.Contains(menuItem) || (!showOpenMenu && menuItem.Equals("open", StringComparison.OrdinalIgnoreCase))); - - return filterMenuItemsImpl; - } - - var contextMenu = await ContextMenu.GetContextMenuForFiles(filePaths, - shiftPressed ? PInvoke.CMF_EXTENDEDVERBS : PInvoke.CMF_NORMAL, FilterMenuItems(showOpenMenu)); - - if (contextMenu is not null) - await LoadMenuFlyoutItemAsync(menuItemsList, contextMenu, contextMenu.Items!, cancellationToken, true); - - if (cancellationToken.IsCancellationRequested) - menuItemsList.Clear(); - - return menuItemsList; + return await GetWindowsContextMenuAsync(showOpenMenu, shiftPressed, workingDirectory, selectedItems, cancellationToken); #endif } - private static async Task LoadMenuFlyoutItemAsync( - List menuItemsListLocal, - ContextMenu contextMenu, - IEnumerable menuFlyoutItems, - CancellationToken cancellationToken, - bool showIcons = true, - int itemsBeforeOverflow = int.MaxValue) - { - if (cancellationToken.IsCancellationRequested) - return; - - var itemsCount = 0; // Separators do not count for reaching the overflow threshold - var menuItems = menuFlyoutItems.TakeWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR || ++itemsCount <= itemsBeforeOverflow).ToList(); - var overflowItems = menuFlyoutItems.Except(menuItems).ToList(); - - if (overflowItems.Any(x => x.Type != MENU_ITEM_TYPE.MFT_SEPARATOR)) - { - var moreItem = menuItemsListLocal.FirstOrDefault(x => x.ID == "ItemOverflow"); - if (moreItem is null) - { - var menuLayoutSubItem = new ContextMenuFlyoutItemViewModel() - { - Text = Strings.ShowMoreOptions.GetLocalizedResource(), - Glyph = "\xE712", - }; - await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items - ?? throw new InvalidOperationException("The shell overflow menu has not been initialized."), contextMenu, overflowItems, cancellationToken, showIcons); - menuItemsListLocal.Insert(0, menuLayoutSubItem); - } - else - { - await LoadMenuFlyoutItemAsync(moreItem.Items - ?? throw new InvalidOperationException("The shell overflow menu has not been initialized."), contextMenu, overflowItems, cancellationToken, showIcons); - } - } - - foreach (var menuFlyoutItem in menuItems - .SkipWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR) // Remove leading separators - .Reverse() - .SkipWhile(x => x.Type == MENU_ITEM_TYPE.MFT_SEPARATOR)) // Remove trailing separators - { - if (cancellationToken.IsCancellationRequested) - break; - - // Avoid duplicate separators - if ((menuFlyoutItem.Type == MENU_ITEM_TYPE.MFT_SEPARATOR) && (menuItemsListLocal.FirstOrDefault()?.ItemType == ContextMenuFlyoutItemType.Separator)) - continue; - - BitmapImage? image = null; - if (showIcons && menuFlyoutItem.Icon is { Length: > 0 }) - { - image = new BitmapImage(); - using var ms = new MemoryStream(menuFlyoutItem.Icon); - await image.SetSourceAsync(ms.AsRandomAccessStream()); - } - - if (menuFlyoutItem.Type is MENU_ITEM_TYPE.MFT_SEPARATOR) - { - var menuLayoutItem = new ContextMenuFlyoutItemViewModel() - { - ItemType = ContextMenuFlyoutItemType.Separator, - Tag = menuFlyoutItem - }; - menuItemsListLocal.Insert(0, menuLayoutItem); - } - else if (!string.IsNullOrEmpty(menuFlyoutItem.Label) && menuFlyoutItem.SubItems is not null) - { - if (string.Equals(menuFlyoutItem.Label, Win32Helper.ExtractStringFromDLL("shell32.dll", 30312))) - menuFlyoutItem.CommandString = "sendto"; - - var menuLayoutSubItem = CreateShellMenuItem(menuFlyoutItem, image); - menuLayoutSubItem.Items = []; - - if (menuFlyoutItem.SubItems.Any()) - { - await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items, contextMenu, menuFlyoutItem.SubItems, cancellationToken, showIcons); - } - else - { - menuLayoutSubItem.LoadSubMenuAction = async () => - { - if (await contextMenu.LoadSubMenu(menuFlyoutItem.SubItems)) - await LoadMenuFlyoutItemAsync(menuLayoutSubItem.Items, contextMenu, menuFlyoutItem.SubItems, cancellationToken, showIcons); - }; - } - - menuItemsListLocal.Insert(0, menuLayoutSubItem); - } - else if (!string.IsNullOrEmpty(menuFlyoutItem.Label)) - { - var menuLayoutItem = CreateShellMenuItem(menuFlyoutItem, image); - menuLayoutItem.Command = new AsyncRelayCommand(x => InvokeShellMenuItemAsync(contextMenu, x)); - menuLayoutItem.CommandParameter = menuFlyoutItem; - menuItemsListLocal.Insert(0, menuLayoutItem); - } - } - - async Task InvokeShellMenuItemAsync(ContextMenu contextMenu, object? tag) - { - if (tag is not Win32ContextMenuItem menuItem) - return; - - var menuId = menuItem.ID; - var isFont = FileExtensionHelpers.IsFontFile(contextMenu.ItemsPath[0]); - var verb = menuItem.CommandString; - switch (verb) - { - case "install" when isFont: - await Win32Helper.InstallFontsAsync([.. contextMenu.ItemsPath], false); - break; - - case "installAllUsers" when isFont: - await Win32Helper.InstallFontsAsync([.. contextMenu.ItemsPath], true); - break; - - case "mount": - var vhdPath = contextMenu.ItemsPath[0]; - await Win32Helper.MountVhdDisk(vhdPath); - break; - - case "format": - var drivePath = contextMenu.ItemsPath[0]; - await Win32Helper.OpenFormatDriveDialog(drivePath); - break; - - case "Windows.PowerShell.Run": - await contextMenu.InvokeItem( - menuId, - contextMenu.ItemsPath[0].EndsWith(".ps1") ? Path.GetDirectoryName(contextMenu.ItemsPath[0]) : null - ); - break; - - default: - await contextMenu.InvokeItem(menuId); - break; - } - - //contextMenu.Dispose(); // Prevents some menu items from working (TBC) - } - } public static List? GetOpenWithItems(List flyout) { diff --git a/src/Files.App/Data/Items/ContextMenu.cs b/src/Files.App/Data/Items/ContextMenu.cs index 091657de6a25..5d0a41d9b2fd 100644 --- a/src/Files.App/Data/Items/ContextMenu.cs +++ b/src/Files.App/Data/Items/ContextMenu.cs @@ -1,7 +1,9 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if WINDOWS using Windows.Win32.UI.WindowsAndMessaging; +#endif namespace Files.App.Data.Items { @@ -31,7 +33,9 @@ public class Win32ContextMenuItem public int ID { get; set; } // Valid only in current menu to invoke item public string? Label { get; set; } public string? CommandString { get; set; } +#if WINDOWS public MENU_ITEM_TYPE Type { get; set; } +#endif public List? SubItems { get; set; } } } diff --git a/src/Files.App/Data/Items/ShellLinkItem.cs b/src/Files.App/Data/Items/ShellLinkItem.cs index b169146fa66f..d8210994afd9 100644 --- a/src/Files.App/Data/Items/ShellLinkItem.cs +++ b/src/Files.App/Data/Items/ShellLinkItem.cs @@ -1,7 +1,7 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Windows.Win32.UI.WindowsAndMessaging; +using Files.App.Data.Enums; namespace Files.App.Data.Items { @@ -17,7 +17,7 @@ public sealed class ShellLinkItem : ShellFileItem public bool InvalidTarget { get; set; } - public SHOW_WINDOW_CMD ShowWindowCommand { get; set; } + public ShowWindowCommand ShowWindowCommand { get; set; } public ShellLinkItem() { diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Windows.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Windows.cs new file mode 100644 index 000000000000..27b2307e9047 --- /dev/null +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Windows.cs @@ -0,0 +1,349 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using System.IO; +using Windows.Storage; +using Windows.Storage.Search; +using Windows.System; +using WinRT; +using Windows.Win32; + +namespace Files.App.Helpers +{ + public static partial class NavigationHelpers + { + private static readonly IWindowsRecentItemsService WindowsRecentItemsService = Ioc.Default.GetRequiredService(); + + private static async Task OpenPathWindowsAsync(string path, IShellPage associatedInstance, FilesystemItemType? itemType, bool openSilent, bool openViaApplicationPicker, IEnumerable? selectItems, string? args, bool forceOpenInNewTab) + { + string? previousDir = associatedInstance.ShellViewModel.WorkingDirectory; + + var fileAttributes = Win32Helper.GetFileAttributes(path); + bool isDirectory = fileAttributes.HasFlag(System.IO.FileAttributes.Directory); + + var shortcutInfo = new ShellLinkItem(); + + if (!isDirectory && FileExtensionHelpers.IsShortcutOrUrlFile(path)) + { + var shInfo = await FileOperationsHelpers.ParseLinkAsync(path); + + if (shInfo is null) + return false; + + itemType = shInfo.IsFolder ? FilesystemItemType.Directory : FilesystemItemType.File; + + shortcutInfo = shInfo; + + if (shortcutInfo.InvalidTarget) + { + if (await DialogDisplayHelper.ShowDialogAsync(DynamicDialogFactory.GetFor_ShortcutNotFound(shortcutInfo.TargetPath)) != DynamicDialogResult.Primary) + return false; + + // Delete shortcut + var shortcutItem = StorageHelpers.FromPathAndType(path, FilesystemItemType.File); + await associatedInstance.FilesystemHelpers.DeleteItemAsync(shortcutItem, DeleteConfirmationPolicies.Never, false, true); + } + } + else if (fileAttributes.HasFlag(System.IO.FileAttributes.ReparsePoint)) + { + if (!isDirectory && + Win32Helper.GetWin32FindDataForPath(path, out var findData) && + findData.dwReserved0 == PInvoke.IO_REPARSE_TAG_SYMLINK) + { + shortcutInfo.TargetPath = Win32Helper.ParseSymLink(path); + } + itemType ??= isDirectory ? FilesystemItemType.Directory : FilesystemItemType.File; + } + else if (fileAttributes.HasFlag(System.IO.FileAttributes.Hidden)) + { + itemType = isDirectory ? FilesystemItemType.Directory : FilesystemItemType.File; + } + else if (itemType is null) + { + itemType = await StorageHelpers.GetTypeFromPath(path); + } + + FilesystemResult opened = (FilesystemResult)false; + switch (itemType) + { + case FilesystemItemType.Library: + opened = await OpenLibrary(path, associatedInstance, selectItems, forceOpenInNewTab); + break; + + case FilesystemItemType.Directory: + opened = await OpenDirectory(path, associatedInstance, selectItems, shortcutInfo, forceOpenInNewTab, fileAttributes.HasFlag(System.IO.FileAttributes.Hidden)); + break; + + case FilesystemItemType.File: + // Starts the screensaver in full-screen mode + if (FileExtensionHelpers.IsScreenSaverFile(path)) + args += "/s"; + + opened = await OpenFile(path, associatedInstance, shortcutInfo, openViaApplicationPicker, args); + break; + } + + if (opened.ErrorCode == FileSystemStatusCode.NotFound && !openSilent) + { + await DialogDisplayHelper.ShowDialogAsync(Strings.FileNotFoundDialogTitle.GetLocalizedResource(), Strings.FileNotFoundDialogText.GetLocalizedResource()); + associatedInstance.ToolbarViewModel.CanRefresh = false; + associatedInstance.ShellViewModel?.RefreshItems(previousDir); + } + + return opened; + } + + private static async Task OpenLibrary(string path, IShellPage associatedInstance, IEnumerable? selectItems, bool forceOpenInNewTab) + { + IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); + + var opened = (FilesystemResult)false; + bool isHiddenItem = Win32Helper.HasFileAttribute(path, System.IO.FileAttributes.Hidden); + if (isHiddenItem) + { + await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, associatedInstance); + opened = (FilesystemResult)true; + } + else if (App.LibraryManager.TryGetLibrary(path, out var library)) + { + opened = (FilesystemResult)await library.CheckDefaultSaveFolderAccess(); + if (opened) + await OpenPathAsync(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, library.Text, associatedInstance, selectItems); + } + return opened; + } + + private static async Task OpenDirectory(string path, IShellPage associatedInstance, IEnumerable? selectItems, ShellLinkItem shortcutInfo, bool forceOpenInNewTab, bool isHiddenItem) + { + IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); + + var opened = (FilesystemResult)false; + bool isShortcut = FileExtensionHelpers.IsShortcutOrUrlFile(path); + + if (isShortcut) + { + if (string.IsNullOrEmpty(shortcutInfo.TargetPath)) + { + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance); + opened = (FilesystemResult)true; + } + else + { + await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, shortcutInfo.TargetPath, associatedInstance, selectItems); + opened = (FilesystemResult)true; + } + } + else if (isHiddenItem) + { + await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, associatedInstance); + opened = (FilesystemResult)true; + } + else + { + if (associatedInstance.ShellViewModel is not null) + { + opened = await associatedInstance.ShellViewModel.GetFolderWithPathFromPathAsync(path) + .OnSuccess(async (childFolder) => + { + var folder = childFolder!; + // Add location to Recent Items List. + // File.Exists distinguishes an archive root (real file on disk) from an inner path like "archive.zip\sub". + await STATask.RunPooled(() => + { + if (folder.Item is SystemStorageFolder || + (folder.Item is ZipStorageFolder && File.Exists(folder.Path))) + return WindowsRecentItemsService.Add(folder.Path); + + return false; + }, App.Logger); + }); + } + if (!opened) + opened = (FilesystemResult)await Task.Run(() => FolderHelpers.CheckFolderAccessWithWin32(path)); + + if (opened) + await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, associatedInstance, selectItems); + else + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance); + } + return opened; + } + + private static async Task OpenFile(string path, IShellPage associatedInstance, ShellLinkItem shortcutInfo, bool openViaApplicationPicker = false, string? args = default) + { + var opened = (FilesystemResult)false; + bool isHiddenItem = Win32Helper.HasFileAttribute(path, System.IO.FileAttributes.Hidden); + bool isShortcut = FileExtensionHelpers.IsShortcutOrUrlFile(path) || !string.IsNullOrEmpty(shortcutInfo.TargetPath); + + if (isShortcut) + { + // Empty or non-rooted shell target (e.g. a shell:appsfolder app): launch the .lnk so the shell activates it + if (string.IsNullOrEmpty(shortcutInfo.TargetPath) || !Path.IsPathRooted(shortcutInfo.TargetPath)) + { + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); + } + else + { + if (!FileExtensionHelpers.IsWebLinkFile(path) && associatedInstance.ShellViewModel is not null) + { + var childFileResult = await associatedInstance.ShellViewModel.GetFileWithPathFromPathAsync(shortcutInfo.TargetPath); + // Add location to Recent Items List + if (childFileResult.Result is { Item: SystemStorageFile } childFile) + WindowsRecentItemsService.Add(childFile.Path); + } + await Win32Helper.InvokeWin32ComponentAsync(shortcutInfo.TargetPath, associatedInstance, $"{args} {shortcutInfo.Arguments}", shortcutInfo.RunAsAdmin, shortcutInfo.WorkingDirectory); + } + opened = (FilesystemResult)true; + } + else if (isHiddenItem) + { + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); + } + else + { + if (associatedInstance.ShellViewModel is not null) + { + var shellViewModel = associatedInstance.ShellViewModel; + + opened = await shellViewModel.GetFileWithPathFromPathAsync(path) + .OnSuccess(async childFile => + { + var file = childFile!; + // Add location to Recent Items List + if (file.Item is SystemStorageFile) + WindowsRecentItemsService.Add(file.Path); + + if (openViaApplicationPicker) + { + var storageFile = file.Item + ?? throw new InvalidOperationException("The file cannot be opened with the application picker because it has no storage item."); + + LauncherOptions options = InitializeWithWindow(new LauncherOptions + { + DisplayApplicationPicker = true + }); + if (!await Launcher.LaunchFileAsync(storageFile, options)) + await ContextMenu.InvokeVerb("openas", path); + } + else + { + var fileExtension = Path.GetExtension(path); + + // Use NeighboringFilesQuery to launch photos + // The query options no longer work with the Windows 11 Photo App but they still work for Windows 10 + if (FileExtensionHelpers.IsImageFile(fileExtension)) + { + //try using launcher first + bool launchSuccess = false; + + // The Windows 11 Photos app ignores NeighboringFilesQuery when launched as default app. + // Use the app URI only when this extension is associated with Microsoft Photos. + if (FileAssociationHelpers.IsMicrosoftPhotosDefaultAssociation(fileExtension)) + { + string uri = $"ms-photos:viewer?fileName={Uri.EscapeDataString(path)}"; + launchSuccess = await Launcher.LaunchUriAsync(new Uri(uri)); + } + + BaseStorageFileQueryResult? fileQueryResult = null; + //Get folder to create a file query (to pass to apps like Photos, Movies & TV..., needed to scroll through the folder like what Windows Explorer does) + var currentFolderResult = await shellViewModel.GetFolderFromPathAsync(PathNormalization.GetParentDir(path)); + if (!launchSuccess && currentFolderResult.Result is { } currentFolder) + { + QueryOptions queryOptions = new(CommonFileQuery.DefaultQuery, null); + //We can have many sort entries + SortEntry sortEntry = new() + { + AscendingOrder = associatedInstance.InstanceViewModel.FolderSettings.DirectorySortDirection == SortDirection.Ascending + }; + //Basically we tell to the launched app to follow how we sorted the files in the directory. + var sortOption = associatedInstance.InstanceViewModel.FolderSettings.DirectorySortOption; + switch (sortOption) + { + case SortOption.Name: + sortEntry.PropertyName = "System.ItemNameDisplay"; + queryOptions.SortOrder.Clear(); + queryOptions.SortOrder.Add(sortEntry); + break; + case SortOption.DateModified: + sortEntry.PropertyName = "System.DateModified"; + queryOptions.SortOrder.Clear(); + queryOptions.SortOrder.Add(sortEntry); + break; + case SortOption.DateCreated: + sortEntry.PropertyName = "System.DateCreated"; + queryOptions.SortOrder.Clear(); + queryOptions.SortOrder.Add(sortEntry); + break; + //Unfortunately this is unsupported | Remarks: https://learn.microsoft.com/uwp/api/windows.storage.search.queryoptions.sortorder?view=winrt-19041 + //case Enums.SortOption.Size: + //sortEntry.PropertyName = "System.TotalFileSize"; + //queryOptions.SortOrder.Clear(); + //queryOptions.SortOrder.Add(sortEntry); + //break; + //Unfortunately this is unsupported | Remarks: https://learn.microsoft.com/uwp/api/windows.storage.search.queryoptions.sortorder?view=winrt-19041 + //case Enums.SortOption.FileType: + //sortEntry.PropertyName = "System.FileExtension"; + //queryOptions.SortOrder.Clear(); + //queryOptions.SortOrder.Add(sortEntry); + //break; + //Handle unsupported + default: + //keep the default one in SortOrder IList + break; + } + var options = InitializeWithWindow(new LauncherOptions()); + if (currentFolder.AreQueryOptionsSupported(queryOptions)) + { + fileQueryResult = currentFolder.CreateFileQueryWithOptions(queryOptions); + options.NeighboringFilesQuery = fileQueryResult.ToStorageFileQueryResult(); + } + // Now launch file with options. + if (file.Item is { } item && + (await FilesystemTasks.Wrap(() => item.ToStorageFileAsync().AsTask())).Result is { } storageItem) + { + launchSuccess = await Launcher.LaunchFileAsync(storageItem, options); + } + } + if (!launchSuccess) + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); + } + else if (file.Item is ZipStorageFile zipStorageFile) + { + var options = InitializeWithWindow(new LauncherOptions()); + var storageItem = (await FilesystemTasks.Wrap(() => zipStorageFile.ToStorageFileAsync().AsTask())).Result; + if (storageItem is null) + { + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); + } + else if (!await Launcher.LaunchFileAsync(storageItem, options)) + { + var pickerOptions = InitializeWithWindow(new LauncherOptions + { + DisplayApplicationPicker = true + }); + if (!await Launcher.LaunchFileAsync(storageItem, pickerOptions)) + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); + } + } + else + { + await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); + } + } + }); + } + } + return opened; + } + + private static LauncherOptions InitializeWithWindow(LauncherOptions obj) + { + WinRT.Interop.InitializeWithWindow.Initialize(obj, MainWindow.Instance.WindowHandle); + return obj; + } + } +} diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs index a598c57a79cc..513894335d75 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs @@ -7,10 +7,8 @@ using Microsoft.UI.Xaml.Media.Imaging; using System.IO; using Windows.Storage; -using Windows.Storage.Search; using Windows.System; using WinRT; -using Windows.Win32; namespace Files.App.Helpers { @@ -18,7 +16,6 @@ public static partial class NavigationHelpers { private static readonly IGeneralSettingsService GeneralSettingsService = Ioc.Default.GetRequiredService(); - private static readonly IWindowsRecentItemsService WindowsRecentItemsService = Ioc.Default.GetRequiredService(); private static MainPageViewModel MainPageViewModel { get; } = Ioc.Default.GetRequiredService(); private static DrivesViewModel DrivesViewModel { get; } = Ioc.Default.GetRequiredService(); private static INetworkService NetworkService { get; } = Ioc.Default.GetRequiredService(); @@ -476,8 +473,12 @@ public static async Task OpenItemsWithExecutableAsync(IShellPage associatedInsta if (workingDirectory.StartsWith(Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.Ordinal)) return; +#if WINDOWS var arguments = string.Join(" ", items.Select(item => $"\"{item.Path}\"")); await Win32Helper.InvokeWin32ComponentAsync(executablePath, associatedInstance, arguments); +#else + await LinuxLauncher.RunExecutableAsync(executablePath, items.Select(item => item.Path).ToArray(), workingDirectory); +#endif } /// @@ -519,333 +520,14 @@ public static async Task OpenPath(string path, IShellPage associatedInstan #if !WINDOWS return await OpenPathLinuxAsync(path, associatedInstance, openViaApplicationPicker, selectItems, forceOpenInNewTab); #else - string? previousDir = associatedInstance.ShellViewModel.WorkingDirectory; - - var fileAttributes = Win32Helper.GetFileAttributes(path); - bool isDirectory = fileAttributes.HasFlag(System.IO.FileAttributes.Directory); - - var shortcutInfo = new ShellLinkItem(); - - if (!isDirectory && FileExtensionHelpers.IsShortcutOrUrlFile(path)) - { - var shInfo = await FileOperationsHelpers.ParseLinkAsync(path); - - if (shInfo is null) - return false; - - itemType = shInfo.IsFolder ? FilesystemItemType.Directory : FilesystemItemType.File; - - shortcutInfo = shInfo; - - if (shortcutInfo.InvalidTarget) - { - if (await DialogDisplayHelper.ShowDialogAsync(DynamicDialogFactory.GetFor_ShortcutNotFound(shortcutInfo.TargetPath)) != DynamicDialogResult.Primary) - return false; - - // Delete shortcut - var shortcutItem = StorageHelpers.FromPathAndType(path, FilesystemItemType.File); - await associatedInstance.FilesystemHelpers.DeleteItemAsync(shortcutItem, DeleteConfirmationPolicies.Never, false, true); - } - } - else if (fileAttributes.HasFlag(System.IO.FileAttributes.ReparsePoint)) - { - if (!isDirectory && - Win32Helper.GetWin32FindDataForPath(path, out var findData) && - findData.dwReserved0 == PInvoke.IO_REPARSE_TAG_SYMLINK) - { - shortcutInfo.TargetPath = Win32Helper.ParseSymLink(path); - } - itemType ??= isDirectory ? FilesystemItemType.Directory : FilesystemItemType.File; - } - else if (fileAttributes.HasFlag(System.IO.FileAttributes.Hidden)) - { - itemType = isDirectory ? FilesystemItemType.Directory : FilesystemItemType.File; - } - else if (itemType is null) - { - itemType = await StorageHelpers.GetTypeFromPath(path); - } - - FilesystemResult opened = (FilesystemResult)false; - switch (itemType) - { - case FilesystemItemType.Library: - opened = await OpenLibrary(path, associatedInstance, selectItems, forceOpenInNewTab); - break; - - case FilesystemItemType.Directory: - opened = await OpenDirectory(path, associatedInstance, selectItems, shortcutInfo, forceOpenInNewTab, fileAttributes.HasFlag(System.IO.FileAttributes.Hidden)); - break; - - case FilesystemItemType.File: - // Starts the screensaver in full-screen mode - if (FileExtensionHelpers.IsScreenSaverFile(path)) - args += "/s"; - - opened = await OpenFile(path, associatedInstance, shortcutInfo, openViaApplicationPicker, args); - break; - } - - if (opened.ErrorCode == FileSystemStatusCode.NotFound && !openSilent) - { - await DialogDisplayHelper.ShowDialogAsync(Strings.FileNotFoundDialogTitle.GetLocalizedResource(), Strings.FileNotFoundDialogText.GetLocalizedResource()); - associatedInstance.ToolbarViewModel.CanRefresh = false; - associatedInstance.ShellViewModel?.RefreshItems(previousDir); - } - - return opened; + return await OpenPathWindowsAsync(path, associatedInstance, itemType, openSilent, openViaApplicationPicker, selectItems, args, forceOpenInNewTab); #endif } - private static async Task OpenLibrary(string path, IShellPage associatedInstance, IEnumerable? selectItems, bool forceOpenInNewTab) - { - IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); - - var opened = (FilesystemResult)false; - bool isHiddenItem = Win32Helper.HasFileAttribute(path, System.IO.FileAttributes.Hidden); - if (isHiddenItem) - { - await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, associatedInstance); - opened = (FilesystemResult)true; - } - else if (App.LibraryManager.TryGetLibrary(path, out var library)) - { - opened = (FilesystemResult)await library.CheckDefaultSaveFolderAccess(); - if (opened) - await OpenPathAsync(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, library.Text, associatedInstance, selectItems); - } - return opened; - } - private static async Task OpenDirectory(string path, IShellPage associatedInstance, IEnumerable? selectItems, ShellLinkItem shortcutInfo, bool forceOpenInNewTab, bool isHiddenItem) - { - IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); - var opened = (FilesystemResult)false; - bool isShortcut = FileExtensionHelpers.IsShortcutOrUrlFile(path); - - if (isShortcut) - { - if (string.IsNullOrEmpty(shortcutInfo.TargetPath)) - { - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance); - opened = (FilesystemResult)true; - } - else - { - await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, shortcutInfo.TargetPath, associatedInstance, selectItems); - opened = (FilesystemResult)true; - } - } - else if (isHiddenItem) - { - await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, associatedInstance); - opened = (FilesystemResult)true; - } - else - { - if (associatedInstance.ShellViewModel is not null) - { - opened = await associatedInstance.ShellViewModel.GetFolderWithPathFromPathAsync(path) - .OnSuccess(async (childFolder) => - { - var folder = childFolder!; - // Add location to Recent Items List. - // File.Exists distinguishes an archive root (real file on disk) from an inner path like "archive.zip\sub". - await STATask.RunPooled(() => - { - if (folder.Item is SystemStorageFolder || - (folder.Item is ZipStorageFolder && File.Exists(folder.Path))) - return WindowsRecentItemsService.Add(folder.Path); - - return false; - }, App.Logger); - }); - } - if (!opened) - opened = (FilesystemResult)await Task.Run(() => FolderHelpers.CheckFolderAccessWithWin32(path)); - - if (opened) - await OpenPath(forceOpenInNewTab, UserSettingsService.FoldersSettingsService.OpenFoldersInNewTab, path, associatedInstance, selectItems); - else - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance); - } - return opened; - } - - private static async Task OpenFile(string path, IShellPage associatedInstance, ShellLinkItem shortcutInfo, bool openViaApplicationPicker = false, string? args = default) - { - var opened = (FilesystemResult)false; - bool isHiddenItem = Win32Helper.HasFileAttribute(path, System.IO.FileAttributes.Hidden); - bool isShortcut = FileExtensionHelpers.IsShortcutOrUrlFile(path) || !string.IsNullOrEmpty(shortcutInfo.TargetPath); - - if (isShortcut) - { - // Empty or non-rooted shell target (e.g. a shell:appsfolder app): launch the .lnk so the shell activates it - if (string.IsNullOrEmpty(shortcutInfo.TargetPath) || !Path.IsPathRooted(shortcutInfo.TargetPath)) - { - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); - } - else - { - if (!FileExtensionHelpers.IsWebLinkFile(path) && associatedInstance.ShellViewModel is not null) - { - var childFileResult = await associatedInstance.ShellViewModel.GetFileWithPathFromPathAsync(shortcutInfo.TargetPath); - // Add location to Recent Items List - if (childFileResult.Result is { Item: SystemStorageFile } childFile) - WindowsRecentItemsService.Add(childFile.Path); - } - await Win32Helper.InvokeWin32ComponentAsync(shortcutInfo.TargetPath, associatedInstance, $"{args} {shortcutInfo.Arguments}", shortcutInfo.RunAsAdmin, shortcutInfo.WorkingDirectory); - } - opened = (FilesystemResult)true; - } - else if (isHiddenItem) - { - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); - } - else - { - if (associatedInstance.ShellViewModel is not null) - { - var shellViewModel = associatedInstance.ShellViewModel; - - opened = await shellViewModel.GetFileWithPathFromPathAsync(path) - .OnSuccess(async childFile => - { - var file = childFile!; - // Add location to Recent Items List - if (file.Item is SystemStorageFile) - WindowsRecentItemsService.Add(file.Path); - - if (openViaApplicationPicker) - { - var storageFile = file.Item - ?? throw new InvalidOperationException("The file cannot be opened with the application picker because it has no storage item."); - - LauncherOptions options = InitializeWithWindow(new LauncherOptions - { - DisplayApplicationPicker = true - }); - if (!await Launcher.LaunchFileAsync(storageFile, options)) - await ContextMenu.InvokeVerb("openas", path); - } - else - { - var fileExtension = Path.GetExtension(path); - - // Use NeighboringFilesQuery to launch photos - // The query options no longer work with the Windows 11 Photo App but they still work for Windows 10 - if (FileExtensionHelpers.IsImageFile(fileExtension)) - { - //try using launcher first - bool launchSuccess = false; - - // The Windows 11 Photos app ignores NeighboringFilesQuery when launched as default app. - // Use the app URI only when this extension is associated with Microsoft Photos. - if (FileAssociationHelpers.IsMicrosoftPhotosDefaultAssociation(fileExtension)) - { - string uri = $"ms-photos:viewer?fileName={Uri.EscapeDataString(path)}"; - launchSuccess = await Launcher.LaunchUriAsync(new Uri(uri)); - } - - BaseStorageFileQueryResult? fileQueryResult = null; - //Get folder to create a file query (to pass to apps like Photos, Movies & TV..., needed to scroll through the folder like what Windows Explorer does) - var currentFolderResult = await shellViewModel.GetFolderFromPathAsync(PathNormalization.GetParentDir(path)); - if (!launchSuccess && currentFolderResult.Result is { } currentFolder) - { - QueryOptions queryOptions = new(CommonFileQuery.DefaultQuery, null); - //We can have many sort entries - SortEntry sortEntry = new() - { - AscendingOrder = associatedInstance.InstanceViewModel.FolderSettings.DirectorySortDirection == SortDirection.Ascending - }; - //Basically we tell to the launched app to follow how we sorted the files in the directory. - var sortOption = associatedInstance.InstanceViewModel.FolderSettings.DirectorySortOption; - switch (sortOption) - { - case SortOption.Name: - sortEntry.PropertyName = "System.ItemNameDisplay"; - queryOptions.SortOrder.Clear(); - queryOptions.SortOrder.Add(sortEntry); - break; - case SortOption.DateModified: - sortEntry.PropertyName = "System.DateModified"; - queryOptions.SortOrder.Clear(); - queryOptions.SortOrder.Add(sortEntry); - break; - case SortOption.DateCreated: - sortEntry.PropertyName = "System.DateCreated"; - queryOptions.SortOrder.Clear(); - queryOptions.SortOrder.Add(sortEntry); - break; - //Unfortunately this is unsupported | Remarks: https://learn.microsoft.com/uwp/api/windows.storage.search.queryoptions.sortorder?view=winrt-19041 - //case Enums.SortOption.Size: - //sortEntry.PropertyName = "System.TotalFileSize"; - //queryOptions.SortOrder.Clear(); - //queryOptions.SortOrder.Add(sortEntry); - //break; - //Unfortunately this is unsupported | Remarks: https://learn.microsoft.com/uwp/api/windows.storage.search.queryoptions.sortorder?view=winrt-19041 - //case Enums.SortOption.FileType: - //sortEntry.PropertyName = "System.FileExtension"; - //queryOptions.SortOrder.Clear(); - //queryOptions.SortOrder.Add(sortEntry); - //break; - //Handle unsupported - default: - //keep the default one in SortOrder IList - break; - } - var options = InitializeWithWindow(new LauncherOptions()); - if (currentFolder.AreQueryOptionsSupported(queryOptions)) - { - fileQueryResult = currentFolder.CreateFileQueryWithOptions(queryOptions); - options.NeighboringFilesQuery = fileQueryResult.ToStorageFileQueryResult(); - } - // Now launch file with options. - if (file.Item is { } item && - (await FilesystemTasks.Wrap(() => item.ToStorageFileAsync().AsTask())).Result is { } storageItem) - { - launchSuccess = await Launcher.LaunchFileAsync(storageItem, options); - } - } - if (!launchSuccess) - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); - } - else if (file.Item is ZipStorageFile zipStorageFile) - { - var options = InitializeWithWindow(new LauncherOptions()); - var storageItem = (await FilesystemTasks.Wrap(() => zipStorageFile.ToStorageFileAsync().AsTask())).Result; - if (storageItem is null) - { - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); - } - else if (!await Launcher.LaunchFileAsync(storageItem, options)) - { - var pickerOptions = InitializeWithWindow(new LauncherOptions - { - DisplayApplicationPicker = true - }); - if (!await Launcher.LaunchFileAsync(storageItem, pickerOptions)) - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); - } - } - else - { - await Win32Helper.InvokeWin32ComponentAsync(path, associatedInstance, args); - } - } - }); - } - } - return opened; - } // WINUI3 - private static LauncherOptions InitializeWithWindow(LauncherOptions obj) - { - WinRT.Interop.InitializeWithWindow.Initialize(obj, MainWindow.Instance.WindowHandle); - return obj; - } private static Task OpenPath(bool forceOpenInNewTab, bool openFolderInNewTabSetting, string path, IShellPage associatedInstance, IEnumerable? selectItems = null) => OpenPathAsync(forceOpenInNewTab, openFolderInNewTabSetting, path, path, associatedInstance, selectItems); diff --git a/src/Files.App/UserControls/Toolbar.xaml.Windows.cs b/src/Files.App/UserControls/Toolbar.xaml.Windows.cs new file mode 100644 index 000000000000..db89ba65be0e --- /dev/null +++ b/src/Files.App/UserControls/Toolbar.xaml.Windows.cs @@ -0,0 +1,82 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using CommunityToolkit.WinUI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using System.IO; +using Windows.Win32.UI.WindowsAndMessaging; +using WinRT; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; +using FlyoutPlacementMode = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode; + +namespace Files.App.UserControls +{ + public sealed partial class Toolbar : UserControl + { + private OpenWithMenu? openWithMenu; + + private async Task PopulateOpenWithFlyoutAsync(MenuFlyout flyout) + { + var requestId = ++openWithFlyoutRequestId; + + flyout.Items.Add(new MenuFlyoutItem + { + Text = Strings.Loading.GetLocalizedResource(), + IsEnabled = false, + }); + + openWithMenu?.Dispose(); + openWithMenu = null; + + OpenWithMenu? loadedOpenWithMenu = null; + if (PageContext.SelectedItems.Count is 1 && PageContext.SelectedItem?.ItemPath is string path) + loadedOpenWithMenu = await OpenWithMenu.GetForFileAsync(path); + + if (requestId != openWithFlyoutRequestId) + { + loadedOpenWithMenu?.Dispose(); + return; + } + + openWithMenu = loadedOpenWithMenu; + + flyout.Items.Clear(); + + if (openWithMenu is not null) + { + foreach (var item in openWithMenu.Items.Where(x => x.Type is MENU_ITEM_TYPE.MFT_STRING && !string.IsNullOrWhiteSpace(x.Label))) + flyout.Items.Add(await CreateOpenWithMenuItemAsync(openWithMenu, item)); + } + + if (flyout.Items.Count == 0) + flyout.Items.Add(CreateChooseAnotherAppMenuItem()); + } + + private static async Task CreateOpenWithMenuItemAsync(OpenWithMenu menu, Win32ContextMenuItem entry) + { + MenuFlyoutItem item; + if (entry.Icon is { Length: > 0 }) + { + using var ms = new MemoryStream(entry.Icon); + var image = new BitmapImage(); + await image.SetSourceAsync(ms.AsRandomAccessStream()); + item = new MenuFlyoutItemWithImage { BitmapIcon = image }; + } + else + { + item = new MenuFlyoutItem(); + } + + item.Text = entry.Label; + item.Command = new AsyncRelayCommand(async () => await menu.InvokeItem(entry.ID)); + + return item; + } + } +} diff --git a/src/Files.App/UserControls/Toolbar.xaml.cs b/src/Files.App/UserControls/Toolbar.xaml.cs index 7328c61ebada..fee122a42ede 100644 --- a/src/Files.App/UserControls/Toolbar.xaml.cs +++ b/src/Files.App/UserControls/Toolbar.xaml.cs @@ -9,7 +9,6 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Media.Imaging; using System.IO; -using Windows.Win32.UI.WindowsAndMessaging; using WinRT; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; @@ -26,7 +25,6 @@ public sealed partial class Toolbar : UserControl private readonly DispatcherQueueTimer toolbarRefreshTimer; private readonly IContentPageContext PageContext = Ioc.Default.GetRequiredService(); private UserControls.Menus.FileTagsContextMenu? editTagsMenu; - private OpenWithMenu? openWithMenu; private int openWithFlyoutRequestId; private readonly List toggleButtonDetachActions = new(); @@ -61,7 +59,10 @@ private void Toolbar_Unloaded(object sender, RoutedEventArgs e) UserSettingsService.AppearanceSettingsService.PropertyChanged -= AppearanceSettings_PropertyChanged; if (editTagsMenu is not null) editTagsMenu.TagsChanged -= EditTagsMenu_TagsChanged; +#if WINDOWS openWithMenu?.Dispose(); +#endif + ++openWithFlyoutRequestId; } private void AppModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -512,7 +513,7 @@ private async Task PopulateGroupFlyoutAsync(MenuFlyout flyout, CommandGroup grou #endif foreach (var code in group.Commands) - if (Commands[code] is { Code: not CommandCodes.None } cmd) + if (Commands[code] is { Code: not CommandCodes.None, IsExecutable: true } cmd) flyout.Items.Add(CreateGroupMenuItem(cmd)); if (group is NewItemCommandGroup && ViewModel?.InstanceViewModel.CanCreateFileInPage == true @@ -531,63 +532,42 @@ private async Task PopulateGroupFlyoutAsync(MenuFlyout flyout, CommandGroup grou } } + + +#if !WINDOWS private async Task PopulateOpenWithFlyoutAsync(MenuFlyout flyout) { var requestId = ++openWithFlyoutRequestId; - flyout.Items.Add(new MenuFlyoutItem { Text = Strings.Loading.GetLocalizedResource(), IsEnabled = false, }); - openWithMenu?.Dispose(); - openWithMenu = null; - - OpenWithMenu? loadedOpenWithMenu = null; - if (PageContext.SelectedItems.Count is 1 && PageContext.SelectedItem?.ItemPath is string path) - loadedOpenWithMenu = await OpenWithMenu.GetForFileAsync(path); + var menus = await ShellContextFlyoutFactory.GetShellContextmenuAsync(true, false, null, PageContext.SelectedItems.ToList(), CancellationToken.None); + var openWith = menus.FirstOrDefault(); + if (openWith?.LoadSubMenuAction is { } load) + await load(); if (requestId != openWithFlyoutRequestId) - { - loadedOpenWithMenu?.Dispose(); return; - } - - openWithMenu = loadedOpenWithMenu; flyout.Items.Clear(); - - if (openWithMenu is not null) + if (openWith?.Items is { } items) { - foreach (var item in openWithMenu.Items.Where(x => x.Type is MENU_ITEM_TYPE.MFT_STRING && !string.IsNullOrWhiteSpace(x.Label))) - flyout.Items.Add(await CreateOpenWithMenuItemAsync(openWithMenu, item)); + foreach (var entry in items) + { + if (entry.ItemType is ContextMenuFlyoutItemType.Separator) + flyout.Items.Add(new MenuFlyoutSeparator()); + else + flyout.Items.Add(new MenuFlyoutItemWithImage { Text = entry.Text, Command = entry.Command, BitmapIcon = entry.BitmapIcon }); + } } if (flyout.Items.Count == 0) flyout.Items.Add(CreateChooseAnotherAppMenuItem()); } - - private static async Task CreateOpenWithMenuItemAsync(OpenWithMenu menu, Win32ContextMenuItem entry) - { - MenuFlyoutItem item; - if (entry.Icon is { Length: > 0 }) - { - using var ms = new MemoryStream(entry.Icon); - var image = new BitmapImage(); - await image.SetSourceAsync(ms.AsRandomAccessStream()); - item = new MenuFlyoutItemWithImage { BitmapIcon = image }; - } - else - { - item = new MenuFlyoutItem(); - } - - item.Text = entry.Label; - item.Command = new AsyncRelayCommand(async () => await menu.InvokeItem(entry.ID)); - - return item; - } +#endif private MenuFlyoutItem CreateChooseAnotherAppMenuItem() { diff --git a/src/Files.App/Utils/Library/LibraryManager.Windows.cs b/src/Files.App/Utils/Library/LibraryManager.Windows.cs new file mode 100644 index 000000000000..e6cff439c3e0 --- /dev/null +++ b/src/Files.App/Utils/Library/LibraryManager.Windows.cs @@ -0,0 +1,363 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml.Controls; +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Windows.System; +using Windows.Win32; +using Windows.Win32.UI.Shell; +using Visibility = Microsoft.UI.Xaml.Visibility; + +namespace Files.App.Utils.Library +{ + public sealed partial class LibraryManager : IDisposable + { + private void InitializeWatcher() + { + if (librariesWatcher is not null) + return; + + librariesWatcher = new FileSystemWatcher + { + Path = ShellLibraryItem.LibrariesPath, + Filter = "*" + ShellLibraryItem.EXTENSION, + NotifyFilter = NotifyFilters.Attributes | NotifyFilters.LastWrite | NotifyFilters.FileName, + IncludeSubdirectories = false, + }; + + librariesWatcher.Created += OnLibraryChanged; + librariesWatcher.Changed += OnLibraryChanged; + librariesWatcher.Deleted += OnLibraryChanged; + librariesWatcher.Renamed += OnLibraryRenamed; + + librariesWatcher.EnableRaisingEvents = true; + } + + public static async Task> ListUserLibraries() + { + var libraries = await STATask.Run(() => + { + try + { + var libraryItems = new List(); + // https://learn.microsoft.com/windows/win32/search/-search-win7-development-scenarios#library-descriptions + var libFiles = Directory.EnumerateFiles(ShellLibraryItem.LibrariesPath, "*" + ShellLibraryItem.EXTENSION); + foreach (var libFile in libFiles) + { + using var libraryFile = ShellItem.Open(libFile); + using var library = new ShellLibraryEx(libraryFile.IShellItem, true); + libraryItems.Add(ShellFolderExtensions.GetShellLibraryItem(library, libFile)); + } + return libraryItems; + } + catch (Exception e) + { + App.Logger.LogWarning(e, null); + } + + return []; + }, App.Logger); + + return libraries.Select(lib => new LibraryLocationItem(lib)).ToList(); + } + + public async Task CreateNewLibrary(string name) + { + if (string.IsNullOrWhiteSpace(name) || !CanCreateLibrary(name).result) + return false; + + var shellLibrary = await STATask.Run(() => + { + try + { + using var library = new ShellLibraryEx(name, PInvoke.FOLDERID_Libraries, false); + library.Folders.Add(ShellItem.Open(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments))); // Add default folder so it's not empty + library.Commit(); + library.Reload(); + var libraryPath = library.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); + return Task.FromResult(libraryPath is null + ? null + : ShellFolderExtensions.GetShellLibraryItem(library, libraryPath)); + } + catch (Exception e) + { + App.Logger.LogWarning(e, null); + } + + return Task.FromResult(null); + }, App.Logger); + + if (shellLibrary is null) + return false; + + var newLib = new LibraryLocationItem(shellLibrary); + lock (libraries) + { + libraries.Add(newLib); + } + DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, newLib)); + return true; + } + + public async Task UpdateLibrary(string libraryPath, string? defaultSaveFolder = null, string[]? folders = null, bool? isPinned = null) + { + if (string.IsNullOrWhiteSpace(libraryPath) || (defaultSaveFolder is null && folders is null && isPinned is null)) + // Nothing to update + return null; + + var item = await STATask.Run(() => + { + try + { + bool updated = false; + using var libraryFile = ShellItem.Open(libraryPath); + using var library = new ShellLibraryEx(libraryFile.IShellItem, false); + if (folders is not null) + { + if (folders.Length > 0) + { + var foldersToRemove = library.Folders + .Where(f => !folders.Any(folderPath => string.Equals(folderPath, f.FileSystemPath, StringComparison.OrdinalIgnoreCase))) + .ToList(); + foreach (var toRemove in foldersToRemove) + { + if (library.Folders.Remove(toRemove)) + { + toRemove.Dispose(); + updated = true; + } + } + var foldersToAdd = folders.Distinct(StringComparer.OrdinalIgnoreCase) + .Where(folderPath => !library.Folders.Any(f => string.Equals(folderPath, f.FileSystemPath, StringComparison.OrdinalIgnoreCase))) + .Select(ShellItem.Open) + .ToList(); + foreach (var toAdd in foldersToAdd) + { + library.Folders.Add(toAdd); + updated = true; + } + foreach (var toAdd in foldersToAdd) + { + toAdd.Dispose(); + } + } + } + if (defaultSaveFolder is not null) + { + using var saveFolder = ShellItem.Open(defaultSaveFolder); + library.DefaultSaveFolder = saveFolder; + updated = true; + } + if (isPinned is not null) + { + library.PinnedToNavigationPane = isPinned == true; + updated = true; + } + if (updated) + { + library.Commit(); + library.Reload(); // Reload folders list + return Task.FromResult(ShellFolderExtensions.GetShellLibraryItem(library, libraryPath)); + } + } + catch (Exception e) + { + App.Logger.LogWarning(e, null); + } + + return Task.FromResult(null); + }, App.Logger); + + var newLib = item is not null ? new LibraryLocationItem(item) : null; + if (newLib is not null) + { + var libItem = Libraries.FirstOrDefault(l => string.Equals(l.Path, libraryPath, StringComparison.OrdinalIgnoreCase)); + if (libItem is not null) + { + lock (libraries) + { + libraries[libraries.IndexOf(libItem)] = newLib; + } + DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newLib, libItem)); + } + return newLib; + } + return null; + } + + public static async Task ShowRestoreDefaultLibrariesDialogAsync() + { + var dialog = new DynamicDialog(new DynamicDialogViewModel + { + TitleText = Strings.DialogRestoreLibrariesTitleText.GetLocalizedResource(), + SubtitleText = Strings.DialogRestoreLibrariesSubtitleText.GetLocalizedResource(), + PrimaryButtonText = Strings.Restore.GetLocalizedResource(), + CloseButtonText = Strings.Cancel.GetLocalizedResource(), + PrimaryButtonAction = async (vm, e) => + { + await ContextMenu.InvokeVerb("restorelibraries", ShellLibraryItem.LibrariesPath); + await App.LibraryManager.UpdateLibrariesAsync(); + }, + CloseButtonAction = (vm, e) => vm.Hide(), + KeyDownAction = (vm, e) => + { + if (e.Key == VirtualKey.Escape) + { + vm.Hide(); + } + }, + DynamicButtons = DynamicDialogButtons.Primary | DynamicDialogButtons.Cancel + }); + await dialog.ShowAsync(); + } + + public static async Task ShowCreateNewLibraryDialogAsync() + { + var inputText = new TextBox + { + PlaceholderText = Strings.FolderWidgetCreateNewLibraryInputPlaceholderText.GetLocalizedResource() + }; + var tipText = new TextBlock + { + Text = string.Empty, + Visibility = Visibility.Collapsed + }; + + var dialog = new DynamicDialog(new DynamicDialogViewModel + { + DisplayControl = new Grid + { + Children = + { + new StackPanel + { + Spacing = 4d, + Children = + { + inputText, + tipText + } + } + } + }, + TitleText = Strings.FolderWidgetCreateNewLibraryDialogTitleText.GetLocalizedResource(), + SubtitleText = Strings.SideBarCreateNewLibraryText.GetLocalizedResource(), + PrimaryButtonText = Strings.Create.GetLocalizedResource(), + CloseButtonText = Strings.Cancel.GetLocalizedResource(), + PrimaryButtonAction = async (vm, e) => + { + var (result, reason) = App.LibraryManager.CanCreateLibrary(inputText.Text); + tipText.Text = reason; + tipText.Visibility = result ? Visibility.Collapsed : Visibility.Visible; + if (!result) + { + e.Cancel = true; + return; + } + await App.LibraryManager.CreateNewLibrary(inputText.Text); + }, + CloseButtonAction = (vm, e) => + { + vm.Hide(); + }, + KeyDownAction = async (vm, e) => + { + if (e.Key == VirtualKey.Enter) + { + await App.LibraryManager.CreateNewLibrary(inputText.Text); + } + else if (e.Key == VirtualKey.Escape) + { + vm.Hide(); + } + }, + DynamicButtons = DynamicDialogButtons.Primary | DynamicDialogButtons.Cancel + }); + await dialog.ShowAsync(); + } + + private void OnLibraryChanged(WatcherChangeTypes changeType, string? oldPath, string? newPath) + { + if (newPath is not null && (!newPath.ToLowerInvariant().EndsWith(ShellLibraryItem.EXTENSION, StringComparison.Ordinal) || !File.Exists(newPath))) + { + System.Diagnostics.Debug.WriteLine($"Ignored library event: {changeType}, {oldPath} -> {newPath}"); + return; + } + + System.Diagnostics.Debug.WriteLine($"Library event: {changeType}, {oldPath} -> {newPath}"); + + if (!changeType.HasFlag(WatcherChangeTypes.Deleted)) + { + if (newPath is null) + { + App.Logger.LogWarning($"Failed to open library after {changeType}: {LogPathHelper.RedactPath(newPath)}"); + return; + } + + using var libraryFile = SafetyExtensions.IgnoreExceptions(() => ShellItem.Open(newPath)); + var library = SafetyExtensions.IgnoreExceptions(() => new ShellLibraryEx(libraryFile!.IShellItem, true)); + if (library is null) + { + App.Logger.LogWarning($"Failed to open library after {changeType}: {LogPathHelper.RedactPath(newPath)}"); + return; + } + + var library1 = SafetyExtensions.IgnoreExceptions(() => ShellFolderExtensions.GetShellLibraryItem(library, newPath)); + if (library1 is null) + { + App.Logger.LogWarning($"Failed to open library after {changeType}: {LogPathHelper.RedactPath(newPath)}"); + return; + } + + string? path = oldPath; + if (string.IsNullOrEmpty(oldPath)) + { + path = library1.FullPath; + } + var changedLibrary = Libraries.FirstOrDefault(l => string.Equals(l.Path, path, StringComparison.OrdinalIgnoreCase)); + if (changedLibrary is not null) + { + lock (libraries) + { + libraries.Remove(changedLibrary); + } + DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, changedLibrary)); + } + // library is null in case it was deleted + if (!Libraries.Any(x => x.Path == library1.FullPath)) + { + var libItem = new LibraryLocationItem(library1); + lock (libraries) + { + libraries.Add(libItem); + } + DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, libItem)); + } + + library.Dispose(); + } + } + + private void OnLibraryChanged(object sender, FileSystemEventArgs e) + { + switch (e.ChangeType) + { + case WatcherChangeTypes.Created: + case WatcherChangeTypes.Changed: + OnLibraryChanged(e.ChangeType, e.FullPath, e.FullPath); + break; + case WatcherChangeTypes.Deleted: + OnLibraryChanged(e.ChangeType, e.FullPath, null); + break; + } + } + + private void OnLibraryRenamed(object sender, RenamedEventArgs e) + => OnLibraryChanged(e.ChangeType, e.OldFullPath, e.FullPath); + + } +} diff --git a/src/Files.App/Utils/Library/LibraryManager.cs b/src/Files.App/Utils/Library/LibraryManager.cs index 44691786fa92..ddd9bd496c6f 100644 --- a/src/Files.App/Utils/Library/LibraryManager.cs +++ b/src/Files.App/Utils/Library/LibraryManager.cs @@ -8,8 +8,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using Windows.System; -using Windows.Win32; -using Windows.Win32.UI.Shell; using Visibility = Microsoft.UI.Xaml.Visibility; namespace Files.App.Utils.Library @@ -43,63 +41,16 @@ public LibraryManager() #endif } - private void InitializeWatcher() - { - if (librariesWatcher is not null) - return; - - librariesWatcher = new FileSystemWatcher - { - Path = ShellLibraryItem.LibrariesPath, - Filter = "*" + ShellLibraryItem.EXTENSION, - NotifyFilter = NotifyFilters.Attributes | NotifyFilters.LastWrite | NotifyFilters.FileName, - IncludeSubdirectories = false, - }; - - librariesWatcher.Created += OnLibraryChanged; - librariesWatcher.Changed += OnLibraryChanged; - librariesWatcher.Deleted += OnLibraryChanged; - librariesWatcher.Renamed += OnLibraryRenamed; - - librariesWatcher.EnableRaisingEvents = true; - } /// /// Get libraries of the current user with the help of the FullTrust process. /// /// List of library items - public static async Task> ListUserLibraries() - { #if !WINDOWS - // LINUX-TODO(libraries): no shell libraries on Linux - return await Task.FromResult>([]); -#else - var libraries = await STATask.Run(() => - { - try - { - var libraryItems = new List(); - // https://learn.microsoft.com/windows/win32/search/-search-win7-development-scenarios#library-descriptions - var libFiles = Directory.EnumerateFiles(ShellLibraryItem.LibrariesPath, "*" + ShellLibraryItem.EXTENSION); - foreach (var libFile in libFiles) - { - using var libraryFile = ShellItem.Open(libFile); - using var library = new ShellLibraryEx(libraryFile.IShellItem, true); - libraryItems.Add(ShellFolderExtensions.GetShellLibraryItem(library, libFile)); - } - return libraryItems; - } - catch (Exception e) - { - App.Logger.LogWarning(e, null); - } - - return []; - }, App.Logger); - - return libraries.Select(lib => new LibraryLocationItem(lib)).ToList(); + // LINUX-TODO(libraries): shell libraries are unavailable on Linux. + public static Task> ListUserLibraries() + => Task.FromResult>([]); #endif - } public async Task UpdateLibrariesAsync() { @@ -121,7 +72,7 @@ public async Task UpdateLibrariesAsync() public bool TryGetLibrary(string? path, [NotNullWhen(true)] out LibraryLocationItem? library) { - if (string.IsNullOrWhiteSpace(path) || !path.EndsWith(ShellLibraryItem.EXTENSION, StringComparison.OrdinalIgnoreCase)) + if (!Ioc.Default.GetRequiredService().SupportsLibraries || string.IsNullOrWhiteSpace(path) || !path.EndsWith(ShellLibraryItem.EXTENSION, StringComparison.OrdinalIgnoreCase)) { library = null; return false; @@ -135,43 +86,10 @@ public bool TryGetLibrary(string? path, [NotNullWhen(true)] out LibraryLocationI /// /// The name of the new library (must be unique) /// The new library if successfully created - public async Task CreateNewLibrary(string name) - { - if (string.IsNullOrWhiteSpace(name) || !CanCreateLibrary(name).result) - return false; - - var shellLibrary = await STATask.Run(() => - { - try - { - using var library = new ShellLibraryEx(name, PInvoke.FOLDERID_Libraries, false); - library.Folders.Add(ShellItem.Open(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments))); // Add default folder so it's not empty - library.Commit(); - library.Reload(); - var libraryPath = library.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); - return Task.FromResult(libraryPath is null - ? null - : ShellFolderExtensions.GetShellLibraryItem(library, libraryPath)); - } - catch (Exception e) - { - App.Logger.LogWarning(e, null); - } - - return Task.FromResult(null); - }, App.Logger); - - if (shellLibrary is null) - return false; - - var newLib = new LibraryLocationItem(shellLibrary); - lock (libraries) - { - libraries.Add(newLib); - } - DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, newLib)); - return true; - } +#if !WINDOWS + public Task CreateNewLibrary(string name) + => Task.FromResult(false); +#endif /// /// Update library details. @@ -181,94 +99,16 @@ public async Task CreateNewLibrary(string name) /// Update the library folders or null to keep current /// Update the library pinned status or null to keep current /// The new library if successfully updated - public async Task UpdateLibrary(string libraryPath, string? defaultSaveFolder = null, string[]? folders = null, bool? isPinned = null) - { - if (string.IsNullOrWhiteSpace(libraryPath) || (defaultSaveFolder is null && folders is null && isPinned is null)) - // Nothing to update - return null; - - var item = await STATask.Run(() => - { - try - { - bool updated = false; - using var libraryFile = ShellItem.Open(libraryPath); - using var library = new ShellLibraryEx(libraryFile.IShellItem, false); - if (folders is not null) - { - if (folders.Length > 0) - { - var foldersToRemove = library.Folders - .Where(f => !folders.Any(folderPath => string.Equals(folderPath, f.FileSystemPath, StringComparison.OrdinalIgnoreCase))) - .ToList(); - foreach (var toRemove in foldersToRemove) - { - if (library.Folders.Remove(toRemove)) - { - toRemove.Dispose(); - updated = true; - } - } - var foldersToAdd = folders.Distinct(StringComparer.OrdinalIgnoreCase) - .Where(folderPath => !library.Folders.Any(f => string.Equals(folderPath, f.FileSystemPath, StringComparison.OrdinalIgnoreCase))) - .Select(ShellItem.Open) - .ToList(); - foreach (var toAdd in foldersToAdd) - { - library.Folders.Add(toAdd); - updated = true; - } - foreach (var toAdd in foldersToAdd) - { - toAdd.Dispose(); - } - } - } - if (defaultSaveFolder is not null) - { - using var saveFolder = ShellItem.Open(defaultSaveFolder); - library.DefaultSaveFolder = saveFolder; - updated = true; - } - if (isPinned is not null) - { - library.PinnedToNavigationPane = isPinned == true; - updated = true; - } - if (updated) - { - library.Commit(); - library.Reload(); // Reload folders list - return Task.FromResult(ShellFolderExtensions.GetShellLibraryItem(library, libraryPath)); - } - } - catch (Exception e) - { - App.Logger.LogWarning(e, null); - } - - return Task.FromResult(null); - }, App.Logger); - - var newLib = item is not null ? new LibraryLocationItem(item) : null; - if (newLib is not null) - { - var libItem = Libraries.FirstOrDefault(l => string.Equals(l.Path, libraryPath, StringComparison.OrdinalIgnoreCase)); - if (libItem is not null) - { - lock (libraries) - { - libraries[libraries.IndexOf(libItem)] = newLib; - } - DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newLib, libItem)); - } - return newLib; - } - return null; - } +#if !WINDOWS + public Task UpdateLibrary(string libraryPath, string? defaultSaveFolder = null, string[]? folders = null, bool? isPinned = null) + => Task.FromResult(null); +#endif public (bool result, string reason) CanCreateLibrary(string name) { + if (!Ioc.Default.GetRequiredService().SupportsLibraries) + return (false, string.Empty); + if (string.IsNullOrWhiteSpace(name)) { return (false, Strings.ErrorInputEmpty.GetLocalizedResource()); @@ -289,178 +129,20 @@ public async Task CreateNewLibrary(string name) return (true, string.Empty); } - public static async Task ShowRestoreDefaultLibrariesDialogAsync() - { - var dialog = new DynamicDialog(new DynamicDialogViewModel - { - TitleText = Strings.DialogRestoreLibrariesTitleText.GetLocalizedResource(), - SubtitleText = Strings.DialogRestoreLibrariesSubtitleText.GetLocalizedResource(), - PrimaryButtonText = Strings.Restore.GetLocalizedResource(), - CloseButtonText = Strings.Cancel.GetLocalizedResource(), - PrimaryButtonAction = async (vm, e) => - { - await ContextMenu.InvokeVerb("restorelibraries", ShellLibraryItem.LibrariesPath); - await App.LibraryManager.UpdateLibrariesAsync(); - }, - CloseButtonAction = (vm, e) => vm.Hide(), - KeyDownAction = (vm, e) => - { - if (e.Key == VirtualKey.Escape) - { - vm.Hide(); - } - }, - DynamicButtons = DynamicDialogButtons.Primary | DynamicDialogButtons.Cancel - }); - await dialog.ShowAsync(); - } - - public static async Task ShowCreateNewLibraryDialogAsync() - { - var inputText = new TextBox - { - PlaceholderText = Strings.FolderWidgetCreateNewLibraryInputPlaceholderText.GetLocalizedResource() - }; - var tipText = new TextBlock - { - Text = string.Empty, - Visibility = Visibility.Collapsed - }; - - var dialog = new DynamicDialog(new DynamicDialogViewModel - { - DisplayControl = new Grid - { - Children = - { - new StackPanel - { - Spacing = 4d, - Children = - { - inputText, - tipText - } - } - } - }, - TitleText = Strings.FolderWidgetCreateNewLibraryDialogTitleText.GetLocalizedResource(), - SubtitleText = Strings.SideBarCreateNewLibraryText.GetLocalizedResource(), - PrimaryButtonText = Strings.Create.GetLocalizedResource(), - CloseButtonText = Strings.Cancel.GetLocalizedResource(), - PrimaryButtonAction = async (vm, e) => - { - var (result, reason) = App.LibraryManager.CanCreateLibrary(inputText.Text); - tipText.Text = reason; - tipText.Visibility = result ? Visibility.Collapsed : Visibility.Visible; - if (!result) - { - e.Cancel = true; - return; - } - await App.LibraryManager.CreateNewLibrary(inputText.Text); - }, - CloseButtonAction = (vm, e) => - { - vm.Hide(); - }, - KeyDownAction = async (vm, e) => - { - if (e.Key == VirtualKey.Enter) - { - await App.LibraryManager.CreateNewLibrary(inputText.Text); - } - else if (e.Key == VirtualKey.Escape) - { - vm.Hide(); - } - }, - DynamicButtons = DynamicDialogButtons.Primary | DynamicDialogButtons.Cancel - }); - await dialog.ShowAsync(); - } - - private void OnLibraryChanged(WatcherChangeTypes changeType, string? oldPath, string? newPath) - { - if (newPath is not null && (!newPath.ToLowerInvariant().EndsWith(ShellLibraryItem.EXTENSION, StringComparison.Ordinal) || !File.Exists(newPath))) - { - System.Diagnostics.Debug.WriteLine($"Ignored library event: {changeType}, {oldPath} -> {newPath}"); - return; - } - - System.Diagnostics.Debug.WriteLine($"Library event: {changeType}, {oldPath} -> {newPath}"); - - if (!changeType.HasFlag(WatcherChangeTypes.Deleted)) - { - if (newPath is null) - { - App.Logger.LogWarning($"Failed to open library after {changeType}: {LogPathHelper.RedactPath(newPath)}"); - return; - } - - using var libraryFile = SafetyExtensions.IgnoreExceptions(() => ShellItem.Open(newPath)); - var library = SafetyExtensions.IgnoreExceptions(() => new ShellLibraryEx(libraryFile!.IShellItem, true)); - if (library is null) - { - App.Logger.LogWarning($"Failed to open library after {changeType}: {LogPathHelper.RedactPath(newPath)}"); - return; - } - - var library1 = SafetyExtensions.IgnoreExceptions(() => ShellFolderExtensions.GetShellLibraryItem(library, newPath)); - if (library1 is null) - { - App.Logger.LogWarning($"Failed to open library after {changeType}: {LogPathHelper.RedactPath(newPath)}"); - return; - } - - string? path = oldPath; - if (string.IsNullOrEmpty(oldPath)) - { - path = library1.FullPath; - } - var changedLibrary = Libraries.FirstOrDefault(l => string.Equals(l.Path, path, StringComparison.OrdinalIgnoreCase)); - if (changedLibrary is not null) - { - lock (libraries) - { - libraries.Remove(changedLibrary); - } - DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, changedLibrary)); - } - // library is null in case it was deleted - if (!Libraries.Any(x => x.Path == library1.FullPath)) - { - var libItem = new LibraryLocationItem(library1); - lock (libraries) - { - libraries.Add(libItem); - } - DataChanged?.Invoke(SectionType.Library, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, libItem)); - } +#if !WINDOWS + public static Task ShowRestoreDefaultLibrariesDialogAsync() + => Task.CompletedTask; +#endif - library.Dispose(); - } - } +#if !WINDOWS + public static Task ShowCreateNewLibraryDialogAsync() + => Task.CompletedTask; +#endif - private void OnLibraryChanged(object sender, FileSystemEventArgs e) - { - switch (e.ChangeType) - { - case WatcherChangeTypes.Created: - case WatcherChangeTypes.Changed: - OnLibraryChanged(e.ChangeType, e.FullPath, e.FullPath); - break; - case WatcherChangeTypes.Deleted: - OnLibraryChanged(e.ChangeType, e.FullPath, null); - break; - } - } - private void OnLibraryRenamed(object sender, RenamedEventArgs e) - => OnLibraryChanged(e.ChangeType, e.OldFullPath, e.FullPath); public static bool IsLibraryPath(string path) - => !string.IsNullOrEmpty(path) && path.EndsWith(ShellLibraryItem.EXTENSION, StringComparison.OrdinalIgnoreCase); + => Ioc.Default.GetRequiredService().SupportsLibraries && !string.IsNullOrEmpty(path) && path.EndsWith(ShellLibraryItem.EXTENSION, StringComparison.OrdinalIgnoreCase); public void Dispose() => librariesWatcher?.Dispose(); diff --git a/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.Windows.cs b/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.Windows.cs new file mode 100644 index 000000000000..1e649cc1744b --- /dev/null +++ b/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.Windows.cs @@ -0,0 +1,66 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows.Input; +using Windows.Win32; +using Windows.Win32.UI.Shell; + +namespace Files.App.ViewModels.Dialogs +{ + public sealed partial class CreateShortcutDialogViewModel : ObservableObject + { + private unsafe Task SelectDestination() + { + BROWSEINFOW bi = new() + { + ulFlags = 0x00004000 + }; + + Span displayName = stackalloc char[260]; + fixed (char* displayNameBuffer = displayName) + fixed (char* title = "Select a folder") + { + bi.pszDisplayName = displayNameBuffer; + bi.lpszTitle = title; + var pidl = PInvoke.SHBrowseForFolder(in bi); + if (pidl is not null) + { + Span path = stackalloc char[260]; + fixed (char* pathBuffer = path) + { + if (PInvoke.SHGetPathFromIDList(pidl, pathBuffer)) + { + var length = path.IndexOf('\0'); + ShortcutTarget = path[..(length < 0 ? path.Length : length)].ToString(); + } + } + + Marshal.FreeCoTaskMem((nint)pidl); + } + } + + return Task.CompletedTask; + } + + private async Task CreateShortcutAsync() + { + var extension = DestinationPathExists ? ".lnk" : ".url"; + + var shortcutName = FilesystemHelpers.GetShortcutNamingPreference(_shortcutName); + ShortcutCompleteName = shortcutName + extension; + var filePath = Path.Combine(WorkingDirectory, ShortcutCompleteName); + + int fileNumber = 1; + while (Path.Exists(filePath)) + { + ShortcutCompleteName = shortcutName + $" ({++fileNumber})" + extension; + filePath = Path.Combine(WorkingDirectory, ShortcutCompleteName); + } + + ShortcutCreatedSuccessfully = await FileOperationsHelpers.CreateOrUpdateLinkAsync(filePath, FullPath, Arguments); + } + } +} diff --git a/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.cs index 099240f13ba7..8295ea38a65a 100644 --- a/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/CreateShortcutDialogViewModel.cs @@ -3,10 +3,7 @@ using Files.Shared.Helpers; using System.IO; -using System.Runtime.InteropServices; using System.Windows.Input; -using Windows.Win32; -using Windows.Win32.UI.Shell; namespace Files.App.ViewModels.Dialogs { @@ -208,7 +205,7 @@ public bool IsLocationValid public bool ShowNameWarningTip => !string.IsNullOrEmpty(_shortcutTarget) && !FilesystemHelpers.IsValidForFilename(_shortcutName ?? string.Empty); - public bool IsShortcutValid => _isLocationValid && !ShowNameWarningTip && !string.IsNullOrEmpty(_shortcutTarget); + public bool IsShortcutValid => Ioc.Default.GetRequiredService().SupportsShortcutFiles && _isLocationValid && !ShowNameWarningTip && !string.IsNullOrEmpty(_shortcutTarget); // Command invoked when the user clicks the 'Browse' button public ICommand SelectDestinationCommand { get; private set; } @@ -230,56 +227,16 @@ private bool IsValidAbsolutePath(string path) return Path.Exists(path) && Path.IsPathFullyQualified(path) && path != Path.GetPathRoot(path); } - private unsafe Task SelectDestination() - { - BROWSEINFOW bi = new() - { - ulFlags = 0x00004000 - }; +#if !WINDOWS + // LINUX-TODO(shortcuts): native shortcut creation is hidden on Linux. + private Task SelectDestination() + => Task.CompletedTask; +#endif - Span displayName = stackalloc char[260]; - fixed (char* displayNameBuffer = displayName) - fixed (char* title = "Select a folder") - { - bi.pszDisplayName = displayNameBuffer; - bi.lpszTitle = title; - var pidl = PInvoke.SHBrowseForFolder(in bi); - if (pidl is not null) - { - Span path = stackalloc char[260]; - fixed (char* pathBuffer = path) - { - if (PInvoke.SHGetPathFromIDList(pidl, pathBuffer)) - { - var length = path.IndexOf('\0'); - ShortcutTarget = path[..(length < 0 ? path.Length : length)].ToString(); - } - } - - Marshal.FreeCoTaskMem((nint)pidl); - } - } - - return Task.CompletedTask; - } - - private async Task CreateShortcutAsync() - { - var extension = DestinationPathExists ? ".lnk" : ".url"; - - var shortcutName = FilesystemHelpers.GetShortcutNamingPreference(_shortcutName); - ShortcutCompleteName = shortcutName + extension; - var filePath = Path.Combine(WorkingDirectory, ShortcutCompleteName); - - int fileNumber = 1; - while (Path.Exists(filePath)) - { - ShortcutCompleteName = shortcutName + $" ({++fileNumber})" + extension; - filePath = Path.Combine(WorkingDirectory, ShortcutCompleteName); - } - - ShortcutCreatedSuccessfully = await FileOperationsHelpers.CreateOrUpdateLinkAsync(filePath, FullPath, Arguments); - } +#if !WINDOWS + private Task CreateShortcutAsync() + => Task.CompletedTask; +#endif private void AutoFillName() { From a6956f6c4cc9b962c63d6da27a5afa42454f8a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 20:58:24 +0300 Subject: [PATCH 2/4] Gate dropped-item executable launch behind the Linux run confirmation Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Co-Authored-By: Claude Sonnet 5.5 --- .../Navigation/NavigationHelpers.Linux.cs | 22 ++++++++++++--- .../Helpers/Navigation/NavigationHelpers.cs | 2 +- .../Launching/OpenDecision.cs | 5 ++++ .../Launching/OpenDecisionTests.cs | 27 +++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs index 2a5fc6eadecb..ca79cfa56ec9 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs @@ -186,7 +186,23 @@ internal static async Task OpenFileLinuxAsync(string path, bool openViaApp return await ExecutePlanAsync(path, await PlanAsync(path)); } - private static async Task ExecutePlanAsync(string path, LinuxOpenPlan plan) + /// + /// Runs an executable with the dropped items as arguments. Goes through the same plan and confirmation as opening it; + /// anything that is not a confirmable executable is refused. + /// + internal static async Task RunWithItemsLinuxAsync(string executablePath, IReadOnlyList arguments) + { + var plan = await PlanAsync(executablePath); + if (!OpenDecision.NeedsRunConfirmation(plan.Action)) + { + await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenRefusedTitle.GetLocalizedResource(), Strings.LinuxOpenRefusedText.GetLocalizedFormatResource(DisplaySanitizer.Field(plan.Target))); + return false; + } + + return await ExecutePlanAsync(executablePath, plan, arguments); + } + + private static async Task ExecutePlanAsync(string path, LinuxOpenPlan plan, IReadOnlyList? arguments = null) { var target = plan.Target; var workingDirectory = Path.GetDirectoryName(target); @@ -217,7 +233,7 @@ async Task ChangedAsync() if (!confirmed) return true; - return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, null, workingDirectory) : await ChangedAsync(); + return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, arguments, workingDirectory) : await ChangedAsync(); } case OpenAction.RunScriptWithConfirm: @@ -242,7 +258,7 @@ async Task ChangedAsync() switch (OpenDecision.Resolve(plan.Action, choice)) { case FollowUp.RunExact: - return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, null, workingDirectory) : await ChangedAsync(); + return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, arguments, workingDirectory) : await ChangedAsync(); case FollowUp.DisplayAsText: return plan.StillValid() ? await DisplayAsTextAsync(target) : await ChangedAsync(); default: diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs index 513894335d75..d5bd2dc370be 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs @@ -477,7 +477,7 @@ public static async Task OpenItemsWithExecutableAsync(IShellPage associatedInsta var arguments = string.Join(" ", items.Select(item => $"\"{item.Path}\"")); await Win32Helper.InvokeWin32ComponentAsync(executablePath, associatedInstance, arguments); #else - await LinuxLauncher.RunExecutableAsync(executablePath, items.Select(item => item.Path).ToArray(), workingDirectory); + await RunWithItemsLinuxAsync(executablePath, items.Select(item => item.Path).ToArray()); #endif } diff --git a/src/Files.Platform.Linux/Launching/OpenDecision.cs b/src/Files.Platform.Linux/Launching/OpenDecision.cs index 1a98505583d1..10b0151d839b 100644 --- a/src/Files.Platform.Linux/Launching/OpenDecision.cs +++ b/src/Files.Platform.Linux/Launching/OpenDecision.cs @@ -128,6 +128,11 @@ public static class OpenDecision "application/x-desktop", ]; + /// + /// True when running the file needs the user's explicit confirmation first (the only actions that may ever start a file's own code). + /// + public static bool NeedsRunConfirmation(OpenAction action) => action is OpenAction.RunBinaryWithConfirm or OpenAction.RunScriptWithConfirm; + /// /// Maps the user's answer to the one follow-up the dialog described. Anything but an explicit Run or Display does nothing. /// diff --git a/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs b/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs index 7724dcd8f1f0..e0f53967871b 100644 --- a/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs +++ b/tests/Files.Platform.Tests/Launching/OpenDecisionTests.cs @@ -31,6 +31,33 @@ public void ContentDecidesNeverTheName(bool exec, ExecutableKind kind, OpenActio public void ExecutableMimeWithoutMatchingContent_IsRefused(string mime) => Assert.AreEqual(OpenAction.Refuse, OpenDecision.Decide(DesktopState.None, false, true, ExecutableKind.None, mime)); + [TestMethod] + [DataRow(OpenAction.RunBinaryWithConfirm, true)] + [DataRow(OpenAction.RunScriptWithConfirm, true)] + [DataRow(OpenAction.OpenDefault, false)] + [DataRow(OpenAction.Refuse, false)] + [DataRow(OpenAction.LaunchDesktopTrusted, false)] + public void OnlyConfirmedActionsMayRunAFile(OpenAction action, bool expected) + => Assert.AreEqual(expected, OpenDecision.NeedsRunConfirmation(action)); + + [TestMethod] + public async System.Threading.Tasks.Task DryRunStarter_SpawnsNothing() + { + var writer = new StringWriter(); + var previous = System.Console.Error; + System.Console.SetError(writer); + try + { + await new DryRunProcessStarter().StartDetachedAsync(new ProcessLaunch("/nonexistent/evil", ["a"], null)); + } + finally + { + System.Console.SetError(previous); + } + + StringAssert.Contains(writer.ToString(), "[launch-dryrun] /nonexistent/evil a"); + } + [TestMethod] public void Desktop_TrustedOnlyInApplicationsDirectory_ExecBitIsNotTrust() { From e64fff8a68797caa9c2308e5923a7af79af51535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 21:01:56 +0300 Subject: [PATCH 3/4] Keep build green with ShowWindowCommand enum and shared factory Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Co-Authored-By: Claude Sonnet 5.5 --- ...Windows.cs => SecurityAdvancedAccessControlItemFactory.cs} | 0 src/Files.App/Data/Items/ContextMenu.cs | 4 ---- src/Files.App/Utils/Shell/ShellFolderExtensions.cs | 2 +- .../Utils/Storage/Enumerators/Win32StorageEnumerator.cs | 4 ++-- src/Files.App/Utils/Storage/Search/FolderSearch.cs | 2 +- src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs | 2 +- .../Utils/Storage/StorageItems/ShellStorageFolder.cs | 2 +- 7 files changed, 6 insertions(+), 10 deletions(-) rename src/Files.App/Data/Factories/{SecurityAdvancedAccessControlItemFactory.Windows.cs => SecurityAdvancedAccessControlItemFactory.cs} (100%) diff --git a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.Windows.cs b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs similarity index 100% rename from src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.Windows.cs rename to src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs diff --git a/src/Files.App/Data/Items/ContextMenu.cs b/src/Files.App/Data/Items/ContextMenu.cs index 5d0a41d9b2fd..091657de6a25 100644 --- a/src/Files.App/Data/Items/ContextMenu.cs +++ b/src/Files.App/Data/Items/ContextMenu.cs @@ -1,9 +1,7 @@ // Copyright (c) Files Community // Licensed under the MIT License. -#if WINDOWS using Windows.Win32.UI.WindowsAndMessaging; -#endif namespace Files.App.Data.Items { @@ -33,9 +31,7 @@ public class Win32ContextMenuItem public int ID { get; set; } // Valid only in current menu to invoke item public string? Label { get; set; } public string? CommandString { get; set; } -#if WINDOWS public MENU_ITEM_TYPE Type { get; set; } -#endif public List? SubItems { get; set; } } } diff --git a/src/Files.App/Utils/Shell/ShellFolderExtensions.cs b/src/Files.App/Utils/Shell/ShellFolderExtensions.cs index 94d1d54aa26f..e5ae5f47f530 100644 --- a/src/Files.App/Utils/Shell/ShellFolderExtensions.cs +++ b/src/Files.App/Utils/Shell/ShellFolderExtensions.cs @@ -125,7 +125,7 @@ public static ShellLibraryItem GetShellLibraryItem(ShellLibraryEx library, strin // which can block on unreachable network locations IsFolder = linkItem.StoredTargetIsFolder() ?? linkItem.IsTargetFolder(targetPath), RunAsAdmin = linkItem.RunAsAdministrator, - ShowWindowCommand = (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)linkItem.ShowState, + ShowWindowCommand = (Files.App.Data.Enums.ShowWindowCommand)linkItem.ShowState, Arguments = linkItem.Arguments, WorkingDirectory = Environment.ExpandEnvironmentVariables(linkItem.WorkingDirectory), TargetPath = targetPath diff --git a/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs b/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs index 4cea4acfa92d..68a58420a202 100644 --- a/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs +++ b/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs @@ -385,7 +385,7 @@ CancellationToken cancellationToken Arguments = shInfo.Arguments, WorkingDirectory = shInfo.WorkingDirectory, RunAsAdmin = shInfo.RunAsAdmin, - ShowWindowCommand = shInfo.ShowWindowCommand, + ShowWindowCommand = (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)shInfo.ShowWindowCommand, IsUrl = isUrl, }; } @@ -411,7 +411,7 @@ CancellationToken cancellationToken Arguments = shInfo.Arguments, WorkingDirectory = shInfo.WorkingDirectory, RunAsAdmin = shInfo.RunAsAdmin, - ShowWindowCommand = shInfo.ShowWindowCommand, + ShowWindowCommand = (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)shInfo.ShowWindowCommand, IsUrl = isUrl, }; } diff --git a/src/Files.App/Utils/Storage/Search/FolderSearch.cs b/src/Files.App/Utils/Storage/Search/FolderSearch.cs index a16769298368..3d887775fc09 100644 --- a/src/Files.App/Utils/Storage/Search/FolderSearch.cs +++ b/src/Files.App/Utils/Storage/Search/FolderSearch.cs @@ -276,7 +276,7 @@ await DispatcherQueue.EnqueueOrInvokeAsync(() => shortcutItem.Arguments = link.Arguments; shortcutItem.WorkingDirectory = link.WorkingDirectory; shortcutItem.RunAsAdmin = link.RunAsAdmin; - shortcutItem.ShowWindowCommand = link.ShowWindowCommand; + shortcutItem.ShowWindowCommand = (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)link.ShowWindowCommand; shortcutItem.PrimaryItemAttribute = link.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File; } }); diff --git a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs index a6af5536b394..4727af593fb0 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs @@ -25,7 +25,7 @@ public ShortcutStorageFile(ShellLinkItem item) : base(item) Arguments = item.Arguments; WorkingDirectory = item.WorkingDirectory; RunAsAdmin = item.RunAsAdmin; - ShowWindowCommand = item.ShowWindowCommand; + ShowWindowCommand = (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)item.ShowWindowCommand; } } diff --git a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs index 919847dd177a..e5fd5aec6e92 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs @@ -24,7 +24,7 @@ public ShortcutStorageFolder(ShellLinkItem item) : base(item) Arguments = item.Arguments; WorkingDirectory = item.WorkingDirectory; RunAsAdmin = item.RunAsAdmin; - ShowWindowCommand = item.ShowWindowCommand; + ShowWindowCommand = (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)item.ShowWindowCommand; } } From bf17c78654ed73ed633c84034dd022de2b4970ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Mon, 5 Oct 2026 22:20:03 +0300 Subject: [PATCH 4/4] Show full argv before running an executable with dropped items; keep Windows toolbar behaviour Co-Authored-By: GPT-6.1-sol (OpenAI Codex) Co-Authored-By: Claude Sonnet 5.5 --- docs/linux-port/threat-model-launching.md | 1 + .../Navigation/NavigationHelpers.Linux.cs | 26 ++++++++++++++----- src/Files.App/UserControls/Toolbar.xaml.cs | 4 +++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/linux-port/threat-model-launching.md b/docs/linux-port/threat-model-launching.md index d4a6df62e445..8f5e2b31f23c 100644 --- a/docs/linux-port/threat-model-launching.md +++ b/docs/linux-port/threat-model-launching.md @@ -19,6 +19,7 @@ Attacker: controls a file's name, content, mode bits and symlinks (downloaded ar | 9 | Defense in depth inside the launcher: `OpenAsync` refuses executable MIME types (x-executable, x-pie-executable, x-sharedlib, appimage, x-desktop); no xdg-open fallback for files with an execute bit (also via symlink); `LaunchUriAsync` refuses `file:` URIs | `LinuxLauncherService` | `Open_ExecutableMimeTypes...`, `Open_ExecBit...`, `LaunchUri_RefusesFileUris` | | 10 | More than 5 files opened at once asks first; files needing a gate are processed one by one, never in the bulk default-app launch | `OpenFilesLinuxAsync` | (UI path, covered by 1-9) | | 11 | Dry-run seam (`FILES_LAUNCH_DRYRUN`) so automated runs spawn nothing | `DryRunProcessStarter` | n/a | +| 12 | Drop items onto an executable: same plan as gates 1-2 (only confirmable binaries/scripts); the dialog shows the full argv (target plus every dropped path, `DisplaySanitizer.FullArguments`, refused if too large) and exactly that argv is run after the identity re-check | `NavigationHelpers.RunWithItemsLinuxAsync` | `OnlyConfirmedActionsMayRunAFile`, `DisplaySanitizerTests` | ## Review of default-open paths that could execute diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs index ca79cfa56ec9..0738c686bbbc 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs @@ -187,22 +187,34 @@ internal static async Task OpenFileLinuxAsync(string path, bool openViaApp } /// - /// Runs an executable with the dropped items as arguments. Goes through the same plan and confirmation as opening it; - /// anything that is not a confirmable executable is refused. + /// Runs an executable with the dropped items as arguments. The complete argv (target and every item) is shown and + /// exactly that argv is run; anything that is not a confirmable executable, or too large to show in full, is refused. /// internal static async Task RunWithItemsLinuxAsync(string executablePath, IReadOnlyList arguments) { var plan = await PlanAsync(executablePath); - if (!OpenDecision.NeedsRunConfirmation(plan.Action)) + var argv = new List(arguments.Count + 1) { plan.Target }; + argv.AddRange(arguments); + + if (!OpenDecision.NeedsRunConfirmation(plan.Action) || DisplaySanitizer.FullArguments(argv) is not { } lines) { await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenRefusedTitle.GetLocalizedResource(), Strings.LinuxOpenRefusedText.GetLocalizedFormatResource(DisplaySanitizer.Field(plan.Target))); return false; } - return await ExecutePlanAsync(executablePath, plan, arguments); + var confirmed = await DialogDisplayHelper.ShowDialogAsync( + Strings.LinuxRunExecutableTitle.GetLocalizedFormatResource(DisplaySanitizer.Field(Path.GetFileName(plan.Target), 60)), + Strings.LinuxWouldRun.GetLocalizedResource() + "\n" + string.Join('\n', lines), + Strings.Run.GetLocalizedResource(), + Strings.Cancel.GetLocalizedResource()); + + if (!confirmed) + return true; + + return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(argv[0], argv.Skip(1).ToList(), Path.GetDirectoryName(argv[0])) : false; } - private static async Task ExecutePlanAsync(string path, LinuxOpenPlan plan, IReadOnlyList? arguments = null) + private static async Task ExecutePlanAsync(string path, LinuxOpenPlan plan) { var target = plan.Target; var workingDirectory = Path.GetDirectoryName(target); @@ -233,7 +245,7 @@ async Task ChangedAsync() if (!confirmed) return true; - return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, arguments, workingDirectory) : await ChangedAsync(); + return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, null, workingDirectory) : await ChangedAsync(); } case OpenAction.RunScriptWithConfirm: @@ -258,7 +270,7 @@ async Task ChangedAsync() switch (OpenDecision.Resolve(plan.Action, choice)) { case FollowUp.RunExact: - return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, arguments, workingDirectory) : await ChangedAsync(); + return plan.StillValid() ? await LinuxLauncher.RunExecutableAsync(target, null, workingDirectory) : await ChangedAsync(); case FollowUp.DisplayAsText: return plan.StillValid() ? await DisplayAsTextAsync(target) : await ChangedAsync(); default: diff --git a/src/Files.App/UserControls/Toolbar.xaml.cs b/src/Files.App/UserControls/Toolbar.xaml.cs index fee122a42ede..7ceb512871d1 100644 --- a/src/Files.App/UserControls/Toolbar.xaml.cs +++ b/src/Files.App/UserControls/Toolbar.xaml.cs @@ -513,7 +513,11 @@ private async Task PopulateGroupFlyoutAsync(MenuFlyout flyout, CommandGroup grou #endif foreach (var code in group.Commands) +#if WINDOWS + if (Commands[code] is { Code: not CommandCodes.None } cmd) +#else if (Commands[code] is { Code: not CommandCodes.None, IsExecutable: true } cmd) +#endif flyout.Items.Add(CreateGroupMenuItem(cmd)); if (group is NewItemCommandGroup && ViewModel?.InstanceViewModel.CanCreateFileInPage == true