From 02643a74483eaa4a87e65812f5bea7107d975eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 00:03:12 +0300 Subject: [PATCH 1/3] Open files from archives and make archive browsing read-only in the UI - Open archive members by extracting the single entry into a private 0700 temp dir and opening it through the existing launcher gates; clean up on exit and at next start. - Disable Delete, Cut, Paste and drop targets while inside an archive. - Tab headers split on both separators. - Add "Open as folder" to the context menu of archive files. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Actions/FileSystem/BaseDeleteAction.cs | 3 +- .../FileSystem/OpenArchiveAsFolderAction.cs | 64 ++++++++++ .../Actions/FileSystem/PasteItemAction.cs | 1 + .../FileSystem/PasteItemAsShortcutAction.cs | 1 + .../FileSystem/PasteItemToSelectionAction.cs | 2 +- .../Transfer/BaseTransferItemAction.cs | 4 +- .../FileSystem/Transfer/CutItemAction.cs | 4 + src/Files.App/App.xaml.cs | 4 + .../ContentPageContextFlyoutFactory.cs | 1 + .../Navigation/ArchiveOpenTempStore.Linux.cs | 116 ++++++++++++++++++ .../Navigation/NavigationHelpers.Linux.cs | 45 ++++++- .../Helpers/Navigation/NavigationHelpers.cs | 2 +- .../Desktop/Generated/ICommandManager.g.cs | 1 + src/Files.App/Strings/en-US/Resources.resw | 7 ++ .../ViewModels/Layouts/BaseLayoutViewModel.cs | 1 + .../NavigationToolbarViewModel.cs | 4 +- .../UserControls/SidebarViewModel.cs | 3 +- src/Files.App/Views/Layouts/BaseLayoutPage.cs | 6 +- 18 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 src/Files.App/Actions/FileSystem/OpenArchiveAsFolderAction.cs create mode 100644 src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs diff --git a/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs b/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs index f7664d02af5b..74364188c5a5 100644 --- a/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs +++ b/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs @@ -16,6 +16,7 @@ public virtual ActionCategory Category public override bool IsExecutable => context.HasSelection && + context.PageType != ContentPageTypes.ZipFolder && (!context.ShellPage?.SlimContentPage?.IsRenamingItem ?? false) && UIHelpers.CanShowDialog; @@ -47,7 +48,7 @@ item.PrimaryItemAttribute is StorageItemTypes.File private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(IContentPageContext.HasSelection)) + if (e.PropertyName is nameof(IContentPageContext.HasSelection) or nameof(IContentPageContext.PageType)) OnPropertyChanged(nameof(IsExecutable)); } } diff --git a/src/Files.App/Actions/FileSystem/OpenArchiveAsFolderAction.cs b/src/Files.App/Actions/FileSystem/OpenArchiveAsFolderAction.cs new file mode 100644 index 000000000000..6eafe9a967b0 --- /dev/null +++ b/src/Files.App/Actions/FileSystem/OpenArchiveAsFolderAction.cs @@ -0,0 +1,64 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using Windows.Storage; + +namespace Files.App.Actions +{ + /// + /// Browses an archive like a folder (Linux), while double-click keeps opening it in the default application. + /// + [GeneratedRichCommand] + internal sealed partial class OpenArchiveAsFolderAction : ObservableObject, IAction + { + private readonly IContentPageContext context; + + public string Label + => Strings.OpenArchiveAsFolder.GetLocalizedResource(); + + public string Description + => Strings.OpenArchiveAsFolderDescription.GetLocalizedResource(); + + public ActionCategory Category + => ActionCategory.Open; + + public RichGlyph Glyph + => new(themedIconStyle: "App.ThemedIcons.Folder"); + + public bool IsExecutable => + !OperatingSystem.IsWindows() && + context.ShellPage is not null && + context.PageType is ContentPageTypes.Folder && + context.SelectedItems.Count == 1 && + context.SelectedItem is { PrimaryItemAttribute: StorageItemTypes.File, ItemPath: { } path } && + FileExtensionHelpers.IsZipPath(path); + + public OpenArchiveAsFolderAction() + { + context = Ioc.Default.GetRequiredService(); + + context.PropertyChanged += Context_PropertyChanged; + } + + public Task ExecuteAsync(object? parameter = null) + { + if (context.ShellPage is not { } shellPage || context.SelectedItem?.ItemPath is not { } path || !IsExecutable) + return Task.CompletedTask; + + shellPage.NavigateWithArguments(shellPage.InstanceViewModel.FolderSettings.GetLayoutType(path), new NavigationArguments() + { + NavPathParam = path, + AssociatedTabInstance = shellPage, + }); + + return Task.CompletedTask; + } + + private void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(IContentPageContext.SelectedItems) or nameof(IContentPageContext.PageType) or nameof(IContentPageContext.HasSelection)) + OnPropertyChanged(nameof(IsExecutable)); + } + } +} diff --git a/src/Files.App/Actions/FileSystem/PasteItemAction.cs b/src/Files.App/Actions/FileSystem/PasteItemAction.cs index f7c5079e0713..d6a0e3d2b5c1 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemAction.cs @@ -54,6 +54,7 @@ public bool GetIsExecutable() { return App.AppModel.IsPasteEnabled && + context.PageType != ContentPageTypes.ZipFolder && context.PageType != ContentPageTypes.Home && context.PageType != ContentPageTypes.RecycleBin && context.PageType != ContentPageTypes.SearchResults && diff --git a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs index a0c689340b32..726161c23494 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs @@ -49,6 +49,7 @@ public bool GetIsExecutable() return Ioc.Default.GetRequiredService().SupportsShortcutFiles && App.AppModel.IsPasteEnabled && + context.PageType != ContentPageTypes.ZipFolder && context.PageType != ContentPageTypes.Home && context.PageType != ContentPageTypes.RecycleBin && context.PageType != ContentPageTypes.SearchResults && diff --git a/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs b/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs index 7862e5e88399..e4aa5755374b 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs @@ -56,7 +56,7 @@ public bool GetIsExecutable() if (!App.AppModel.IsPasteEnabled) return false; - if (context.PageType is ContentPageTypes.Home or ContentPageTypes.RecycleBin or ContentPageTypes.SearchResults or ContentPageTypes.ReleaseNotes or ContentPageTypes.Settings) + if (context.PageType is ContentPageTypes.Home or ContentPageTypes.RecycleBin or ContentPageTypes.ZipFolder or ContentPageTypes.SearchResults or ContentPageTypes.ReleaseNotes or ContentPageTypes.Settings) return false; if (!context.HasSelection) diff --git a/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs b/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs index ebd1f4068c87..b8c20938d7d7 100644 --- a/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs +++ b/src/Files.App/Actions/FileSystem/Transfer/BaseTransferItemAction.cs @@ -10,7 +10,7 @@ internal abstract class BaseTransferItemAction : ObservableObject protected readonly IContentPageContext ContentPageContext = Ioc.Default.GetRequiredService(); protected readonly StatusCenterViewModel StatusCenterViewModel = Ioc.Default.GetRequiredService(); - public bool IsExecutable + public virtual bool IsExecutable => ContentPageContext.HasSelection; public BaseTransferItemAction() @@ -25,7 +25,7 @@ public async Task ExecuteTransferAsync(DataPackageOperation type = DataPackageOp private void ContentPageContext_PropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(IContentPageContext.HasSelection)) + if (e.PropertyName is nameof(IContentPageContext.HasSelection) or nameof(IContentPageContext.PageType)) OnPropertyChanged(nameof(IsExecutable)); } } diff --git a/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs b/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs index cf74ace23395..5be7adfd893f 100644 --- a/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs +++ b/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs @@ -29,6 +29,10 @@ public string AccessKey public HotKey HotKey => new(Keys.X, KeyModifiers.Ctrl); + // Archives are read-only, so their items can only be copied out + public override bool IsExecutable + => base.IsExecutable && ContentPageContext.PageType != ContentPageTypes.ZipFolder; + public CutItemAction() : base() { } diff --git a/src/Files.App/App.xaml.cs b/src/Files.App/App.xaml.cs index 4ce50a65c762..905019b1b19e 100644 --- a/src/Files.App/App.xaml.cs +++ b/src/Files.App/App.xaml.cs @@ -80,6 +80,10 @@ public App() }; AppDomain.CurrentDomain.ProcessExit += static (_, _) => SafetyExtensions.IgnoreExceptions(() => Ioc.Default.GetService()?.TryCompleteAndFlush(TimeSpan.FromSeconds(2))); +#if !WINDOWS + AppDomain.CurrentDomain.ProcessExit += static (_, _) => SafetyExtensions.IgnoreExceptions(ArchiveOpenTempStore.CleanupCurrentProcess); + _ = Task.Run(ArchiveOpenTempStore.CleanupStale); +#endif } #if WINDOWS diff --git a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs index 3a43995d9e99..65c87bd92d21 100644 --- a/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs +++ b/src/Files.App/Data/Factories/ContentPageContextFlyoutFactory.cs @@ -389,6 +389,7 @@ public static List GetBaseItemMenuItems( IsVisible = currentInstanceViewModel.IsPageTypeRecycleBin && itemsSelected, }.Build(), new ContextMenuFlyoutItemViewModelBuilder(Commands.OpenItem).Build(), + new ContextMenuFlyoutItemViewModelBuilder(Commands.OpenArchiveAsFolder).Build(), new ContextMenuFlyoutItemViewModelBuilder(Commands.OpenItemWithApplicationPicker) { Tag = "OpenWith", diff --git a/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs new file mode 100644 index 000000000000..0a66d397406c --- /dev/null +++ b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs @@ -0,0 +1,116 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if !WINDOWS +using System.IO; +using System.Text; + +namespace Files.App.Helpers +{ + /// + /// Private (0700) scratch space for archive members that are extracted so they can be opened. + /// Each process uses its own p<pid> directory; leftovers of dead processes are removed at start. + /// + internal static class ArchiveOpenTempStore + { + private static readonly object gate = new(); + private static string? processDirectory; + + private static string Root + { + get + { + var runtime = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + var baseDir = !string.IsNullOrEmpty(runtime) && Path.IsPathRooted(runtime) && Directory.Exists(runtime) + ? Path.Combine(runtime, "linuxfiles") + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "linuxfiles"); + return Path.Combine(baseDir, "archive-open"); + } + } + + /// Creates a fresh private directory and returns the path for a sanitized file name inside it. + public static string CreateFilePath(string entryName) + { + lock (gate) + { + if (processDirectory is null) + { + Directory.CreateDirectory(Root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + processDirectory = Path.Combine(Root, "p" + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + var directory = Path.Combine(processDirectory, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(processDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return Path.Combine(directory, SanitizeName(entryName)); + } + } + + /// Keeps only the last path segment, with control and separator characters replaced. + public static string SanitizeName(string entryPath) + { + var name = entryPath.Replace('\\', '/'); + name = name[(name.LastIndexOf('/') + 1)..]; + var builder = new StringBuilder(name.Length); + foreach (var c in name) + builder.Append(char.IsControl(c) || c == '/' || c == '\0' ? '_' : c); + + var result = builder.ToString().Trim(); + if (result.Length > 120) + result = result[..120]; + return result is "" or "." or ".." ? "file" : result; + } + + /// Removes this process's extracted files. + public static void CleanupCurrentProcess() + { + string? directory; + lock (gate) + directory = processDirectory; + + TryDelete(directory); + } + + /// Removes directories left behind by processes that no longer exist. + public static void CleanupStale() + { + try + { + var root = Root; + if (!Directory.Exists(root)) + return; + + foreach (var directory in Directory.EnumerateDirectories(root, "p*")) + { + var name = Path.GetFileName(directory); + if (int.TryParse(name.AsSpan(1), out var pid) && pid != Environment.ProcessId && Directory.Exists("/proc/" + pid.ToString(System.Globalization.CultureInfo.InvariantCulture))) + continue; + + if (pid == Environment.ProcessId) + continue; + + TryDelete(directory); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + + private static void TryDelete(string? directory) + { + if (directory is null) + return; + + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + } +} +#endif diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs index 79372fb96256..c59a455aa649 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs @@ -45,9 +45,23 @@ private static async Task OpenPathLinuxAsync(string path, IShellPage assoc return true; } - // LINUX-TODO(archives): open archive members by extracting them to a private temporary copy first - await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenFailedTitle.GetLocalizedResource(), Strings.LinuxOpenFailedText.GetLocalizedFormatResource(DisplaySanitizer.Field(path))); - return false; + if (resolved.Item is not OwlCore.Storage.IFile member) + { + await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenFailedTitle.GetLocalizedResource(), Strings.LinuxOpenFailedText.GetLocalizedFormatResource(DisplaySanitizer.Field(path))); + return false; + } + + // Extract only this entry (size and ratio limits apply in the archive service) and open the private copy, + // so the usual confirmation gates see a real file + var temporaryPath = await ExtractArchiveMemberAsync(member); + if (temporaryPath is null) + return false; + + var openedMember = await OpenFileLinuxAsync(temporaryPath, openViaApplicationPicker); + if (!openedMember) + await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenFailedTitle.GetLocalizedResource(), Strings.LinuxOpenFailedText.GetLocalizedFormatResource(DisplaySanitizer.Field(path))); + + return openedMember; } if (!File.Exists(path)) @@ -71,6 +85,31 @@ private static async Task OpenPathLinuxAsync(string path, IShellPage assoc } } + private static async Task ExtractArchiveMemberAsync(OwlCore.Storage.IFile member) + { + var name = member is Files.App.Storage.Archives.ArchiveEntryFile entry ? entry.Entry.Path : member.Name; + string? target = null; + try + { + target = ArchiveOpenTempStore.CreateFilePath(name); + await using var input = await member.OpenReadAsync(); + await using var output = new FileStream(target, new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite, + }); + await input.CopyToAsync(output); + return target; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + App.Logger.LogWarning(ex, "Failed to extract archive member {Name}", DisplaySanitizer.Field(name)); + await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenFailedTitle.GetLocalizedResource(), Strings.LinuxOpenFailedText.GetLocalizedFormatResource(DisplaySanitizer.Field(name))); + return null; + } + } + /// /// Opens several files. Files that need a gate (executables, launchers) are opened one by one through /// ; plain documents are passed to their default applications together. diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs index d5bd2dc370be..334ee4f9d871 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.cs @@ -318,7 +318,7 @@ private static string[] AncestorHints(string? path) } else { - tabLocationHeader = currentPath.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar).Split('\\', StringSplitOptions.RemoveEmptyEntries).Last(); + tabLocationHeader = currentPath.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar).Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries).Last(); var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(currentPath)); if (rootItem) diff --git a/src/Files.App/Platforms/Desktop/Generated/ICommandManager.g.cs b/src/Files.App/Platforms/Desktop/Generated/ICommandManager.g.cs index 3cda0be855af..a8b88efda0fc 100644 --- a/src/Files.App/Platforms/Desktop/Generated/ICommandManager.g.cs +++ b/src/Files.App/Platforms/Desktop/Generated/ICommandManager.g.cs @@ -111,6 +111,7 @@ public interface ICommandManager : IEnumerable IRichCommand NewWindow { get; } IRichCommand NextTab { get; } IRichCommand OpenAllTagged { get; } + IRichCommand OpenArchiveAsFolder { get; } IRichCommand OpenClassicProperties { get; } IRichCommand OpenCommandPalette { get; } IRichCommand OpenCurrentFolderInOtherPane { get; } diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index df00781c8866..74f7fcc91985 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -4971,4 +4971,11 @@ LinuxFiles Display name of the Linux port, shown in the window title and About page on Linux. + + Open as folder + Context menu entry on Linux that browses an archive like a folder. + + + Browse the contents of the selected archive like a folder + diff --git a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs index 349e1deb66a4..10a2570ee6d9 100644 --- a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs +++ b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs @@ -150,6 +150,7 @@ public async Task DragOverAsync(DragEventArgs? e) // As long as one file doesn't already belong to this folder, and this folder isn't one of the dragged items or inside one if (_associatedInstance.InstanceViewModel.IsPageTypeSearchResults || + ZipStorageFolder.IsZipPath(workingDirectoryPath) || draggedItems.Any() && (draggedItems.AreItemsAlreadyInFolder(workingDirectoryPath) || draggedItems.ContainsDestinationOrAncestor(workingDirectoryPath))) { e.AcceptedOperation = DataPackageOperation.None; diff --git a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs index eac3edf97ca3..c2bcc8ae4c31 100644 --- a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs @@ -480,7 +480,9 @@ public async Task PathBoxItem_DragOver(object sender, DragEventArgs e) var storageItems = await FilesystemHelpers.GetDraggedStorageItems(e.DataView); - if (storageItems.ContainsDestinationOrAncestor(pathBoxItem.Path) || + // Archives are read-only: nothing can be dropped into them + if (ZipStorageFolder.IsZipPath(pathBoxItem.Path) || + storageItems.ContainsDestinationOrAncestor(pathBoxItem.Path) || !storageItems.Any(storageItem => !string.IsNullOrEmpty(storageItem?.Path) && storageItem.Path.Replace(pathBoxItem.Path, string.Empty, StringComparison.Ordinal) diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index 3e6be428c906..b9d771769023 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -1304,7 +1304,8 @@ private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, It (hasStorageItems && (storageItems.AreItemsAlreadyInFolder(path!) || storageItems.ContainsDestinationOrAncestor(path))) || path!.StartsWith("Home", StringComparison.OrdinalIgnoreCase) || path.StartsWith("ReleaseNotes", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("Settings", StringComparison.OrdinalIgnoreCase)) + path.StartsWith("Settings", StringComparison.OrdinalIgnoreCase) || + ZipStorageFolder.IsZipPath(path)) { rawEvent.AcceptedOperation = DataPackageOperation.None; } diff --git a/src/Files.App/Views/Layouts/BaseLayoutPage.cs b/src/Files.App/Views/Layouts/BaseLayoutPage.cs index 96f7b4c68ef9..58bc43921b1f 100644 --- a/src/Files.App/Views/Layouts/BaseLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseLayoutPage.cs @@ -1159,7 +1159,11 @@ private async void Item_DragOver(object sender, DragEventArgs e) // Dropping onto an executable or a script opens the dragged items with it, so only the item itself is rejected there var isOpenWithTarget = item.IsExecutable || item.IsScriptFile; - if (isOpenWithTarget + if (ZipStorageFolder.IsZipPath(item.ItemPath)) + { + e.AcceptedOperation = DataPackageOperation.None; + } + else if (isOpenWithTarget ? draggedItems.ContainsDestinationPath(item.ItemPath) : draggedItems.ContainsDestinationOrAncestor(item.ItemPath)) { From 2710ecf8eb920d403e557d8123cf760ba8e0cb6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 01:32:22 +0300 Subject: [PATCH 2/3] Linux: archive open review fixes (password cancel, safe temp store, Windows guards, long names) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Actions/FileSystem/BaseDeleteAction.cs | 2 +- .../Actions/FileSystem/PasteItemAction.cs | 2 +- .../FileSystem/PasteItemAsShortcutAction.cs | 2 +- .../FileSystem/PasteItemToSelectionAction.cs | 5 +- .../FileSystem/Transfer/CutItemAction.cs | 2 +- .../Navigation/ArchiveOpenTempStore.Linux.cs | 75 ++++++++++++++++--- .../Navigation/NavigationHelpers.Linux.cs | 15 +++- .../ViewModels/Layouts/BaseLayoutViewModel.cs | 2 +- .../NavigationToolbarViewModel.cs | 2 +- .../UserControls/SidebarViewModel.cs | 2 +- src/Files.App/Views/Layouts/BaseLayoutPage.cs | 2 +- 11 files changed, 90 insertions(+), 21 deletions(-) diff --git a/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs b/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs index 74364188c5a5..f36d0dab9565 100644 --- a/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs +++ b/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs @@ -16,7 +16,7 @@ public virtual ActionCategory Category public override bool IsExecutable => context.HasSelection && - context.PageType != ContentPageTypes.ZipFolder && + (OperatingSystem.IsWindows() || context.PageType != ContentPageTypes.ZipFolder) && (!context.ShellPage?.SlimContentPage?.IsRenamingItem ?? false) && UIHelpers.CanShowDialog; diff --git a/src/Files.App/Actions/FileSystem/PasteItemAction.cs b/src/Files.App/Actions/FileSystem/PasteItemAction.cs index d6a0e3d2b5c1..3b5c0f275f63 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemAction.cs @@ -54,7 +54,7 @@ public bool GetIsExecutable() { return App.AppModel.IsPasteEnabled && - context.PageType != ContentPageTypes.ZipFolder && + (OperatingSystem.IsWindows() || context.PageType != ContentPageTypes.ZipFolder) && context.PageType != ContentPageTypes.Home && context.PageType != ContentPageTypes.RecycleBin && context.PageType != ContentPageTypes.SearchResults && diff --git a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs index 726161c23494..df31dec919a8 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemAsShortcutAction.cs @@ -49,7 +49,7 @@ public bool GetIsExecutable() return Ioc.Default.GetRequiredService().SupportsShortcutFiles && App.AppModel.IsPasteEnabled && - context.PageType != ContentPageTypes.ZipFolder && + (OperatingSystem.IsWindows() || context.PageType != ContentPageTypes.ZipFolder) && context.PageType != ContentPageTypes.Home && context.PageType != ContentPageTypes.RecycleBin && context.PageType != ContentPageTypes.SearchResults && diff --git a/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs b/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs index e4aa5755374b..5f4affcd474e 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs @@ -56,7 +56,10 @@ public bool GetIsExecutable() if (!App.AppModel.IsPasteEnabled) return false; - if (context.PageType is ContentPageTypes.Home or ContentPageTypes.RecycleBin or ContentPageTypes.ZipFolder or ContentPageTypes.SearchResults or ContentPageTypes.ReleaseNotes or ContentPageTypes.Settings) + if (!OperatingSystem.IsWindows() && context.PageType is ContentPageTypes.ZipFolder) + return false; + + if (context.PageType is ContentPageTypes.Home or ContentPageTypes.RecycleBin or ContentPageTypes.SearchResults or ContentPageTypes.ReleaseNotes or ContentPageTypes.Settings) return false; if (!context.HasSelection) diff --git a/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs b/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs index 5be7adfd893f..8b4807900c54 100644 --- a/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs +++ b/src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs @@ -31,7 +31,7 @@ public HotKey HotKey // Archives are read-only, so their items can only be copied out public override bool IsExecutable - => base.IsExecutable && ContentPageContext.PageType != ContentPageTypes.ZipFolder; + => base.IsExecutable && (OperatingSystem.IsWindows() || ContentPageContext.PageType != ContentPageTypes.ZipFolder); public CutItemAction() : base() { diff --git a/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs index 0a66d397406c..7ccf4a1a34b6 100644 --- a/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs +++ b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs @@ -28,24 +28,59 @@ private static string Root } } + private const UnixFileMode PrivateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// + /// Creates (or validates) a directory component: it must be a real directory, not a symlink, and (when required) closed to group and others. + /// A directory owned by someone else that passes this is still unusable, since it cannot be written to. + /// + private static bool EnsurePrivateDirectory(string path, bool create, bool requirePrivateMode = true) + { + var info = new DirectoryInfo(path); + if (!info.Exists && !File.Exists(path) && info.LinkTarget is null) + { + if (!create) + return false; + Directory.CreateDirectory(path, PrivateMode); + info.Refresh(); + } + + return info.LinkTarget is null + && info.Exists + && (!requirePrivateMode || (File.GetUnixFileMode(path) & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute)) == 0); + } + + private static bool EnsureRoot(bool create) + { + var root = Root; + var parent = Path.GetDirectoryName(root)!; + if (create) + Directory.CreateDirectory(Path.GetDirectoryName(parent)!); + return EnsurePrivateDirectory(parent, create, requirePrivateMode: false) && EnsurePrivateDirectory(root, create); + } + /// Creates a fresh private directory and returns the path for a sanitized file name inside it. public static string CreateFilePath(string entryName) { lock (gate) { - if (processDirectory is null) - { - Directory.CreateDirectory(Root, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - processDirectory = Path.Combine(Root, "p" + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); - } + if (!EnsureRoot(create: true)) + throw new IOException("The archive scratch directory is not private."); + + processDirectory ??= Path.Combine(Root, "p" + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (!EnsurePrivateDirectory(processDirectory, create: true)) + throw new IOException("The archive scratch directory is not private."); var directory = Path.Combine(processDirectory, Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(processDirectory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - Directory.CreateDirectory(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + Directory.CreateDirectory(directory, PrivateMode); return Path.Combine(directory, SanitizeName(entryName)); } } + /// Removes the directory holding a file created by . + public static void Discard(string filePath) + => TryDelete(Path.GetDirectoryName(filePath)); + /// Keeps only the last path segment, with control and separator characters replaced. public static string SanitizeName(string entryPath) { @@ -57,7 +92,14 @@ public static string SanitizeName(string entryPath) var result = builder.ToString().Trim(); if (result.Length > 120) - result = result[..120]; + { + // Keep the extension: it decides which application opens the file + var extension = Path.GetExtension(result); + if (extension.Length is 0 or > 40) + result = result[..120]; + else + result = result[..(120 - extension.Length)] + extension; + } return result is "" or "." or ".." ? "file" : result; } @@ -68,7 +110,8 @@ public static void CleanupCurrentProcess() lock (gate) directory = processDirectory; - TryDelete(directory); + if (directory is not null && EnsureRootSafe()) + TryDelete(directory); } /// Removes directories left behind by processes that no longer exist. @@ -77,7 +120,7 @@ public static void CleanupStale() try { var root = Root; - if (!Directory.Exists(root)) + if (!EnsureRootSafe()) return; foreach (var directory in Directory.EnumerateDirectories(root, "p*")) @@ -97,6 +140,18 @@ public static void CleanupStale() } } + private static bool EnsureRootSafe() + { + try + { + return EnsureRoot(create: false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + private static void TryDelete(string? directory) { if (directory is null) diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs index c59a455aa649..3b121687f805 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs @@ -78,7 +78,11 @@ private static async Task OpenPathLinuxAsync(string path, IShellPage assoc return opened; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + return true; + } + catch (Exception ex) { App.Logger.LogWarning(ex, "Failed to open {Path}", path); return false; @@ -102,7 +106,14 @@ private static async Task OpenPathLinuxAsync(string path, IShellPage assoc await input.CopyToAsync(output); return target; } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + // Password prompt cancelled by the user + if (target is not null) + ArchiveOpenTempStore.Discard(target); + return null; + } + catch (Exception ex) { App.Logger.LogWarning(ex, "Failed to extract archive member {Name}", DisplaySanitizer.Field(name)); await DialogDisplayHelper.ShowDialogAsync(Strings.LinuxOpenFailedTitle.GetLocalizedResource(), Strings.LinuxOpenFailedText.GetLocalizedFormatResource(DisplaySanitizer.Field(name))); diff --git a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs index 10a2570ee6d9..9dbee7e42b18 100644 --- a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs +++ b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs @@ -150,7 +150,7 @@ public async Task DragOverAsync(DragEventArgs? e) // As long as one file doesn't already belong to this folder, and this folder isn't one of the dragged items or inside one if (_associatedInstance.InstanceViewModel.IsPageTypeSearchResults || - ZipStorageFolder.IsZipPath(workingDirectoryPath) || + (!OperatingSystem.IsWindows() && ZipStorageFolder.IsZipPath(workingDirectoryPath)) || draggedItems.Any() && (draggedItems.AreItemsAlreadyInFolder(workingDirectoryPath) || draggedItems.ContainsDestinationOrAncestor(workingDirectoryPath))) { e.AcceptedOperation = DataPackageOperation.None; diff --git a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs index c2bcc8ae4c31..59b028c4e503 100644 --- a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs @@ -481,7 +481,7 @@ public async Task PathBoxItem_DragOver(object sender, DragEventArgs e) var storageItems = await FilesystemHelpers.GetDraggedStorageItems(e.DataView); // Archives are read-only: nothing can be dropped into them - if (ZipStorageFolder.IsZipPath(pathBoxItem.Path) || + if ((!OperatingSystem.IsWindows() && ZipStorageFolder.IsZipPath(pathBoxItem.Path)) || storageItems.ContainsDestinationOrAncestor(pathBoxItem.Path) || !storageItems.Any(storageItem => !string.IsNullOrEmpty(storageItem?.Path) && diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index b9d771769023..c91f623ae7a9 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -1305,7 +1305,7 @@ private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, It path!.StartsWith("Home", StringComparison.OrdinalIgnoreCase) || path.StartsWith("ReleaseNotes", StringComparison.OrdinalIgnoreCase) || path.StartsWith("Settings", StringComparison.OrdinalIgnoreCase) || - ZipStorageFolder.IsZipPath(path)) + (!OperatingSystem.IsWindows() && ZipStorageFolder.IsZipPath(path))) { rawEvent.AcceptedOperation = DataPackageOperation.None; } diff --git a/src/Files.App/Views/Layouts/BaseLayoutPage.cs b/src/Files.App/Views/Layouts/BaseLayoutPage.cs index 9793bdc8f924..7eb5bf423cd7 100644 --- a/src/Files.App/Views/Layouts/BaseLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseLayoutPage.cs @@ -1159,7 +1159,7 @@ private async void Item_DragOver(object sender, DragEventArgs e) // Dropping onto an executable or a script opens the dragged items with it, so only the item itself is rejected there var isOpenWithTarget = item.IsExecutable || item.IsScriptFile; - if (ZipStorageFolder.IsZipPath(item.ItemPath)) + if (!OperatingSystem.IsWindows() && ZipStorageFolder.IsZipPath(item.ItemPath)) { e.AcceptedOperation = DataPackageOperation.None; } From afb2b7aa9cc1e1305b7728c843e7e32287fd4414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kov=C3=A1cs=20B=C3=A1lint=20Hunor?= Date: Tue, 6 Oct 2026 01:38:38 +0300 Subject: [PATCH 3/3] Linux: require the archive scratch parent directory to be private An entry in a directory others can write to could be swapped for a symlink after it is checked, so the parent of archive-open now gets the same 0700/no-symlink check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeKXJc3DnK3PzAqwgA72NY --- .../Helpers/Navigation/ArchiveOpenTempStore.Linux.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs index 7ccf4a1a34b6..6377257d2db4 100644 --- a/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs +++ b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs @@ -31,10 +31,10 @@ private static string Root private const UnixFileMode PrivateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; /// - /// Creates (or validates) a directory component: it must be a real directory, not a symlink, and (when required) closed to group and others. + /// Creates (or validates) a directory component: it must be a real directory, not a symlink, and closed to group and others. /// A directory owned by someone else that passes this is still unusable, since it cannot be written to. /// - private static bool EnsurePrivateDirectory(string path, bool create, bool requirePrivateMode = true) + private static bool EnsurePrivateDirectory(string path, bool create) { var info = new DirectoryInfo(path); if (!info.Exists && !File.Exists(path) && info.LinkTarget is null) @@ -47,7 +47,7 @@ private static bool EnsurePrivateDirectory(string path, bool create, bool requir return info.LinkTarget is null && info.Exists - && (!requirePrivateMode || (File.GetUnixFileMode(path) & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute)) == 0); + && (File.GetUnixFileMode(path) & (UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute)) == 0; } private static bool EnsureRoot(bool create) @@ -56,7 +56,8 @@ private static bool EnsureRoot(bool create) var parent = Path.GetDirectoryName(root)!; if (create) Directory.CreateDirectory(Path.GetDirectoryName(parent)!); - return EnsurePrivateDirectory(parent, create, requirePrivateMode: false) && EnsurePrivateDirectory(root, create); + // The parent must be private too: an entry in a directory someone else can write to could be swapped for a symlink after it is checked + return EnsurePrivateDirectory(parent, create) && EnsurePrivateDirectory(root, create); } /// Creates a fresh private directory and returns the path for a sanitized file name inside it.