diff --git a/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs b/src/Files.App/Actions/FileSystem/BaseDeleteAction.cs index f7664d02af5b..f36d0dab9565 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 && + (OperatingSystem.IsWindows() || 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..3b5c0f275f63 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 && + (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 a0c689340b32..df31dec919a8 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 && + (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 7862e5e88399..5f4affcd474e 100644 --- a/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs +++ b/src/Files.App/Actions/FileSystem/PasteItemToSelectionAction.cs @@ -56,6 +56,9 @@ public bool GetIsExecutable() if (!App.AppModel.IsPasteEnabled) return false; + 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; 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..8b4807900c54 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 && (OperatingSystem.IsWindows() || 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..6377257d2db4 --- /dev/null +++ b/src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs @@ -0,0 +1,172 @@ +// 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"); + } + } + + 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 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) + { + 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 + && (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)!); + // 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. + public static string CreateFilePath(string entryName) + { + lock (gate) + { + 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(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) + { + 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) + { + // 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; + } + + /// Removes this process's extracted files. + public static void CleanupCurrentProcess() + { + string? directory; + lock (gate) + directory = processDirectory; + + if (directory is not null && EnsureRootSafe()) + TryDelete(directory); + } + + /// Removes directories left behind by processes that no longer exist. + public static void CleanupStale() + { + try + { + var root = Root; + if (!EnsureRootSafe()) + 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 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) + 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..3b121687f805 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)) @@ -64,13 +78,49 @@ 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; } } + 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 (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))); + 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..9dbee7e42b18 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 || + (!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 eac3edf97ca3..59b028c4e503 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 ((!OperatingSystem.IsWindows() && 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..c91f623ae7a9 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) || + (!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 075db67fad24..7eb5bf423cd7 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 (!OperatingSystem.IsWindows() && ZipStorageFolder.IsZipPath(item.ItemPath)) + { + e.AcceptedOperation = DataPackageOperation.None; + } + else if (isOpenWithTarget ? draggedItems.ContainsDestinationPath(item.ItemPath) : draggedItems.ContainsDestinationOrAncestor(item.ItemPath)) {