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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Files.App/Actions/FileSystem/BaseDeleteAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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));
}
}
Expand Down
64 changes: 64 additions & 0 deletions src/Files.App/Actions/FileSystem/OpenArchiveAsFolderAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) Files Community
// Licensed under the MIT License.

using Files.Shared.Helpers;
using Windows.Storage;

namespace Files.App.Actions
{
/// <summary>
/// Browses an archive like a folder (Linux), while double-click keeps opening it in the default application.
/// </summary>
[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<IContentPageContext>();

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));
}
}
}
1 change: 1 addition & 0 deletions src/Files.App/Actions/FileSystem/PasteItemAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public bool GetIsExecutable()
return
Ioc.Default.GetRequiredService<Files.Platform.Abstractions.IPlatformCapabilities>().SupportsShortcutFiles &&
App.AppModel.IsPasteEnabled &&
(OperatingSystem.IsWindows() || context.PageType != ContentPageTypes.ZipFolder) &&
context.PageType != ContentPageTypes.Home &&
context.PageType != ContentPageTypes.RecycleBin &&
context.PageType != ContentPageTypes.SearchResults &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ internal abstract class BaseTransferItemAction : ObservableObject
protected readonly IContentPageContext ContentPageContext = Ioc.Default.GetRequiredService<IContentPageContext>();
protected readonly StatusCenterViewModel StatusCenterViewModel = Ioc.Default.GetRequiredService<StatusCenterViewModel>();

public bool IsExecutable
public virtual bool IsExecutable
=> ContentPageContext.HasSelection;

public BaseTransferItemAction()
Expand All @@ -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));
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/Files.App/Actions/FileSystem/Transfer/CutItemAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
}
Expand Down
4 changes: 4 additions & 0 deletions src/Files.App/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ public App()
};
AppDomain.CurrentDomain.ProcessExit += static (_, _) =>
SafetyExtensions.IgnoreExceptions(() => Ioc.Default.GetService<FileLoggerProvider>()?.TryCompleteAndFlush(TimeSpan.FromSeconds(2)));
#if !WINDOWS
AppDomain.CurrentDomain.ProcessExit += static (_, _) => SafetyExtensions.IgnoreExceptions(ArchiveOpenTempStore.CleanupCurrentProcess);
_ = Task.Run(ArchiveOpenTempStore.CleanupStale);
#endif
}

#if WINDOWS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ public static List<ContextMenuFlyoutItemViewModel> GetBaseItemMenuItems(
IsVisible = currentInstanceViewModel.IsPageTypeRecycleBin && itemsSelected,
}.Build(),
new ContextMenuFlyoutItemViewModelBuilder(Commands.OpenItem).Build(),
new ContextMenuFlyoutItemViewModelBuilder(Commands.OpenArchiveAsFolder).Build(),
new ContextMenuFlyoutItemViewModelBuilder(Commands.OpenItemWithApplicationPicker)
{
Tag = "OpenWith",
Expand Down
172 changes: 172 additions & 0 deletions src/Files.App/Helpers/Navigation/ArchiveOpenTempStore.Linux.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Private (0700) scratch space for archive members that are extracted so they can be opened.
/// Each process uses its own <c>p&lt;pid&gt;</c> directory; leftovers of dead processes are removed at start.
/// </summary>
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;

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

/// <summary>Creates a fresh private directory and returns the path for a sanitized file name inside it.</summary>
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));
}
}

/// <summary>Removes the directory holding a file created by <see cref="CreateFilePath"/>.</summary>
public static void Discard(string filePath)
=> TryDelete(Path.GetDirectoryName(filePath));

/// <summary>Keeps only the last path segment, with control and separator characters replaced.</summary>
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;
}

/// <summary>Removes this process's extracted files.</summary>
public static void CleanupCurrentProcess()
{
string? directory;
lock (gate)
directory = processDirectory;

if (directory is not null && EnsureRootSafe())
TryDelete(directory);
}

/// <summary>Removes directories left behind by processes that no longer exist.</summary>
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
Loading
Loading