diff --git a/.github/README.md b/.github/README.md index a63e931ced55..50abdbf455fb 100644 --- a/.github/README.md +++ b/.github/README.md @@ -32,6 +32,14 @@ cd LinuxFiles scripts/linux/install-local.sh ``` +### Nightly builds + +Every merge to `main` publishes a tarball and AppImage to the rolling [nightly pre-release](https://github.com/MemerGamer/LinuxFiles/releases/tag/nightly). Run it with: + +```sh +curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nightly/LinuxFiles-nightly-x86_64.AppImage && chmod +x LinuxFiles-nightly-x86_64.AppImage && ./LinuxFiles-nightly-x86_64.AppImage +``` + See [docs/linux-port/packaging.md](../docs/linux-port/packaging.md) for packaging details and [docs/linux-port/PLAN.md](../docs/linux-port/PLAN.md) for the roadmap. ## Reporting bugs diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 8ba1364847f0..e727e09bca9e 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -LinuxFiles is an unofficial, community-maintained Linux port of [Files](https://github.com/files-community/Files). It is in alpha, and only the latest release and the `linux/main` branch receive fixes. +LinuxFiles is an unofficial, community-maintained Linux port of [Files](https://github.com/files-community/Files). It is in alpha, and only the latest release and the `main` branch receive fixes. ## Reporting a vulnerability in LinuxFiles diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 6bfdfa6438c8..2f1a798bc881 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -71,9 +71,17 @@ jobs: - name: Build Files.App.Storage (Uno desktop) run: dotnet build src/Files.App.Storage/Files.App.Storage.csproj -c Release -f net10.0-desktop - # Informational until the Phase 2 error inventory (docs/linux-port/phase2-errors.md) reaches zero. - - name: Build Files.App (Uno desktop) - run: dotnet build src/Files.App/Files.App.csproj -c Release -f net10.0-desktop + # Blocking: Win32 compat mode is off (the default since P4-Z), so no Win32/Shell code and no CsWin32 assembly may be needed. + - name: Build Files.App (Uno desktop, Win32 compat off) + run: dotnet build src/Files.App/Files.App.csproj -c Release -f net10.0-desktop -p:FilesWin32Compat=false + + - name: Verify the desktop build did not build CsWin32 + shell: bash + run: | + if [ -d src/Files.App.CsWin32/bin ] || [ -d src/Files.App.CsWin32/obj ]; then + echo "::error::Files.App.CsWin32 was built for the desktop target; something still depends on Win32 compat mode." + exit 1 + fi - name: Build and test platform test projects (if any) shell: bash diff --git a/.github/workflows/nightly-linux.yml b/.github/workflows/nightly-linux.yml new file mode 100644 index 000000000000..9a18795488ee --- /dev/null +++ b/.github/workflows/nightly-linux.yml @@ -0,0 +1,119 @@ +# Copyright (c) Files Community +# Licensed under the MIT License. + +# Abstract: +# Builds the self-contained linux-x64 tarball and AppImage on every push to main and publishes them to +# one rolling "nightly" GitHub pre-release with stable asset names. Pull requests targeting main get the +# same build as workflow artifacts only (read-only token). The Flatpak is left to package-linux.yml. + +name: Nightly Linux + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || 'nightly' }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + + build-linux-x64: + + runs-on: ubuntu-latest + + steps: + + - name: Checkout the repository + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Publish self-contained linux-x64 + run: scripts/linux/publish.sh --rid linux-x64 --tarball + + - name: Build AppImage + run: packaging/linux/appimage/build-appimage.sh + + - name: Stage assets with stable names + run: | + mkdir -p dist + cp artifacts/files-linux-x64.tar.gz dist/LinuxFiles-nightly-linux-x64.tar.gz + cp artifacts/Files-x86_64.AppImage dist/LinuxFiles-nightly-x86_64.AppImage + + - name: Upload packages + uses: actions/upload-artifact@v4 + with: + name: linuxfiles-nightly-linux-x64 + path: dist/* + if-no-files-found: error + + publish-nightly: + + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: build-linux-x64 + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + + - name: Checkout the repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Download packages + uses: actions/download-artifact@v4 + with: + name: linuxfiles-nightly-linux-x64 + path: dist + + - name: Replace the nightly release + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + short="${SHA:0:7}" + + # Commit list since the previous nightly (falls back to the last 20 commits). + prev="$(gh api "repos/$REPO/git/ref/tags/nightly" --jq '.object.sha' 2>/dev/null || true)" + if [ -n "$prev" ] && git cat-file -e "$prev^{commit}" 2>/dev/null; then + range="$prev..$SHA" + else + range="-20 $SHA" + fi + { + echo "Rolling build of \`main\` at $SHA. Replaced on every push; not for production use." + echo + echo "Run the AppImage: \`chmod +x LinuxFiles-nightly-x86_64.AppImage && ./LinuxFiles-nightly-x86_64.AppImage\`" + echo + echo "### Changes since the previous nightly" + # shellcheck disable=SC2086 + git log --no-merges --format='- %h %s' $range + } > notes.md + + gh release delete nightly -R "$REPO" --cleanup-tag --yes 2>/dev/null || true + gh release create nightly -R "$REPO" --target "$SHA" --prerelease \ + --title "Nightly (main @ $short)" --notes-file notes.md \ + dist/LinuxFiles-nightly-linux-x64.tar.gz dist/LinuxFiles-nightly-x86_64.AppImage diff --git a/Directory.Build.props b/Directory.Build.props index 3db5d9e4871f..1c84d532f745 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,8 +9,8 @@ 10.0.26100.67-preview $(BaseTargetFramework)-windows$(TargetWindowsVersion) $(BaseTargetFramework)-desktop - - true + + false preview nullable diff --git a/Directory.Packages.props b/Directory.Packages.props index 491811286e8a..5dc4381756c8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,6 +45,7 @@ + diff --git a/docs/linux-port/AGENT-RULES.md b/docs/linux-port/AGENT-RULES.md index 690f7f22a517..803fa7c4bc58 100644 --- a/docs/linux-port/AGENT-RULES.md +++ b/docs/linux-port/AGENT-RULES.md @@ -16,10 +16,10 @@ Read this first, then `PLAN.md` Β§0, then `CLAUDE.md`. - Never commit anything from the real home directory. ## Git / GitHub -- Branch from `origin/linux/main`: `git fetch origin && git switch -c linux/ origin/linux/main`. +- Branch from `origin/main` (the single integration branch; releases are tags: `nightly` rolling, `linux-v*` stable): `git fetch origin && git switch -c linux/ origin/main`. - Commit trailer: `Co-Authored-By: Claude Sonnet 5.5 `. - Push: `git -c credential.helper='!gh auth git-credential' push -u origin linux/`. -- PR: `gh pr create -R MemerGamer/LinuxFiles --base linux/main --head linux/ ...`. The body ends with `πŸ€– Generated with [Claude Code](https://claude.com/claude-code)`. +- PR: `gh pr create -R MemerGamer/LinuxFiles --base main --head linux/ ...`. The body ends with `πŸ€– Generated with [Claude Code](https://claude.com/claude-code)`. - Never target, push to, or comment on upstream `files-community/Files`. ## Code diff --git a/docs/linux-port/PLAN.md b/docs/linux-port/PLAN.md index 89d443d8d8bc..f58ae059a205 100644 --- a/docs/linux-port/PLAN.md +++ b/docs/linux-port/PLAN.md @@ -1,5 +1,7 @@ # Files on Linux β€” Porting Plan +> **Branching (2026-10-05):** `main` is the single integration branch. `linux/main` is retired (frozen at the last merge). Releases are tags: the rolling `nightly` pre-release (every push to `main`) and stable `linux-v*` tags. + Status: draft v1 Β· 2026-10-04 Β· fork base `0e3c17ca4` (in sync with `files-community/Files` main) ## 0. Revision 2 (2026-10-04): Linux-first @@ -43,7 +45,7 @@ The owner decided to focus on Linux first. Windows support is revisited only aft | 1 | Abstraction skeleton (`Files.Platform.*`, tests, DI) | serial, small | | 2 | **Linux bring-up: make `Files.App` compile and launch on Uno `net10.0-desktop`.** Retarget Controls + App to Uno. Exclude Windows-only folders (`Compile Remove`). Replace Win32/WinRT calls with interface calls, backed by stub or simple Linux implementations. Fix compile errors folder by folder. **βœ… Done (2026-10-05):** the app compiles, launches and runs on Linux; the compat-off criterion moved to Phase 4. | parallel by folder ownership | | 3 | Real Linux backends per interface (enumeration, watcher, file ops, trash, launcher, thumbnails, drives, clipboard…), with unit tests | parallel, one agent per interface | -| 4 | Replace the legacy WinRT storage layer (`BaseStorageFile`/`Folder`) with OwlCore storables, then switch Win32 compat mode off (moved from Phase 2) | parallel work packages (see Phase 4 plan, Revision 3) | +| 4 | Replace the legacy WinRT storage layer (`BaseStorageFile`/`Folder`) with OwlCore storables, then switch Win32 compat mode off (moved from Phase 2). **βœ… Done (2026-10-05, P4-Z):** compat mode is off by default and the desktop build has 0 errors, which also meets Phase 2's criterion | parallel work packages (see Phase 4 plan, Revision 3) | | 5 | UI blockers and UX parity: L-DND, SpeedGraph, StickyHeader, title bar, previews, single instance | parallel | | 6 | Desktop integration and packaging (FileManager1 D-Bus, `.desktop`, Flatpak/AppImage/AUR) | parallel | | 7 | Hardening (performance, HiDPI, themes, localisation) | parallel | @@ -53,7 +55,7 @@ The Phase 2 workstreams in Β§7.3 still define folder ownership. Their goal is no not "Windows code wrapped without behaviour change". ### Phase 2 build decisions (2026-10-04) -- **Win32 compat mode is the default** (`FilesWin32Compat=true` in `Directory.Build.props`). Existing Win32/Shell code compiles on Linux against the CsWin32 assembly, which is built automatically for Platform=x64. It cannot run on Linux, so any call that is reached at runtime must be replaced by a `Files.Platform` service. Switching compat mode off was Phase 2's exit criterion; since 2026-10-05 it is Phase 4's (P4-Z), because it depends on removing the legacy storage layer. +- **Win32 compat mode is off by default since P4-Z (2026-10-05)** (`FilesWin32Compat=false` in `Directory.Build.props`). The desktop build no longer compiles Win32/Shell code or builds the CsWin32 assembly. `-p:FilesWin32Compat=true` still works as an opt-in inventory lens: it compiles the Windows-only folders against CsWin32 and defines `FILES_WIN32_COMPAT`. Nothing compiled that way runs on Linux. Linux CI builds with compat off and fails on any error. (Until P4-Z compat mode was the default. Switching it off was Phase 2's exit criterion and became Phase 4's on 2026-10-05.) - **Nullable diagnostics are warnings on `-desktop`**, because Uno's annotations differ from WinUI's. Re-enable once the build is clean. - Stubbed behaviour is tagged `// LINUX-TODO()`. Use `rg -n LINUX-TODO src` to find the backlog. @@ -247,7 +249,8 @@ Each workstream: define its interface methods from real call sites, implement th (`Files.Core.Storage`) + the Phase 2 interfaces. `ShellViewModel` first, then properties, search, archives (`Zip*`), FTP, virtual folders. - Kept on a single owner (or a strictly serialized queue), because almost everything touches `ShellViewModel`. -- **Exit:** `Utils/Storage/StorageItems/*` is no longer referenced by the Linux build. +- **Exit:** `Utils/Storage/StorageItems/*` is no longer referenced by the Linux build. *Superseded by the Revision 3 exit below, which is met. The legacy items + (`FtpStorage*`, `SystemStorage*`, `ZipStorageFolder.IsZipPath`, `BaseStorage*`) still compile on Linux behind `LINUX-TODO(storage)` and remain follow-up work.* #### Phase 4 plan, Revision 3 (2026-10-05): drop the legacy storage layer, then switch Win32 compat off The two are one project: the legacy items (`Zip*`, `NativeStorageFile`, `VirtualStorageItem`, `Shell*`, `SystemStorageFolder`) call @@ -287,6 +290,8 @@ Add first, delete last; default build stays green. Contracts (P4-0, then frozen) | P4-Z | 1–2 | Flip `FilesWin32Compat=false`, fix leftovers, delete `#if WINDOWS` IStorageItem members, CI job blocking | all | strong | Order: P4-0 β†’ wave 1a (K, A, B, C, H, I) β†’ wave 1b (F, D, G, J) β†’ E β†’ Z. Critical path β‰ˆ 10 working days. +**Status (2026-10-05):** all 14 packages are merged or in review; P4-E (#73) and P4-Z close the phase. Compat off by default, 0 errors, headless smoke +test passed (browse, copy/move, trash/restore, browse into a zip, Properties, search, live updates), and the Linux CI compat-off build is blocking. Gate after every merge: normal desktop build passes; `-p:FilesWin32Compat=false` error count ratchets down vs `docs/linux-port/compat-off-baseline.txt`; `rg --no-ignore 'BaseStorage|StorageItems\.|Windows\.Win32|PInvoke\.|Win32Helper\.'` on the WP's files (outside `*.Windows.cs`) β†’ 0. Exit: compat off by default, 0 errors, headless smoke run passes, CI compat-off job blocking. @@ -356,8 +361,8 @@ files (outside `*.Windows.cs`) β†’ 0. Exit: compat off by default, 0 errors, hea unit tests, and an `rg --no-ignore` coupling check against the WP's owned files. It reports pass/fail plus a capped log. 4. **Opus** reviews the diff (correctness, CRLF, `.editorconfig`, AOT rules, no scope creep), then merges into - `linux/main` in dependency order. -5. **Haiku** merges `upstream/main` into `linux/main` weekly and reports conflicts. **Opus** resolves non-trivial ones. + `main` in dependency order. +5. **Haiku** merges `upstream/main` into `main` (via a sync PR) weekly and reports conflicts. **Opus** resolves non-trivial ones. Windows builds can't run locally on Linux. Validate them through GitHub Actions on `windows-2025-vs2026`, with a Haiku agent polling `gh run` results. diff --git a/docs/linux-port/compat-off-baseline.txt b/docs/linux-port/compat-off-baseline.txt index d584a54da734..ec88cc1608cf 100644 --- a/docs/linux-port/compat-off-baseline.txt +++ b/docs/linux-port/compat-off-baseline.txt @@ -1,40 +1,2 @@ # Compiler error keys of: dotnet build src/Files.App -f net10.0-desktop -p:FilesWin32Compat=false # One "file: error CODE: message" per line. Regenerate with scripts/linux/compat-off-check.sh --update. -src/Files.App/Data/Factories/ShellContextFlyoutHelper.cs: error CS0246: The type or namespace name 'ContextMenu' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Items/ContextMenu.cs: error CS0246: The type or namespace name 'MENU_ITEM_TYPE' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Items/ListedItem.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Items/ShellLinkItem.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Items/WidgetFolderCardItem.cs: error CS0246: The type or namespace name 'IWindowsStorable' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Items/WidgetRecentItem.cs: error CS0246: The type or namespace name 'IShellItem' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Data/Models/RemovableDevice.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/Data/Models/RemovableDevice.cs: error CS0234: The type or namespace name 'Win32PInvoke' does not exist in the namespace 'Files.App.Helpers' (are you missing an assembly reference?) -src/Files.App/Helpers/UI/UIFilesystemHelpers.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/UserControls/Toolbar.xaml.cs: error CS0246: The type or namespace name 'OpenWithMenu' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs: error CS0246: The type or namespace name 'Win32PInvoke' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Helpers/FileTypesHelper.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs: error CS0122: 'BOOL' is inaccessible due to its protection level -src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0103: The name 'SHOW_WINDOW_CMD' does not exist in the current context -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HRESULT' is inaccessible due to its protection level -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HWND' is inaccessible due to its protection level -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'FILEOPERATION_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'PROPERTYKEY' does not exist in the namespace 'Windows.Win32.Foundation' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'SLR_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'Disposable' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellFileOperations2' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellItem' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/Utils/Storage/Search/FolderSearch.cs: error CS0246: The type or namespace name 'WIN32_FIND_DATAW' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) -src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs: error CS0122: 'IStream' is inaccessible due to its protection level -src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs: error CS0122: 'STATSTG' is inaccessible due to its protection level -src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs: error CS0122: 'STGMEDIUM' is inaccessible due to its protection level -src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/ViewModels/ShellViewModel.cs: error CS0234: The type or namespace name 'Win32PInvoke' does not exist in the namespace 'Files.App.Helpers' (are you missing an assembly reference?) -src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) -src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs: error CS0234: The type or namespace name 'WinRT' does not exist in the namespace 'Windows.Win32.System' (are you missing an assembly reference?) diff --git a/docs/linux-port/packaging.md b/docs/linux-port/packaging.md index 2e487df4a2dc..91ead674b1ff 100644 --- a/docs/linux-port/packaging.md +++ b/docs/linux-port/packaging.md @@ -115,6 +115,14 @@ files, which is how the package was build-tested with `makepkg` here (resulting installed on the dev box, so the package is not linted. `aur/linuxfiles/PKGBUILD` (from source) is unchanged and unbuilt. +## Nightly builds + +`.github/workflows/nightly-linux.yml` builds the tarball and AppImage on every push to `main` and replaces the +single rolling pre-release at https://github.com/MemerGamer/LinuxFiles/releases/tag/nightly (stable asset names +`LinuxFiles-nightly-linux-x64.tar.gz` and `LinuxFiles-nightly-x86_64.AppImage`; the Flatpak is only built for +`linux-v*` tags). Pull requests targeting `main` get the same files as workflow artifacts with a read-only token. +Try it: `curl -LO https://github.com/MemerGamer/LinuxFiles/releases/download/nightly/LinuxFiles-nightly-x86_64.AppImage && chmod +x LinuxFiles-nightly-x86_64.AppImage && ./LinuxFiles-nightly-x86_64.AppImage` + ## Becoming the default file manager (Phase 6, not done yet) 1. `inode/directory`: already in the desktop entry's `MimeType=`. Users (or the installer's post-install hook) diff --git a/docs/linux-port/phase2-errors.md b/docs/linux-port/phase2-errors.md index af63bbe759cd..2ae4e5c76a07 100644 --- a/docs/linux-port/phase2-errors.md +++ b/docs/linux-port/phase2-errors.md @@ -1,5 +1,10 @@ # Phase 2: Files.App on Uno (net10.0-desktop) build inventory +> **Status (2026-10-05, P4-Z): resolved.** Win32 compat mode is off by default, and `dotnet build src/Files.App -f net10.0-desktop` reports +> 0 errors without building CsWin32. The last iterations went 2 β†’ 53 β†’ 2 β†’ 0 errors (declaration errors hid the body errors), plus one more +> round after `IStorageItemWithPath.Item` became Windows-only (10 β†’ 0). `-p:FilesWin32Compat=true` remains as an opt-in lens and still +> builds. The inventory below is historical. + Branch `linux/p2-head`. Generated from `dotnet build src/Files.App -f net10.0-desktop` (Uno.Sdk 6.7.30, Uno.WinUI 6.7.135). ## How the build is organised 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.Storage/Archives/ArchiveContext.cs b/src/Files.App.Storage/Archives/ArchiveContext.cs new file mode 100644 index 000000000000..5dcb0925013c --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveContext.cs @@ -0,0 +1,100 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using System.IO; +using System.Text; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + internal sealed class ArchiveContext(string path, IArchiveService service, IArchivePasswordPrompt? prompt, System.Collections.Concurrent.ConcurrentDictionary? passwordCache = null) + { + private string? password = passwordCache is not null && passwordCache.TryGetValue(path, out var cached) ? cached : null; + private Dictionary? entries; + private readonly SemaphoreSlim gate = new(1, 1); + public string Path { get; } = path; + public IArchiveService Service { get; } = service; + + public async Task WithPasswordAsync(Func> operation, CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + for (var attempt = 0; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var result = await operation(password).ConfigureAwait(false); + if (password is not null && passwordCache is not null) + passwordCache[Path] = password; + return result; + } + catch (ArchivePasswordException) when (prompt is not null && attempt < 3) + { + password = await prompt.RequestPasswordAsync(Path, password is not null, cancellationToken).ConfigureAwait(false); + if (password is null) + throw new OperationCanceledException(cancellationToken); + } + } + } + finally { gate.Release(); } + } + + public async Task> ListAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entries is { } cached) + return cached; + var listing = await WithPasswordAsync(password => Service.ListForBrowsingAsync(Path, password, cancellationToken: cancellationToken), cancellationToken).ConfigureAwait(false); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var entry in listing.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var name = Normalize(entry.Path); + if (name.Length == 0 || entry.LinkTarget is not null) + continue; + if (!result.TryAdd(name, entry with { Path = name })) + throw new ArchiveSecurityException("The archive contains ambiguous entry names."); + } + foreach (var name in result.Keys.ToArray()) + { + var parent = name; + while (parent.LastIndexOf('/') is var index && index >= 0) + { + parent = parent[..index]; + if (result.TryGetValue(parent, out var item) && !item.IsDirectory) + throw new ArchiveSecurityException("An archive file is also used as a directory."); + if (result.Count >= 10000 && !result.ContainsKey(parent)) + throw new ArchiveSecurityException("The archive hierarchy limit was exceeded."); + result.TryAdd(parent, new ArchiveEntryInfo(parent, true, 0, 0, null, false)); + } + } + entries = result; + return result; + } + + internal static string Normalize(string value) + { + if (Encoding.UTF8.GetByteCount(value) > 4096) + throw new ArchiveSecurityException("An archive entry path is too long."); + var path = value.Replace('\\', '/'); + if (path.StartsWith('/') || path.Contains('\0') || (path.Length >= 2 && path[1] == ':')) + throw new ArchiveSecurityException("An archive entry has an unsafe path."); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length > 128 || segments.Any(segment => Encoding.UTF8.GetByteCount(segment) > 255)) + throw new ArchiveSecurityException("An archive entry path exceeds browsing limits."); + if (segments.Any(segment => segment == "..")) + throw new ArchiveSecurityException("An archive entry traverses its parent."); + return string.Join('/', segments.Where(segment => segment != ".")); + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveEntryFile.cs b/src/Files.App.Storage/Archives/ArchiveEntryFile.cs new file mode 100644 index 000000000000..b0be489784f1 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveEntryFile.cs @@ -0,0 +1,49 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using Files.Shared.Helpers; +using System.IO; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + /// A regular archive member. The caller owns the returned bounded stream. + public sealed class ArchiveEntryFile : IChildFile + { + private readonly ArchiveContext context; + private readonly ArchiveFolder parent; + public ArchiveEntryInfo Entry { get; } + public string Id => context.Path + "/" + Entry.Path; + public string Name => ArchiveDisplayName.Escape(Entry.Path[(Entry.Path.LastIndexOf('/') + 1)..]); + + internal ArchiveEntryFile(ArchiveContext context, ArchiveEntryInfo entry, ArchiveFolder parent) + { + this.context = context; + Entry = entry; + this.parent = parent; + } + + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(parent); + } + + public Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // LINUX-TODO(archives): entry modification remains disabled behind IArchiveService.CanWriteEntries. + if (accessMode != FileAccess.Read) + throw new NotSupportedException("Archive browsing is read-only."); + return context.WithPasswordAsync(password => context.Service.OpenEntryAsync(context.Path, Entry.Path, password, cancellationToken), cancellationToken); + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveFolder.cs b/src/Files.App.Storage/Archives/ArchiveFolder.cs new file mode 100644 index 000000000000..039d599d58e8 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveFolder.cs @@ -0,0 +1,73 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using Files.Shared.Helpers; +using OwlCore.Storage.System.IO; +using System.IO; +using System.Runtime.CompilerServices; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + /// A read-only view of an archive directory, including directories implied by entry names. + public sealed class ArchiveFolder : IChildFolder, IGetItem + { + private readonly ArchiveContext context; + internal string EntryPath { get; } + public string Id => EntryPath.Length == 0 ? context.Path : context.Path + "/" + EntryPath; + public string Name => ArchiveDisplayName.Escape(EntryPath.Length == 0 ? Path.GetFileName(context.Path) : EntryPath[(EntryPath.LastIndexOf('/') + 1)..]); + + internal ArchiveFolder(ArchiveContext context, string entryPath) + { + this.context = context; + EntryPath = entryPath; + } + + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + IFolder? parent = EntryPath.Length == 0 + ? new SystemFolder(Path.GetDirectoryName(context.Path)!) + : new ArchiveFolder(context, EntryPath.Contains('/') ? EntryPath[..EntryPath.LastIndexOf('/')] : string.Empty); + return Task.FromResult(parent); + } + + public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var entries = await context.ListAsync(cancellationToken).ConfigureAwait(false); + var prefix = EntryPath.Length == 0 ? string.Empty : EntryPath + "/"; + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!entry.Key.StartsWith(prefix, StringComparison.Ordinal) || entry.Key.Length == prefix.Length || entry.Key.AsSpan(prefix.Length).Contains('/')) + continue; + if (entry.Value.IsDirectory && type.HasFlag(StorableType.Folder)) + yield return new ArchiveFolder(context, entry.Key); + else if (!entry.Value.IsDirectory && type.HasFlag(StorableType.File)) + yield return new ArchiveEntryFile(context, entry.Value, this); + } + } + + public async Task GetItemAsync(string id, CancellationToken cancellationToken = default) + { + await foreach (var item in GetItemsAsync(StorableType.All, cancellationToken).ConfigureAwait(false)) + if (item.Id == id) + return item; + throw new FileNotFoundException("The archive child was not found."); + } + + public Task GetFolderWatcherAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromException(new NotSupportedException()); + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs b/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs new file mode 100644 index 000000000000..5fd7e21667d8 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + public static class ArchiveServiceCollectionExtensions + { + /// Registers archive routing; the host provides IArchiveService and an optional IArchivePasswordPrompt. + public static IServiceCollection AddArchiveStorables(this IServiceCollection services) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services; + } + } +} diff --git a/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs new file mode 100644 index 000000000000..24e9e32f2427 --- /dev/null +++ b/src/Files.App.Storage/Archives/ArchiveStorableRoute.cs @@ -0,0 +1,62 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using System.IO; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Files.Core.Storage.Contracts; +using OwlCore.Storage; + +namespace Files.App.Storage.Archives +{ + /// Routes archive roots and members before the local file system route. + public sealed class ArchiveStorableRoute(IArchiveService service, IArchivePasswordPrompt? passwordPrompt = null) : IStorableRoute + { + // Session-only; avoids re-prompting on every navigation into an encrypted archive + private readonly System.Collections.Concurrent.ConcurrentDictionary passwords = new(StringComparer.Ordinal); + + public int Order => 100; + + public async Task TryGetAsync(string path, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(path) || path.Contains('\0') || !Path.IsPathFullyQualified(path)) + return StorableResult.NotMine; + try + { + for (var end = 1; end <= path.Length; end++) + { + if (end < path.Length && path[end] != Path.DirectorySeparatorChar) + continue; + var container = path[..end]; + if (!service.IsArchiveFileName(container)) + continue; + var attributes = File.GetAttributes(container); + if (attributes.HasFlag(FileAttributes.Directory)) + continue; + var context = new ArchiveContext(container, service, passwordPrompt, passwords); + var entryPath = ArchiveContext.Normalize(path[end..].TrimStart('/')); + var listing = await context.ListAsync(cancellationToken).ConfigureAwait(false); + if (entryPath.Length == 0) + return StorableResult.Success(new ArchiveFolder(context, string.Empty)); + if (!listing.TryGetValue(entryPath, out var entry)) + return StorableResult.NotFound; + var parentPath = entryPath.Contains('/') ? entryPath[..entryPath.LastIndexOf('/')] : string.Empty; + return StorableResult.Success(entry.IsDirectory + ? new ArchiveFolder(context, entryPath) + : new ArchiveEntryFile(context, entry, new ArchiveFolder(context, parentPath))); + } + return StorableResult.NotMine; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { return StorableResult.NotFound; } + catch (UnauthorizedAccessException) { return StorableResult.AccessDenied; } + catch (OperationCanceledException) { throw; } + catch (Exception) { return StorableResult.Error; } + } + } +} diff --git a/src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.cs b/src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.Windows.cs similarity index 100% rename from src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.cs rename to src/Files.App.Storage/Legacy/HomeFolder/HomeFolder.Windows.cs diff --git a/src/Files.App.Storage/Legacy/HomeFolder/LinuxHomeFolder.cs b/src/Files.App.Storage/Legacy/HomeFolder/LinuxHomeFolder.cs new file mode 100644 index 000000000000..1a9efb937cf3 --- /dev/null +++ b/src/Files.App.Storage/Legacy/HomeFolder/LinuxHomeFolder.cs @@ -0,0 +1,92 @@ +// Copyright (c) Files Community +// SPDX-License-Identifier: MPL-2.0 + +using Files.Platform.Abstractions; +using Files.Platform.Abstractions.Gvfs; +using Files.Platform.Abstractions.Recent; +using Files.Platform.Abstractions.Volumes; +using OwlCore.Storage.System.IO; +using System.Runtime.CompilerServices; + +namespace Files.App.Storage.Storables +{ + public sealed class LinuxHomeFolder : IHomeFolder + { + private readonly IUserDirectories directories; + private readonly IVolumeService volumes; + private readonly IRecentFilesStore recentFiles; + private readonly INetworkLocationService network; + + public string Id => "Home"; + public string Name => "Home"; + + public LinuxHomeFolder(IUserDirectories directories, IVolumeService volumes, IRecentFilesStore recentFiles, INetworkLocationService network) + { + this.directories = directories; + this.volumes = volumes; + this.recentFiles = recentFiles; + this.network = network; + } + + public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.Folder, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (type.HasFlag(StorableType.Folder)) + { + await foreach (var item in GetQuickAccessFolderAsync(cancellationToken)) + yield return item; + await foreach (var item in GetLogicalDrivesAsync(cancellationToken)) + yield return item; + await foreach (var item in GetNetworkLocationsAsync(cancellationToken)) + yield return item; + } + if (type.HasFlag(StorableType.File)) + { + await foreach (var item in GetRecentFilesAsync(cancellationToken)) + yield return item; + } + } + + public async IAsyncEnumerable GetQuickAccessFolderAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + foreach (var path in new[] { directories.Desktop, directories.Downloads, directories.Documents, directories.Pictures, directories.Music, directories.Videos }.Distinct(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (SystemIO.Directory.Exists(path)) + yield return new SystemFolder(path); + } + } + + public async IAsyncEnumerable GetLogicalDrivesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var paths = (await volumes.GetVolumesAsync(cancellationToken).ConfigureAwait(false)) + .SelectMany(volume => volume.MountPoints).Prepend("/").Distinct(StringComparer.Ordinal); + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new SystemFolder(path); + } + } + + public async IAsyncEnumerable GetNetworkLocationsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + foreach (var mount in network.GetMounts()) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new SystemFolder(mount.Path); + } + } + + public async IAsyncEnumerable GetRecentFilesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + foreach (var entry in recentFiles.Read()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (SystemIO.File.Exists(entry.Path)) + yield return new SystemFile(entry.Path); + } + } + } +} diff --git a/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj b/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj new file mode 100644 index 000000000000..251835dddf62 --- /dev/null +++ b/src/Files.App.UnoVirtualization/Files.App.UnoVirtualization.csproj @@ -0,0 +1,23 @@ + + + + + + $(DesktopTargetFramework) + enable + enable + Debug;Release + x86;x64;arm64 + true + false + true + + + + + + + + + + diff --git a/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs new file mode 100644 index 000000000000..1da43b06fe3d --- /dev/null +++ b/src/Files.App.UnoVirtualization/VirtualizingWrapGrid.cs @@ -0,0 +1,344 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Windows.Foundation; + +namespace Files.App.UnoVirtualization +{ + /// + /// A virtualizing wrapping grid for on Uno Skia, where is not implemented. + /// Only the lines inside the viewport are realized, so large folders stay cheap. + /// + /// + /// Plugs a multi-item line layout into Uno's VirtualizingPanelLayout (the same machinery as ). + /// That layout API is internal to Uno.UI, so the project publicizes Uno.UI (Krafs.Publicizer), which also lets the runtime skip access checks. + /// + public sealed class VirtualizingWrapGrid : Panel, IVirtualizingPanel + { + private readonly WrapGridLayout _layout; + + public VirtualizingWrapGrid() + { + _layout = new WrapGridLayout { CacheLength = 4d }; + _layout.Initialize(this); + UpdateLayoutOrientation(); + } + + public static DependencyProperty OrientationProperty { get; } = DependencyProperty.Register( + nameof(Orientation), + typeof(Orientation), + typeof(VirtualizingWrapGrid), + new PropertyMetadata(Orientation.Horizontal, new PropertyChangedCallback((d, _) => ((VirtualizingWrapGrid)d).UpdateLayoutOrientation()))); + + /// + /// Gets or sets the direction items are laid out in before wrapping (same meaning as WrapPanel.Orientation). + /// + public Orientation Orientation + { + get => (Orientation)GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + private Orientation ScrollOrientation => + Orientation == Orientation.Horizontal ? Orientation.Vertical : Orientation.Horizontal; + + public override Orientation? PhysicalOrientation => ScrollOrientation; + + public override bool WantsScrollViewerToObscureAvailableSizeBasedOnScrollBarVisibility(Orientation orientation) => + ScrollOrientation == orientation; + + VirtualizingPanelLayout IVirtualizingPanel.GetLayouter() => _layout; + + public override Size MeasureOverride(Size availableSize) + { + _layout.Update(Orientation == Orientation.Horizontal ? availableSize.Width : availableSize.Height); + return _layout.CorrectExtent(_layout.MeasureOverride(availableSize), availableSize); + } + + public override Size ArrangeOverride(Size finalSize) => _layout.CorrectExtent(_layout.ArrangeOverride(finalSize), finalSize); + + /// + /// Gets the number of items in a row (a column for the vertical-wrap orientation) at the current size. + /// + public int ItemsPerRow => _layout.ItemsPerRow; + + /// + /// Gets the scroll direction: Vertical when items wrap horizontally into rows, Horizontal when they wrap vertically into columns. + /// + public Orientation PanelScrollOrientation => ScrollOrientation; + + /// + /// Scrolls the minimum distance that shows the whole cell of the item at . + /// + public void EnsureItemVisible(int index) => _layout.EnsureItemVisible(index); + + private const string SupportedUnoVersionPrefix = "6.7."; + private static string? s_unsupportedReason; + private static bool s_probed; + + /// + /// Checks that the Uno internals this panel builds on are present, so callers can fall back to a plain panel (and log + /// ) after a Uno upgrade changes them. The result is computed once. + /// + public static bool IsSupported(out string? reason) + { + if (!s_probed) + { + try + { + var unoVersion = typeof(Panel).Assembly.GetName().Version?.ToString() ?? string.Empty; + var informational = typeof(Panel).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false); + var full = informational.Length > 0 ? ((System.Reflection.AssemblyInformationalVersionAttribute)informational[0]).InformationalVersion : unoVersion; + if (!full.StartsWith(SupportedUnoVersionPrefix, StringComparison.Ordinal)) + { + s_unsupportedReason = $"Uno.UI {full} is not the version the virtualizing wrap grid was built against ({SupportedUnoVersionPrefix}x)"; + } + else + { + // Instantiating exercises the internal types and members the layout subclasses + _ = ((IVirtualizingPanel)new VirtualizingWrapGrid()).GetLayouter(); + } + } + catch (Exception ex) when (ex is TypeLoadException or MissingMemberException or MethodAccessException or TypeInitializationException or BadImageFormatException or InvalidProgramException) + { + s_unsupportedReason = $"{ex.GetType().Name}: {ex.Message}"; + } + + s_probed = true; + } + + reason = s_unsupportedReason; + return s_unsupportedReason is null; + } + + public static DependencyProperty ProvisionalCellSizeProperty { get; } = DependencyProperty.Register( + nameof(ProvisionalCellSize), + typeof(Size), + typeof(VirtualizingWrapGrid), + new PropertyMetadata(new Size(120, 140))); + + /// + /// Gets or sets the cell size assumed until the first container has loaded and reports its real size. + /// + public Size ProvisionalCellSize + { + get => (Size)GetValue(ProvisionalCellSizeProperty); + set + { + SetValue(ProvisionalCellSizeProperty, value); + _layout.SetProvisionalCell(value); + } + } + + private void UpdateLayoutOrientation() => _layout.Orientation = ScrollOrientation; + + /// + /// Uno's layout works in lines of one item (its scroll-offset to item mapping assumes that). Each item therefore gets a virtual strip + /// (cell extent / items per row) along the scroll axis, which keeps every item inside the real band of its row, and + /// places the container at its real grid cell. + /// + private sealed class WrapGridLayout : VirtualizingPanelLayout + { + private const double MinReliableSize = 8d; + private const int MaxConsecutiveResizes = 4; + + private double _cellBreadth = 120d; + private double _cellExtent = 140d; + private int _itemsPerRow = 1; + private bool _cellMeasured; + private int _consecutiveResizes; + + public override Orientation ScrollOrientation => Orientation; + + public override int GetItemsPerLine() => 1; + + private double StripExtent => _cellExtent / _itemsPerRow; + + public void SetProvisionalCell(Size size) + { + if (_cellMeasured || size.Width < MinReliableSize || size.Height < MinReliableSize) + return; + + _cellBreadth = ScrollOrientation == Orientation.Vertical ? size.Width : size.Height; + _cellExtent = ScrollOrientation == Orientation.Vertical ? size.Height : size.Width; + } + + public int ItemsPerRow => _itemsPerRow; + + /// + /// Adopts the real cell size once a container has loaded (the item template sizes itself through bindings that only resolve after load) + /// and re-derives the number of items per row. When either changed, the realized lines are rebuilt around the item that was + /// at the top of the viewport, so a resize deep in a large folder does not realize items from the start. + /// + public void Update(double availableBreadth) + { + var anchorItem = GetAnchorItem(); + var changed = false; + if (GetFirstMaterializedLine()?.FirstView is { IsLoaded: true } view) + { + var breadth = GetBreadth(view.DesiredSize); + var extent = GetExtent(view.DesiredSize); + if (breadth >= MinReliableSize && extent >= MinReliableSize && + (Math.Abs(breadth - _cellBreadth) > 0.5 || Math.Abs(extent - _cellExtent) > 0.5 || !_cellMeasured)) + { + _cellBreadth = breadth; + _cellExtent = extent; + _cellMeasured = true; + changed = true; + } + } + + if (!double.IsInfinity(availableBreadth) && !double.IsNaN(availableBreadth)) + { + var perRow = Math.Max(1, (int)(availableBreadth / _cellBreadth)); + if (perRow != _itemsPerRow) + { + _itemsPerRow = perRow; + changed = true; + } + } + + if (!changed) + { + _consecutiveResizes = 0; + return; + } + + // Guards against a cell size that keeps changing between passes + if (++_consecutiveResizes > MaxConsecutiveResizes) + return; + + if (anchorItem > 0 && ScrollViewer is not null) + Reanchor(anchorItem); + else + Refresh(); + } + + private int GetAnchorItem() + { + if (GetFirstMaterializedLine() is null || ScrollViewer is null || _cellExtent <= 0) + return -1; + + var count = ItemsControl?.NumberOfItems ?? 0; + var row = (int)(ScrollOffset / _cellExtent); + return Math.Clamp(row * _itemsPerRow, 0, Math.Max(0, count - 1)); + } + + private double? _pendingOffset; + + /// + /// Rebuilds the realized lines around the current scroll offset (a plain refresh would realize items from the start up to the + /// offset), then moves the scroll offset so the item that was at the top stays there once the new extent is known. + /// + private void Reanchor(int anchorItem) + { + _pendingOffset = anchorItem / _itemsPerRow * _cellExtent; + + var count = ItemsControl?.NumberOfItems ?? 0; + var currentRow = Math.Min((int)(ScrollOffset / _cellExtent), Math.Max(0, (count - 1) / _itemsPerRow)); + ClearLines(clearContainer: true); + Generator.ClearIdCache(); + SetDynamicSeed(Uno.UI.IndexPath.FromRowSection(currentRow * _itemsPerRow - 1, 0), currentRow * _cellExtent); + UpdateLayout(null, isScroll: true); + UpdateCompleted(); + + ScheduleApplyPendingOffset(0); + } + + private void ScheduleApplyPendingOffset(int attempt) + { + var dispatcher = OwnerPanel.DispatcherQueue; + dispatcher?.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => ApplyPendingOffset(attempt)); + } + + private void ApplyPendingOffset(int attempt) + { + if (_pendingOffset is not { } target || ScrollViewer is not { } scroller) + return; + + var vertical = ScrollOrientation == Orientation.Vertical; + var extent = vertical ? scroller.ExtentHeight : scroller.ExtentWidth; + var viewport = vertical ? scroller.ViewportHeight : scroller.ViewportWidth; + + // The new extent arrives with the next layout pass; wait for it so the offset isn't clamped to the old one + if (extent < target + viewport * 0.5 && attempt < 8) + { + ScheduleApplyPendingOffset(attempt + 1); + return; + } + + _pendingOffset = null; + var clamped = Math.Clamp(target, 0, Math.Max(0, extent - viewport)); + scroller.ChangeView(vertical ? null : clamped, vertical ? clamped : null, null, disableAnimation: true); + } + + /// + /// Uno sizes the panel from per-item strips, which falls short of the real extent by the empty part of the last row. + /// + public Size CorrectExtent(Size size, Size available) + { + var count = ItemsControl?.NumberOfItems ?? 0; + if (count == 0) + return size; + + var extent = Math.Ceiling(count / (double)_itemsPerRow) * _cellExtent; + var scrollsVertically = ScrollOrientation == Orientation.Vertical; + var availableExtent = scrollsVertically ? available.Height : available.Width; + extent = Math.Max(extent, double.IsInfinity(availableExtent) ? 0 : availableExtent); + return scrollsVertically ? new Size(size.Width, extent) : new Size(extent, size.Height); + } + + /// + /// Scrolls the minimum distance that shows the whole cell of an item; Uno only knows the item's virtual strip. + /// + public void EnsureItemVisible(int index) + { + if (ScrollViewer is not { } scroller || index < 0) + return; + + var start = index / _itemsPerRow * _cellExtent; + var end = start + _cellExtent; + var vertical = ScrollOrientation == Orientation.Vertical; + var offset = vertical ? scroller.VerticalOffset : scroller.HorizontalOffset; + var viewport = vertical ? scroller.ViewportHeight : scroller.ViewportWidth; + + double? target = null; + if (start < offset) + target = start; + else if (end > offset + viewport) + target = Math.Max(0, end - viewport); + + if (target is { } value) + scroller.ChangeView(vertical ? null : value, vertical ? value : null, null, disableAnimation: true); + } + + public override Line CreateLine(GeneratorDirection fillDirection, double extentOffset, double availableBreadth, Uno.UI.IndexPath nextVisibleItem) + { + var flat = GetFlatItemIndex(nextVisibleItem); + var view = Generator.DequeueViewForItem(flat)!; + AddView(view, fillDirection, extentOffset, 0); + + // Pin the virtual strip: a container whose template has not sized itself yet must not distort the line positions + var strip = StripExtent; + var start = fillDirection == GeneratorDirection.Forward ? extentOffset : extentOffset - strip; + SetBounds(view, ScrollOrientation == Orientation.Vertical + ? new Rect(0, start, _cellBreadth, strip) + : new Rect(start, 0, strip, _cellBreadth)); + + return new Line(flat, (view, nextVisibleItem)); + } + + public override Rect GetElementArrangeBounds(int elementIndex, Rect containerBounds, Size windowConstraint, Size finalSize) + { + var row = elementIndex / _itemsPerRow; + var column = elementIndex % _itemsPerRow; + return ScrollOrientation == Orientation.Vertical + ? new Rect(column * _cellBreadth, row * _cellExtent, _cellBreadth, _cellExtent) + : new Rect(row * _cellExtent, column * _cellBreadth, _cellExtent, _cellBreadth); + } + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs b/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs new file mode 100644 index 000000000000..5b844b9364c2 --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/ArchivePasswordPrompt.cs @@ -0,0 +1,26 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using System.Text; + +namespace Files.App.Actions +{ + internal sealed class ArchivePasswordPrompt : IArchivePasswordPrompt + { + public async Task RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var viewModel = new CredentialDialogViewModel { PasswordOnly = true, CanBeAnonymous = false, IsWrongPassword = retry }; + var dialogService = Ioc.Default.GetRequiredService(); + var result = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => dialogService.ShowDialogAsync(viewModel)); + cancellationToken.ThrowIfCancellationRequested(); + if (result != DialogResult.Primary) + return null; + if (viewModel.Password is not { } password) + return null; + using (password) + return Encoding.UTF8.GetString(password); + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs b/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs index 2cae00576d18..74e6863c9d2f 100644 --- a/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs +++ b/src/Files.App/Actions/Content/Archives/Compress/BaseCompressArchiveAction.cs @@ -67,7 +67,11 @@ private bool IsContextPageTypeAdaptedToCommand() { return context.PageType != ContentPageTypes.RecycleBin && - context.PageType != ContentPageTypes.ZipFolder && + (context.PageType != ContentPageTypes.ZipFolder +#if !WINDOWS + || Ioc.Default.GetRequiredService().CanWriteEntries +#endif + ) && context.PageType != ContentPageTypes.ReleaseNotes && context.PageType != ContentPageTypes.Settings && context.PageType != ContentPageTypes.None; diff --git a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs new file mode 100644 index 000000000000..53f80ccb7299 --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.Windows.cs @@ -0,0 +1,134 @@ +ο»Ώ// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Microsoft.UI.Xaml.Controls; +using System.Text; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Actions +{ + internal abstract class BaseDecompressArchiveAction : BaseUIAction, IAction + { + protected readonly IContentPageContext context; + protected IStorageArchiveService StorageArchiveService { get; } = Ioc.Default.GetRequiredService(); + + public abstract string Label { get; } + + public abstract string Description { get; } + + public virtual ActionCategory Category + => ActionCategory.Archive; + + public virtual HotKey HotKey + => HotKey.None; + + public override bool IsExecutable => + (IsContextPageTypeAdaptedToCommand() && + CanDecompressSelectedItems() || + CanDecompressInsideArchive()) && + UIHelpers.CanShowDialog; + + public BaseDecompressArchiveAction() + { + context = Ioc.Default.GetRequiredService(); + + context.PropertyChanged += Context_PropertyChanged; + } + + public abstract Task ExecuteAsync(object? parameter = null); + + protected bool IsContextPageTypeAdaptedToCommand() + { + return + context.PageType != ContentPageTypes.RecycleBin && + context.PageType != ContentPageTypes.ZipFolder && + context.PageType != ContentPageTypes.ReleaseNotes && + context.PageType != ContentPageTypes.Settings && + context.PageType != ContentPageTypes.None; + } + + protected async Task DecompressArchiveHereAsync(bool smart = false) + { + if (context.SelectedItems.Count is 0) + return; + + var selectedItems = context.SelectedItems.ToList(); + var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath ?? string.Empty; + BaseStorageFolder? currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath); + + foreach (var selectedItem in selectedItems) + { + var password = string.Empty; + BaseStorageFile? archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); + + if (archive?.Path is null) + return; + + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + { + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + { + IsArchiveEncrypted = true, + ShowPathSelection = false + }; + + DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); + if (option != ContentDialogResult.Primary) + return; + + if (decompressArchiveViewModel.Password is not null) + password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); + } + + BaseStorageFolder? destinationFolder = null; + + var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archive.Path, password); + + if (smart && currentFolder is not null && isMultipleItems) + { + destinationFolder = + await FilesystemTasks.WrapNullable(() => + currentFolder.CreateFolderAsync( + SystemIO.Path.GetFileNameWithoutExtension(archive.Path), + CreationCollisionOption.GenerateUniqueName).AsTask()); + } + else + { + destinationFolder = currentFolder; + } + + // Operate decompress + var result = await FilesystemTasks.Wrap(() => + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + } + } + + protected virtual bool CanDecompressInsideArchive() + { + return false; + } + + protected virtual bool CanDecompressSelectedItems() + { + return StorageArchiveService.CanDecompress(context.SelectedItems); + } + + protected virtual void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(IContentPageContext.SelectedItems): + case nameof(IContentPageContext.Folder): + OnPropertyChanged(nameof(IsExecutable)); + break; + } + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs index 53f80ccb7299..9e8af621965c 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/BaseDecompressArchiveAction.cs @@ -1,11 +1,12 @@ ο»Ώ// Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.Dialogs; using Microsoft.UI.Xaml.Controls; using System.Text; -using Windows.Foundation.Metadata; -using Windows.Storage; + + namespace Files.App.Actions { @@ -56,19 +57,17 @@ protected async Task DecompressArchiveHereAsync(bool smart = false) var selectedItems = context.SelectedItems.ToList(); var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath ?? string.Empty; - BaseStorageFolder? currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath); foreach (var selectedItem in selectedItems) { var password = string.Empty; - BaseStorageFile? archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); - - if (archive?.Path is null) + var archivePath = selectedItem.ItemPath; + if (string.IsNullOrEmpty(archivePath) || !SystemIO.File.Exists(archivePath)) return; - if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath))) { - DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath) { IsArchiveEncrypted = true, ShowPathSelection = false @@ -76,8 +75,7 @@ protected async Task DecompressArchiveHereAsync(bool smart = false) DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); if (option != ContentDialogResult.Primary) @@ -87,26 +85,15 @@ protected async Task DecompressArchiveHereAsync(bool smart = false) password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); } - BaseStorageFolder? destinationFolder = null; - - var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archive.Path, password); - - if (smart && currentFolder is not null && isMultipleItems) - { - destinationFolder = - await FilesystemTasks.WrapNullable(() => - currentFolder.CreateFolderAsync( - SystemIO.Path.GetFileNameWithoutExtension(archive.Path), - CreationCollisionOption.GenerateUniqueName).AsTask()); - } - else - { - destinationFolder = currentFolder; - } + var destinationFolderPath = currentFolderPath; + var isMultipleItems = await StorageArchiveService.HasMultipleTopLevelEntriesAsync(archivePath, password); + if (smart && isMultipleItems) + destinationFolderPath = SystemIO.Path.Combine(currentFolderPath, + Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath)); // Operate decompress var result = await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolderPath, password)); } } @@ -132,3 +119,5 @@ protected virtual void Context_PropertyChanged(object? sender, PropertyChangedEv } } } + +#endif diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs new file mode 100644 index 000000000000..5a1d4a8f4e68 --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.Windows.cs @@ -0,0 +1,145 @@ +ο»Ώ// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Files.Shared.Helpers; +using Microsoft.UI.Xaml.Controls; +using System.IO; +using System.Text; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Actions +{ + [GeneratedRichCommand] + internal sealed partial class DecompressArchiveAction : BaseDecompressArchiveAction + { + private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); + + public override string Label + => Strings.ExtractFiles.GetLocalizedResource(); + + public override string Description + => Strings.DecompressArchiveDescription.GetLocalizedFormatResource(context.SelectedItems.Count); + + public string AccessKey + => "E"; + + public override HotKey HotKey + => new(Keys.E, KeyModifiers.Ctrl); + + public DecompressArchiveAction() + { + } + + public override async Task ExecuteAsync(object? parameter = null) + { + if (context.ShellPage is null) + return; + + var archivePath = GetArchivePath(); + + if (string.IsNullOrEmpty(archivePath)) + return; + + BaseStorageFile? archive = await StorageHelpers.ToStorageItem(archivePath); + + if (archive?.Path is null) + return; + + var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)); + var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path)); + Encoding? detectedEncoding = null; + if (isArchiveEncodingUndetermined) + { + detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path)); + } + var password = string.Empty; + Encoding? encoding = null; + + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + { + IsArchiveEncrypted = isArchiveEncrypted, + IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined, + ShowPathSelection = true, + DetectedEncoding = detectedEncoding, + }; + DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); + if (option != ContentDialogResult.Primary) + return; + + if (isArchiveEncrypted && decompressArchiveViewModel.Password is not null) + password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password); + + encoding = decompressArchiveViewModel.SelectedEncoding.Encoding; + + // Check if archive still exists + if (!StorageHelpers.Exists(archive.Path)) + return; + + BaseStorageFolder? destinationFolder = decompressArchiveViewModel.DestinationFolder; + string destinationFolderPath = decompressArchiveViewModel.DestinationFolderPath; + + // Save extraction location for future use + SaveExtractionLocation(destinationFolderPath); + + if (destinationFolder is null) + { + BaseStorageFolder? parentFolder = await StorageHelpers.ToStorageItem(Path.GetDirectoryName(archive.Path) ?? string.Empty); + if (parentFolder is null) + return; + + destinationFolder = await FilesystemTasks.WrapNullable(() => parentFolder.CreateFolderAsync(Path.GetFileName(destinationFolderPath), CreationCollisionOption.GenerateUniqueName).AsTask()); + } + + // Operate decompress + var result = await FilesystemTasks.Wrap(() => + StorageArchiveService.DecompressAsync(archive.Path, destinationFolder?.Path ?? string.Empty, password, encoding)); + + if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion) + await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory); + } + + protected override bool CanDecompressInsideArchive() + { + return + context.PageType == ContentPageTypes.ZipFolder && + !context.HasSelection && + context.Folder is not null && + FileExtensionHelpers.IsZipFile(Path.GetExtension(context.Folder.ItemPath)); + } + + protected override bool CanDecompressSelectedItems() + { + return context.SelectedItems.Count == 1 && base.CanDecompressSelectedItems(); + } + + private string? GetArchivePath() + { + if (!string.IsNullOrEmpty(context.SelectedItem?.ItemPath)) + return context.SelectedItem?.ItemPath; + + if (context.PageType == ContentPageTypes.ZipFolder && !context.HasSelection) + return context.Folder?.ItemPath; + + return null; + } + + private void SaveExtractionLocation(string path) + { + var previousArchiveExtractionLocations = UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations?.ToList() ?? []; + previousArchiveExtractionLocations.Remove(path); + previousArchiveExtractionLocations.Insert(0, path); + + if (previousArchiveExtractionLocations.Count > 10) + UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations = previousArchiveExtractionLocations.RemoveFrom(11); + else + UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations = previousArchiveExtractionLocations; + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs index 5a1d4a8f4e68..31d0fd4b8e1b 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchive.cs @@ -1,13 +1,14 @@ ο»Ώ// Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.Dialogs; using Files.Shared.Helpers; using Microsoft.UI.Xaml.Controls; using System.IO; using System.Text; -using Windows.Foundation.Metadata; -using Windows.Storage; + + namespace Files.App.Actions { @@ -42,22 +43,20 @@ public override async Task ExecuteAsync(object? parameter = null) if (string.IsNullOrEmpty(archivePath)) return; - BaseStorageFile? archive = await StorageHelpers.ToStorageItem(archivePath); - - if (archive?.Path is null) + if (!File.Exists(archivePath)) return; - var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path)); - var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archive.Path)); + var isArchiveEncrypted = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath)); + var isArchiveEncodingUndetermined = await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncodingUndeterminedAsync(archivePath)); Encoding? detectedEncoding = null; if (isArchiveEncodingUndetermined) { - detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archive.Path)); + detectedEncoding = await FilesystemTasks.Wrap(() => StorageArchiveService.DetectEncodingAsync(archivePath)); } var password = string.Empty; Encoding? encoding = null; - DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath) { IsArchiveEncrypted = isArchiveEncrypted, IsArchiveEncodingUndetermined = isArchiveEncodingUndetermined, @@ -66,8 +65,7 @@ public override async Task ExecuteAsync(object? parameter = null) }; DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); if (option != ContentDialogResult.Primary) @@ -79,27 +77,18 @@ public override async Task ExecuteAsync(object? parameter = null) encoding = decompressArchiveViewModel.SelectedEncoding.Encoding; // Check if archive still exists - if (!StorageHelpers.Exists(archive.Path)) + if (!File.Exists(archivePath)) return; - BaseStorageFolder? destinationFolder = decompressArchiveViewModel.DestinationFolder; string destinationFolderPath = decompressArchiveViewModel.DestinationFolderPath; // Save extraction location for future use SaveExtractionLocation(destinationFolderPath); - if (destinationFolder is null) - { - BaseStorageFolder? parentFolder = await StorageHelpers.ToStorageItem(Path.GetDirectoryName(archive.Path) ?? string.Empty); - if (parentFolder is null) - return; - - destinationFolder = await FilesystemTasks.WrapNullable(() => parentFolder.CreateFolderAsync(Path.GetFileName(destinationFolderPath), CreationCollisionOption.GenerateUniqueName).AsTask()); - } // Operate decompress var result = await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(archive.Path, destinationFolder?.Path ?? string.Empty, password, encoding)); + StorageArchiveService.DecompressAsync(archivePath, destinationFolderPath, password, encoding)); if (decompressArchiveViewModel.OpenDestinationFolderOnCompletion) await NavigationHelpers.OpenPath(destinationFolderPath, context.ShellPage, FilesystemItemType.Directory); @@ -143,3 +132,5 @@ private void SaveExtractionLocation(string path) } } } + +#endif diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs new file mode 100644 index 000000000000..a730c4ea548d --- /dev/null +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.Windows.cs @@ -0,0 +1,111 @@ +ο»Ώ// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Dialogs; +using Microsoft.UI.Xaml.Controls; +using System.Text; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Actions +{ + [GeneratedRichCommand] + internal sealed partial class DecompressArchiveToChildFolderAction : BaseDecompressArchiveAction + { + public override string Label + => ComputeLabel(); + + public override string Description + => Strings.DecompressArchiveToChildFolderDescription.GetLocalizedFormatResource(context.SelectedItems.Count); + + public string AccessKey + => "C"; + + public DecompressArchiveToChildFolderAction() + { + } + + public override async Task ExecuteAsync(object? parameter = null) + { + if (context.SelectedItems.Count is 0) + return; + + foreach (var selectedItem in context.SelectedItems) + { + var password = string.Empty; + + var archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); + var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath; + if (archive?.Path is null) + return; + + var currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath ?? string.Empty); + + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + { + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + { + IsArchiveEncrypted = true, + ShowPathSelection = false + }; + DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); + if (option != ContentDialogResult.Primary) + return; + + password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password!); + } + + BaseStorageFolder? destinationFolder = null; + if (currentFolder is not null) + { + var destinationResult = await FilesystemTasks.WrapNullable(() => + currentFolder.CreateFolderAsync(SystemIO.Path.GetFileNameWithoutExtension(archive.Path), CreationCollisionOption.GenerateUniqueName).AsTask()); + destinationFolder = destinationResult.Result; + } + + // Operate decompress + await FilesystemTasks.Wrap(() => + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + } + } + + protected override void Context_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(IContentPageContext.SelectedItems): + case nameof(IContentPageContext.Folder): + { + if (IsContextPageTypeAdaptedToCommand()) + { + OnPropertyChanged(nameof(Label)); + OnPropertyChanged(nameof(IsExecutable)); + } + + break; + } + } + } + + private string ComputeLabel() + => ToPlatformSeparator(ComputeLabelCore()); + + private static string ToPlatformSeparator(string label) + => SystemIO.Path.DirectorySeparatorChar == '\\' ? label : label.Replace('\\', SystemIO.Path.DirectorySeparatorChar); + + private string ComputeLabelCore() + { + if (context.SelectedItems == null || context.SelectedItems.Count == 0) + return string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), string.Empty); + + return context.SelectedItems.Count > 1 + ? string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), "*") + : string.Format(Strings.BaseLayoutItemContextFlyoutExtractToChildFolder.GetLocalizedResource(), SystemIO.Path.GetFileNameWithoutExtension(context.SelectedItems.First().Name)); + } + } +} diff --git a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs index a730c4ea548d..8036eb252ea0 100644 --- a/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs +++ b/src/Files.App/Actions/Content/Archives/Decompress/DecompressArchiveToChildFolderAction.cs @@ -1,11 +1,12 @@ ο»Ώ// Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.Dialogs; using Microsoft.UI.Xaml.Controls; using System.Text; -using Windows.Foundation.Metadata; -using Windows.Storage; + + namespace Files.App.Actions { @@ -34,24 +35,21 @@ public override async Task ExecuteAsync(object? parameter = null) { var password = string.Empty; - var archive = await StorageHelpers.ToStorageItem(selectedItem.ItemPath!); + var archivePath = selectedItem.ItemPath; var currentFolderPath = context.ShellPage?.ShellViewModel?.CurrentFolder?.ItemPath; - if (archive?.Path is null) + if (string.IsNullOrEmpty(archivePath) || !SystemIO.File.Exists(archivePath) || string.IsNullOrEmpty(currentFolderPath)) return; - var currentFolder = await StorageHelpers.ToStorageItem(currentFolderPath ?? string.Empty); - - if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archive.Path))) + if (await FilesystemTasks.Wrap(() => StorageArchiveService.IsEncryptedAsync(archivePath))) { - DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archive) + DecompressArchiveDialogViewModel decompressArchiveViewModel = new(archivePath) { IsArchiveEncrypted = true, ShowPathSelection = false }; DecompressArchiveDialog decompressArchiveDialog = new() { ViewModel = decompressArchiveViewModel }; - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + decompressArchiveDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; ContentDialogResult option = await decompressArchiveDialog.TryShowAsync(); if (option != ContentDialogResult.Primary) @@ -60,17 +58,12 @@ public override async Task ExecuteAsync(object? parameter = null) password = Encoding.UTF8.GetString(decompressArchiveViewModel.Password!); } - BaseStorageFolder? destinationFolder = null; - if (currentFolder is not null) - { - var destinationResult = await FilesystemTasks.WrapNullable(() => - currentFolder.CreateFolderAsync(SystemIO.Path.GetFileNameWithoutExtension(archive.Path), CreationCollisionOption.GenerateUniqueName).AsTask()); - destinationFolder = destinationResult.Result; - } + var destinationFolderPath = SystemIO.Path.Combine(currentFolderPath, + Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath)); // Operate decompress await FilesystemTasks.Wrap(() => - StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolder?.Path ?? string.Empty, password)); + StorageArchiveService.DecompressAsync(selectedItem.ItemPath!, destinationFolderPath, password)); } } @@ -109,3 +102,5 @@ private string ComputeLabelCore() } } } + +#endif 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/CopyItemFromHomeAction.cs b/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs index 15477b11f12f..048aa65ffbb8 100644 --- a/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs +++ b/src/Files.App/Actions/FileSystem/CopyItemFromHomeAction.cs @@ -63,6 +63,7 @@ public async Task ExecuteAsync(object? parameter = null) return; } +#if WINDOWS if (storageItem is SystemStorageFolder or SystemStorageFile) { var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync(); @@ -70,6 +71,7 @@ public async Task ExecuteAsync(object? parameter = null) storageItem = standardItems.First(); } +#endif dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; dataPackage.SetStorageItems(new[] { storageItem }, false); 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/EmptyRecycleBinAction.cs b/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs index 6e57587c0a9a..8e0939f003ae 100644 --- a/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs +++ b/src/Files.App/Actions/FileSystem/EmptyRecycleBinAction.cs @@ -58,7 +58,11 @@ await confirmationDialog.TryShowAsync() is ContentDialogResult.Primary) { var banner = StatusCenterHelper.AddCard_EmptyRecycleBin(ReturnResult.InProgress); +#if WINDOWS bool result = await STATask.Run(StorageTrashBinService.EmptyTrashBin, App.Logger); +#else + bool result = await Task.Run(StorageTrashBinService.EmptyTrashBin); +#endif StatusCenterViewModel.RemoveItem(banner); 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/Sidebar/CopyItemFromSidebarAction.cs b/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs index 512d363ae2bf..27415b998e33 100644 --- a/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs +++ b/src/Files.App/Actions/Sidebar/CopyItemFromSidebarAction.cs @@ -63,6 +63,7 @@ public async Task ExecuteAsync(object? parameter = null) return; } +#if WINDOWS if (storageItem is SystemStorageFolder or SystemStorageFile) { var standardItems = await new[] { storageItem }.ToStandardStorageItemsAsync(); @@ -70,6 +71,7 @@ public async Task ExecuteAsync(object? parameter = null) storageItem = standardItems.First(); } +#endif dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; dataPackage.SetStorageItems(new[] { storageItem }, false); 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/Contexts/HomePage/HomePageContext.cs b/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs index 7968eb02324e..9cab4f66bb4c 100644 --- a/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs +++ b/src/Files.App/Data/Contexts/HomePage/HomePageContext.cs @@ -1,4 +1,4 @@ -ο»Ώ// Copyright (c) Files Community +// Copyright (c) Files Community // Licensed under the MIT License. using Microsoft.UI.Xaml.Controls.Primitives; @@ -12,7 +12,20 @@ public sealed partial class HomePageContext : ObservableObject, IHomePageContext public bool IsAnyItemRightClicked => rightClickedItem is not null; - public IHomeFolder HomeFolder { get; } = new HomeFolder(); + public IHomeFolder HomeFolder { get; } = CreateHomeFolder(); + + private static IHomeFolder CreateHomeFolder() + { +#if WINDOWS + return new HomeFolder(); +#else + return new LinuxHomeFolder( + Ioc.Default.GetRequiredService(), + Ioc.Default.GetRequiredService(), + Ioc.Default.GetRequiredService(), + Ioc.Default.GetRequiredService()); +#endif + } private WidgetCardItem? rightClickedItem = null; public WidgetCardItem? RightClickedItem => rightClickedItem; diff --git a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs index 5b91b8c85878..b138a0b106f8 100644 --- a/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.cs +++ b/src/Files.App/Data/Factories/SecurityAdvancedAccessControlItemFactory.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..399e6632e5bc 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 || FILES_WIN32_COMPAT 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 || FILES_WIN32_COMPAT public MENU_ITEM_TYPE Type { get; set; } +#endif public List? SubItems { get; set; } } } diff --git a/src/Files.App/Data/Items/IListedItem.cs b/src/Files.App/Data/Items/IListedItem.cs index b7a17f7672fd..5cd90b468ac7 100644 --- a/src/Files.App/Data/Items/IListedItem.cs +++ b/src/Files.App/Data/Items/IListedItem.cs @@ -53,7 +53,11 @@ public interface IListedItem DateTimeOffset ItemDateCreatedReal { get; set; } string? ItemDateModified { get; } DateTimeOffset ItemDateModifiedReal { get; set; } +#if WINDOWS BaseStorageFile? ItemFile { get; set; } +#else + IFile? ItemFile { get; set; } +#endif string? ItemNameRaw { get; set; } string? ItemPath { get; set; } ObservableCollection? ItemProperties { get; set; } diff --git a/src/Files.App/Data/Items/ListedItem.Windows.cs b/src/Files.App/Data/Items/ListedItem.Windows.cs new file mode 100644 index 000000000000..431125a7d774 --- /dev/null +++ b/src/Files.App/Data/Items/ListedItem.Windows.cs @@ -0,0 +1,24 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.IO; +using Windows.Storage; + +namespace Files.App.Utils +{ + public sealed partial class FtpItem + { + public async Task ToStorageItem() + { + var path = this.GetRequiredPath(); + var name = ItemNameRaw ?? throw new InvalidOperationException("The FTP item does not have a name."); + + return PrimaryItemAttribute switch + { + StorageItemTypes.File => await new Utils.Storage.FtpStorageFile(path, name, ItemDateCreatedReal).ToStorageFileAsync(), + StorageItemTypes.Folder => new Utils.Storage.FtpStorageFolder(path, name, ItemDateCreatedReal), + _ => throw new InvalidDataException("The FTP item has an unsupported storage type."), + }; + } + } +} diff --git a/src/Files.App/Data/Items/ListedItem.cs b/src/Files.App/Data/Items/ListedItem.cs index 4ee7941ebec5..8ca68abc565a 100644 --- a/src/Files.App/Data/Items/ListedItem.cs +++ b/src/Files.App/Data/Items/ListedItem.cs @@ -10,7 +10,6 @@ using System.IO; using System.Text; using Windows.Storage; -using Windows.Win32.UI.WindowsAndMessaging; using ByteSize = ByteSizeLib.ByteSize; #pragma warning disable CS0618 // Type or member is obsolete @@ -496,8 +495,13 @@ public override string ToString() public bool IsDriveRoot => ItemPath == PathNormalization.GetPathRoot(ItemPath); public bool IsElevationRequired { get; set; } +#if WINDOWS private BaseStorageFile? itemFile; public BaseStorageFile? ItemFile +#else + private IFile? itemFile; + public IFile? ItemFile +#endif { get => itemFile; set => SetProperty(ref itemFile, value); @@ -607,18 +611,7 @@ public FtpItem(FtpListItem item, string folder) : base(null) IsHiddenItem = false; } - public async Task ToStorageItem() - { - var path = this.GetRequiredPath(); - var name = ItemNameRaw ?? throw new InvalidOperationException("The FTP item does not have a name."); - return PrimaryItemAttribute switch - { - StorageItemTypes.File => await new Utils.Storage.FtpStorageFile(path, name, ItemDateCreatedReal).ToStorageFileAsync(), - StorageItemTypes.Folder => new Utils.Storage.FtpStorageFolder(path, name, ItemDateCreatedReal), - _ => throw new InvalidDataException("The FTP item has an unsupported storage type."), - }; - } } public sealed partial class ShortcutItem : ListedItem, IShortcutItem @@ -640,7 +633,7 @@ public override string? Name public string? Arguments { get; set; } public string? WorkingDirectory { get; set; } public bool RunAsAdmin { get; set; } - public SHOW_WINDOW_CMD ShowWindowCommand { get; set; } + public ShowWindowCommand ShowWindowCommand { get; set; } public bool IsUrl { get; set; } public bool IsSymLink { get; set; } public override bool IsScriptFile => FileExtensionHelpers.IsScriptFile(TargetPath); @@ -842,7 +835,7 @@ public override string Name public string? Arguments { get; set; } public string? WorkingDirectory { get; set; } public bool RunAsAdmin { get; set; } - public SHOW_WINDOW_CMD ShowWindowCommand { get; set; } + public ShowWindowCommand ShowWindowCommand { get; set; } public bool IsUrl { get; set; } public bool IsSymLink { get; set; } public override bool IsScriptFile => FileExtensionHelpers.IsScriptFile(TargetPath); @@ -875,7 +868,7 @@ public interface IShortcutItem : IListedItem public string? Arguments { get; set; } public string? WorkingDirectory { get; set; } public bool RunAsAdmin { get; set; } - public SHOW_WINDOW_CMD ShowWindowCommand { get; set; } + public ShowWindowCommand ShowWindowCommand { get; set; } public bool IsUrl { get; set; } public bool IsSymLink { 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/Data/Items/WidgetFolderCardItem.Windows.cs b/src/Files.App/Data/Items/WidgetFolderCardItem.Windows.cs new file mode 100644 index 000000000000..cf8570edac79 --- /dev/null +++ b/src/Files.App/Data/Items/WidgetFolderCardItem.Windows.cs @@ -0,0 +1,54 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Win32; +using Windows.Win32.System.Com; +using Windows.Win32.System.WinRT; +using Windows.Win32.UI.Shell; + +namespace Files.App.Data.Items +{ + public sealed partial class WidgetFolderCardItem + { + public async Task LoadCardThumbnailAsync() + { + if (_isDisposed || string.IsNullOrEmpty(Path)) + return; + + if (Item is null) + { + byte[]? icon = Path == Constants.UserEnvironmentPaths.RecycleBinPath + ? await DriveHelpers.GetDriveIconAsync(null, Constants.ShellIconSizes.Large) + : await FileThumbnailHelper.GetIconAsync(Path, (uint)Constants.ShellIconSizes.Large, true, IconOptions.ReturnIconOnly); + if (icon is not null) + Thumbnail = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => icon.ToBitmapAsync(), Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); + return; + } + + var thumbnailSize = (int)(Constants.ShellIconSizes.Large * App.AppModel.AppWindowDPI); + // Ensure thumbnail size is at least 1 to prevent layout errors + thumbnailSize = Math.Max(1, thumbnailSize); + var hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, ((IWindowsStorable)Item).ThisPtr, out IAgileReference shellItemReference); + if (hr.ThrowIfFailedOnDebug().Failed) + return; + + var rawThumbnailData = await STATask.RunPooled(() => + { + if (shellItemReference.Resolve(out IShellItem shellItem).ThrowIfFailedOnDebug().Failed) + return null; + + using var folder = new WindowsFolder(shellItem); + folder.TryGetThumbnail(thumbnailSize, SIIGBF.SIIGBF_ICONONLY, out var data); + return data; + }, App.Logger); + if (_isDisposed || rawThumbnailData is null) + return; + + var thumbnail = await rawThumbnailData.ToBitmapAsync(); + if (!_isDisposed) + Thumbnail = thumbnail; + } + + } +} diff --git a/src/Files.App/Data/Items/WidgetFolderCardItem.cs b/src/Files.App/Data/Items/WidgetFolderCardItem.cs index f3f4bc6a8a45..f54a89986256 100644 --- a/src/Files.App/Data/Items/WidgetFolderCardItem.cs +++ b/src/Files.App/Data/Items/WidgetFolderCardItem.cs @@ -1,21 +1,17 @@ -ο»Ώ// Copyright (c) Files Community +// Copyright (c) Files Community // Licensed under the MIT License. using Microsoft.UI.Xaml.Media.Imaging; -using Windows.Win32; -using Windows.Win32.System.Com; -using Windows.Win32.System.WinRT; -using Windows.Win32.UI.Shell; namespace Files.App.Data.Items { - public sealed partial class WidgetFolderCardItem : WidgetCardItem, IWidgetCardItem, IDisposable + public sealed partial class WidgetFolderCardItem : WidgetCardItem, IWidgetCardItem, IDisposable { // Properties public string? AutomationProperties { get; set; } - public new IWindowsStorable Item { get; private set; } + public new IStorable Item { get; private set; } public string? Text { get; set; } @@ -29,7 +25,7 @@ public sealed partial class WidgetFolderCardItem : WidgetCardItem, IWidgetCardIt // Constructor - public WidgetFolderCardItem(IWindowsStorable item, string text, string path, bool isPinned, string tooltip) + public WidgetFolderCardItem(IStorable item, string text, string path, bool isPinned, string tooltip) { AutomationProperties = text; Item = item; @@ -46,7 +42,9 @@ public WidgetFolderCardItem(IWindowsStorable item, string text, string path, boo public WidgetFolderCardItem(string path, string text, bool isPinned, string tooltip) { AutomationProperties = text; - Item = null!; + Item = SystemIO.Path.IsPathFullyQualified(path) + ? new OwlCore.Storage.System.IO.SystemFolder(path) + : new FolderPath(path, text); Text = text; IsPinned = isPinned; Path = path; @@ -54,51 +52,35 @@ public WidgetFolderCardItem(string path, string text, bool isPinned, string tool } #endif + private sealed record FolderPath(string Id, string Name) : IStorable; + // Methods +#if !WINDOWS public async Task LoadCardThumbnailAsync() { if (_isDisposed || string.IsNullOrEmpty(Path)) return; - if (Item is null) - { - byte[]? icon = Path == Constants.UserEnvironmentPaths.RecycleBinPath - ? await DriveHelpers.GetDriveIconAsync(null, Constants.ShellIconSizes.Large) - : await FileThumbnailHelper.GetIconAsync(Path, (uint)Constants.ShellIconSizes.Large, true, IconOptions.ReturnIconOnly); - if (icon is not null) - Thumbnail = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => icon.ToBitmapAsync(), Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal); - return; - } - - var thumbnailSize = (int)(Constants.ShellIconSizes.Large * App.AppModel.AppWindowDPI); - // Ensure thumbnail size is at least 1 to prevent layout errors - thumbnailSize = Math.Max(1, thumbnailSize); - var hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, Item.ThisPtr, out IAgileReference shellItemReference); - if (hr.ThrowIfFailedOnDebug().Failed) + byte[]? icon = Path == Constants.UserEnvironmentPaths.RecycleBinPath + ? await DriveHelpers.GetDriveIconAsync(null, Constants.ShellIconSizes.Large) + : await FileThumbnailHelper.GetIconAsync(Path, (uint)Constants.ShellIconSizes.Large, true, IconOptions.ReturnIconOnly); + if (_isDisposed || icon is null) return; - var rawThumbnailData = await STATask.RunPooled(() => + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => { - if (shellItemReference.Resolve(out IShellItem shellItem).ThrowIfFailedOnDebug().Failed) - return null; - - using var folder = new WindowsFolder(shellItem); - folder.TryGetThumbnail(thumbnailSize, SIIGBF.SIIGBF_ICONONLY, out var data); - return data; - }, App.Logger); - if (_isDisposed || rawThumbnailData is null) - return; - - var thumbnail = await rawThumbnailData.ToBitmapAsync(); - if (!_isDisposed) - Thumbnail = thumbnail; + var thumbnail = await icon.ToBitmapAsync(); + if (!_isDisposed) + Thumbnail = thumbnail; + }); } +#endif public void Dispose() { _isDisposed = true; - Item?.Dispose(); + (Item as IDisposable)?.Dispose(); } } } diff --git a/src/Files.App/Data/Items/WidgetRecentItem.cs b/src/Files.App/Data/Items/WidgetRecentItem.cs index 82af14ec74c1..7cd7a94f3bc8 100644 --- a/src/Files.App/Data/Items/WidgetRecentItem.cs +++ b/src/Files.App/Data/Items/WidgetRecentItem.cs @@ -2,12 +2,14 @@ // SPDX-License-Identifier: MPL-2.0 using Microsoft.UI.Xaml.Media.Imaging; +#if WINDOWS || FILES_WIN32_COMPAT using Windows.Win32.UI.Shell; +#endif namespace Files.App.Data.Items { /// - /// Represents an item for recent item of File Explorer on Windows. + /// Represents a recently used file. /// public sealed partial class RecentItem : WidgetCardItem, IEquatable, IDisposable { @@ -31,13 +33,12 @@ public BitmapImage? Icon /// public required DateTime LastModified { get; set; } +#if WINDOWS || FILES_WIN32_COMPAT /// - /// Gets or initializes PIDL of the recent item. + /// Gets or initializes the Windows shell item of the recent item. /// - /// - /// This has to be removed in the future. - /// - public required IShellItem ShellItem { get; init; } + public IShellItem? ShellItem { get; init; } +#endif /// /// Loads thumbnail icon of the recent item. @@ -47,9 +48,12 @@ public async Task LoadRecentItemIconAsync() { var result = await FileThumbnailHelper.GetIconAsync(Path, Constants.ShellIconSizes.Small, false, IconOptions.None); - var bitmapImage = await result.ToBitmapAsync(); - if (bitmapImage is not null) - Icon = bitmapImage; + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => + { + var bitmapImage = await result.ToBitmapAsync(); + if (bitmapImage is not null) + Icon = bitmapImage; + }); } public override int GetHashCode() => (Path, Name).GetHashCode(); diff --git a/src/Files.App/Data/Models/CompressArchiveModel.Windows.cs b/src/Files.App/Data/Models/CompressArchiveModel.Windows.cs new file mode 100644 index 000000000000..303a24807ede --- /dev/null +++ b/src/Files.App/Data/Models/CompressArchiveModel.Windows.cs @@ -0,0 +1,457 @@ +ο»Ώ// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Utils.Storage.Operations; +using Microsoft.Extensions.Logging; +using SevenZip; +using System.IO; + +namespace Files.App.Data.Models +{ + /// + /// Provides an archive creation support. + /// + public sealed class CompressArchiveModel : ICompressArchiveModel + { + private StatusCenterItemProgressModel? _fileSystemProgress; + + private FileSizeCalculator? _sizeCalculator; + + private string ArchiveExtension => FileFormat switch + { + ArchiveFormats.Zip => ".zip", + ArchiveFormats.SevenZip => ".7z", + ArchiveFormats.Tar => ".tar", + ArchiveFormats.GZip => ".gz", + _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), + }; + + private OutArchiveFormat SevenZipArchiveFormat => FileFormat switch + { + ArchiveFormats.Zip => OutArchiveFormat.Zip, + ArchiveFormats.SevenZip => OutArchiveFormat.SevenZip, + ArchiveFormats.Tar => OutArchiveFormat.Tar, + ArchiveFormats.GZip => OutArchiveFormat.GZip, + _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), + }; + + private CompressionLevel SevenZipCompressionLevel => CompressionLevel switch + { + ArchiveCompressionLevels.Ultra => SevenZip.CompressionLevel.Ultra, + ArchiveCompressionLevels.High => SevenZip.CompressionLevel.High, + ArchiveCompressionLevels.Normal => SevenZip.CompressionLevel.Normal, + ArchiveCompressionLevels.Low => SevenZip.CompressionLevel.Low, + ArchiveCompressionLevels.Fast => SevenZip.CompressionLevel.Fast, + ArchiveCompressionLevels.None => SevenZip.CompressionLevel.None, + _ => throw new ArgumentOutOfRangeException(nameof(CompressionLevel)), + }; + + private long SevenZipVolumeSize => SplittingSize switch + { + ArchiveSplittingSizes.None => 0L, + ArchiveSplittingSizes.Mo10 => 10 * 1000 * 1000L, + ArchiveSplittingSizes.Mo100 => 100 * 1000 * 1000L, + ArchiveSplittingSizes.Mo1024 => 1024 * 1000 * 1000L, + ArchiveSplittingSizes.Mo2048 => 2048 * 1000 * 1000L, + ArchiveSplittingSizes.Mo5120 => 5120 * 1000 * 1000L, + ArchiveSplittingSizes.Fat4092 => 4092 * 1000 * 1000L, + ArchiveSplittingSizes.Cd650 => 650 * 1000 * 1000L, + ArchiveSplittingSizes.Cd700 => 700 * 1000 * 1000L, + ArchiveSplittingSizes.Dvd4480 => 4480 * 1000 * 1000L, + ArchiveSplittingSizes.Dvd8128 => 8128 * 1000 * 1000L, + ArchiveSplittingSizes.Bd23040 => 23040 * 1000 * 1000L, + _ => throw new ArgumentOutOfRangeException(nameof(SplittingSize)), + }; + + private IProgress? _Progress; + public IProgress? Progress + { + get => _Progress; + set + { + _Progress = value; + _fileSystemProgress = new( + value, + false, + FileSystemStatusCode.InProgress); + + _fileSystemProgress.Report(0); + } + } + + /// + public string ArchivePath { get; set; } + + /// + public string Directory { get; init; } + + /// + public string FileName { get; init; } + + /// + public string Password { get; init; } + + /// + public IEnumerable Sources { get; init; } + + /// + public ArchiveFormats FileFormat { get; init; } + + /// + public ArchiveCompressionLevels CompressionLevel { get; init; } + + /// + public ArchiveSplittingSizes SplittingSize { get; init; } + + /// + public ArchiveDictionarySizes DictionarySize { get; init; } + + /// + public ArchiveWordSizes WordSize { get; init; } + + /// + public CancellationToken CancellationToken { get; set; } + + /// + public bool IsCancelled { get; private set; } + + /// + public int CPUThreads { get; set; } + + public CompressArchiveModel( + string[] source, + string directory, + string fileName, + int cpuThreads, + string? password = null, + ArchiveFormats fileFormat = ArchiveFormats.Zip, + ArchiveCompressionLevels compressionLevel = ArchiveCompressionLevels.Normal, + ArchiveSplittingSizes splittingSize = ArchiveSplittingSizes.None, + ArchiveDictionarySizes dictionarySize = ArchiveDictionarySizes.Auto, + ArchiveWordSizes wordSize = ArchiveWordSizes.Auto) + { + _Progress = new Progress(); + + Sources = source; + Directory = directory; + FileName = fileName; + Password = password ?? string.Empty; + ArchivePath = string.Empty; + FileFormat = fileFormat; + CompressionLevel = compressionLevel; + SplittingSize = splittingSize; + DictionarySize = dictionarySize; + WordSize = wordSize; + CPUThreads = cpuThreads; + } + + /// + public string GetArchivePath(string suffix = "") + { + return Path.Combine(Directory, $"{FileName}{suffix}{ArchiveExtension}"); + } + + /// + public async Task RunCreationAsync() + { + if (_fileSystemProgress is null) + throw new InvalidOperationException("Compression progress must be initialized before archive creation starts."); + + string[] sources = Sources.ToArray(); + + var compressor = new SevenZipCompressor() + { + ArchiveFormat = SevenZipArchiveFormat, + CompressionLevel = SevenZipCompressionLevel, + VolumeSize = FileFormat is ArchiveFormats.SevenZip ? SevenZipVolumeSize : 0, + FastCompression = false, + IncludeEmptyDirectories = true, + EncryptHeaders = true, + PreserveDirectoryRoot = sources.Length > 1, + }; + + compressor.CustomParameters.Add("mt", CPUThreads.ToString()); + // Use UTF-8 encoding. + // References: 7-zip chm --> Command Line Version --> Switches + // --> -m --> cu=[off | on]. + // Don't add "cu" parameter for 7zip files, see https://github.com/files-community/Files/issues/17257 + if (FileFormat != ArchiveFormats.SevenZip) + compressor.CustomParameters.Add("cu", "on"); + + if (FileFormat is ArchiveFormats.SevenZip) + { + var dictParam = GetDictionarySizeParam(); + if (dictParam is not null) + compressor.CustomParameters.Add("d", dictParam); + + var wordParam = GetWordSizeParam(); + if (wordParam is not null) + compressor.CustomParameters.Add("fb", wordParam); + } + + compressor.Compressing += Compressor_Compressing; + compressor.FileCompressionStarted += Compressor_FileCompressionStarted; + compressor.FileCompressionFinished += Compressor_FileCompressionFinished; + + var cts = new CancellationTokenSource(); + + try + { + var files = sources.Where(File.Exists).ToArray(); + var directories = sources.Where(SystemIO.Directory.Exists); + var skippedItems = new List(); + + _sizeCalculator = new FileSizeCalculator([.. files, .. directories]); + var sizeTask = _sizeCalculator.ComputeSizeAsync(cts.Token); + _ = sizeTask.ContinueWith(_ => + { + _fileSystemProgress.TotalSize = _sizeCalculator.Size; + _fileSystemProgress.ItemsCount = _sizeCalculator.ItemsCount; + _fileSystemProgress.EnumerationCompleted = true; + _fileSystemProgress.Report(); + }); + + // Enumerate the sources ourselves, skipping items that cannot be + // archived (e.g. broken junctions or locked files), see #16240 + var directoryItems = new List<(string Path, List ArchivableItems)>(); + var archivableFiles = new List(); + + await Task.Run(() => + { + foreach (string directory in directories) + { + var directoryPath = Path.GetFullPath(directory); + var archivableItems = new List(); + + AddArchivableItems(directoryPath, archivableItems, skippedItems); + directoryItems.Add((directoryPath, archivableItems)); + } + + foreach (var file in files) + { + if (CanOpenFile(file)) + archivableFiles.Add(file); + else + skippedItems.Add(file); + } + }); + + if (skippedItems.Count > 0) + { + var logger = Ioc.Default.GetRequiredService>(); + logger?.LogWarning($"Skipped {skippedItems.Count} item(s) that could not be archived to {LogPathHelper.RedactPath(ArchivePath)}: {string.Join(", ", skippedItems.Select(LogPathHelper.RedactPath))}"); + + // Ask the user whether to skip the items or cancel the operation, see #16240 + var dialogService = Ioc.Default.GetRequiredService(); + var dialogResult = await dialogService.ShowDialogAsync(new CompressSkippedItemsDialogViewModel(skippedItems)); + + if (dialogResult is not DialogResult.Primary) + { + IsCancelled = true; + cts.Cancel(); + + return false; + } + } + + foreach ((string directoryPath, List archivableItems) in directoryItems) + { + if (archivableItems.Any(File.Exists)) + { + var commonRootLength = GetCommonRootLength(directoryPath, sources.Length > 1); + + if (string.IsNullOrEmpty(Password)) + await compressor.CompressFilesAsync(ArchivePath, commonRootLength, [.. archivableItems]); + else + await compressor.CompressFilesEncryptedAsync(ArchivePath, commonRootLength, Password, [.. archivableItems]); + } + else + { + // The directory has no files, so we need to create entries manually + var fileDictionary = new Dictionary(); + AddEntry(fileDictionary, directoryPath, ""); + + compressor.CompressFileDictionary(fileDictionary, ArchivePath, Password); + + static void AddEntry(IDictionary fileDictionary, string directory, string entryPrefix) + { + DirectoryInfo directoryInfo = new DirectoryInfo(directory); + + DirectoryInfo[] directories; + + try + { + directories = directoryInfo.GetDirectories(); + } + catch (Exception) + { + // The directory contents are inaccessible (e.g. a broken junction), skip it + return; + } + + if (directories.Length == 0) + { + AddArchiveEntry(fileDictionary, entryPrefix + directoryInfo.Name, null); + } + else + { + entryPrefix += directoryInfo.Name + Path.DirectorySeparatorChar; + foreach (DirectoryInfo directoryInfo2 in directories) + AddEntry(fileDictionary, directoryInfo2.FullName, entryPrefix); + } + } + + // SevenZipSharp uses a null source path to represent an empty directory. + static void AddArchiveEntry(IDictionary entries, string name, string? sourcePath) + => entries.Add(name, sourcePath!); + } + + compressor.CompressionMode = CompressionMode.Append; + } + + if (archivableFiles.Count > 0) + { + if (string.IsNullOrEmpty(Password)) + await compressor.CompressFilesAsync(ArchivePath, [.. archivableFiles]); + else + await compressor.CompressFilesEncryptedAsync(ArchivePath, Password, [.. archivableFiles]); + } + + cts.Cancel(); + + return true; + } + catch (Exception ex) + { + var logger = Ioc.Default.GetRequiredService>(); + logger?.LogWarning(ex, $"Error compressing folder: {LogPathHelper.RedactPath(ArchivePath)}"); + + cts.Cancel(); + + return false; + } + } + + /// + /// Recursively collects the items of a directory that can be archived, + /// skipping items that cannot be read (e.g. broken junctions or locked files). + /// + private void AddArchivableItems(string directory, List items, List skippedItems) + { + CancellationToken.ThrowIfCancellationRequested(); + + FileSystemInfo[] children; + + try + { + children = new DirectoryInfo(directory).GetFileSystemInfos(); + } + catch (Exception) + { + // The directory contents are inaccessible (e.g. a broken junction or access denied) + skippedItems.Add(directory); + return; + } + + foreach (var child in children) + { + if (child is DirectoryInfo) + { + // Add the directory entry itself so empty directories are preserved + items.Add(child.FullName); + AddArchivableItems(child.FullName, items, skippedItems); + } + else if (CanOpenFile(child.FullName)) + { + items.Add(child.FullName); + } + else + { + skippedItems.Add(child.FullName); + } + } + } + + private static bool CanOpenFile(string path) + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + return true; + } + catch (Exception) + { + return false; + } + } + + private static int GetCommonRootLength(string directoryPath, bool preserveRootEntry) + { + // Entries are relative to the directory itself unless the root should be preserved + var trimmedPath = directoryPath.TrimEnd(Path.DirectorySeparatorChar); + var root = preserveRootEntry ? Path.GetDirectoryName(trimmedPath) : trimmedPath; + + return string.IsNullOrEmpty(root) + ? trimmedPath.Length + 1 + : root.TrimEnd(Path.DirectorySeparatorChar).Length + 1; + } + + private void Compressor_FileCompressionStarted(object? sender, FileNameEventArgs e) + { + if (CancellationToken.IsCancellationRequested) + { + e.Cancel = true; + return; + } + + _sizeCalculator!.ForceComputeFileSize(e.FilePath); + _fileSystemProgress!.FileName = e.FileName; + _fileSystemProgress.Report(); + } + + private void Compressor_FileCompressionFinished(object? sender, EventArgs e) + { + _fileSystemProgress!.AddProcessedItemsCount(1); + _fileSystemProgress.Report(); + } + + private void Compressor_Compressing(object? _, ProgressEventArgs e) + { + if (_fileSystemProgress!.TotalSize > 0) + _fileSystemProgress.Report((_fileSystemProgress.ProcessedSize + e.PercentDelta / 100.0 * e.BytesCount) / _fileSystemProgress.TotalSize * 100); + } + + private string? GetDictionarySizeParam() => DictionarySize switch + { + ArchiveDictionarySizes.Auto => null, + ArchiveDictionarySizes.Kb64 => "64k", + ArchiveDictionarySizes.Kb256 => "256k", + ArchiveDictionarySizes.Mb1 => "1m", + ArchiveDictionarySizes.Mb2 => "2m", + ArchiveDictionarySizes.Mb4 => "4m", + ArchiveDictionarySizes.Mb8 => "8m", + ArchiveDictionarySizes.Mb16 => "16m", + ArchiveDictionarySizes.Mb32 => "32m", + ArchiveDictionarySizes.Mb64 => "64m", + ArchiveDictionarySizes.Mb128 => "128m", + ArchiveDictionarySizes.Mb256 => "256m", + ArchiveDictionarySizes.Mb512 => "512m", + ArchiveDictionarySizes.Mb1024 => "1024m", + _ => null, + }; + + private string? GetWordSizeParam() => WordSize switch + { + ArchiveWordSizes.Auto => null, + ArchiveWordSizes.Fb8 => "8", + ArchiveWordSizes.Fb16 => "16", + ArchiveWordSizes.Fb32 => "32", + ArchiveWordSizes.Fb64 => "64", + ArchiveWordSizes.Fb128 => "128", + ArchiveWordSizes.Fb256 => "256", + ArchiveWordSizes.Fb273 => "273", + _ => null, + }; + } +} diff --git a/src/Files.App/Data/Models/CompressArchiveModel.cs b/src/Files.App/Data/Models/CompressArchiveModel.cs index 303a24807ede..8c7c579bd07c 100644 --- a/src/Files.App/Data/Models/CompressArchiveModel.cs +++ b/src/Files.App/Data/Models/CompressArchiveModel.cs @@ -1,9 +1,8 @@ ο»Ώ// Copyright (c) Files Community // Licensed under the MIT License. -using Files.App.Utils.Storage.Operations; -using Microsoft.Extensions.Logging; -using SevenZip; +#if !WINDOWS +using Files.Platform.Abstractions.Archives; using System.IO; namespace Files.App.Data.Models @@ -15,54 +14,6 @@ public sealed class CompressArchiveModel : ICompressArchiveModel { private StatusCenterItemProgressModel? _fileSystemProgress; - private FileSizeCalculator? _sizeCalculator; - - private string ArchiveExtension => FileFormat switch - { - ArchiveFormats.Zip => ".zip", - ArchiveFormats.SevenZip => ".7z", - ArchiveFormats.Tar => ".tar", - ArchiveFormats.GZip => ".gz", - _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), - }; - - private OutArchiveFormat SevenZipArchiveFormat => FileFormat switch - { - ArchiveFormats.Zip => OutArchiveFormat.Zip, - ArchiveFormats.SevenZip => OutArchiveFormat.SevenZip, - ArchiveFormats.Tar => OutArchiveFormat.Tar, - ArchiveFormats.GZip => OutArchiveFormat.GZip, - _ => throw new ArgumentOutOfRangeException(nameof(FileFormat)), - }; - - private CompressionLevel SevenZipCompressionLevel => CompressionLevel switch - { - ArchiveCompressionLevels.Ultra => SevenZip.CompressionLevel.Ultra, - ArchiveCompressionLevels.High => SevenZip.CompressionLevel.High, - ArchiveCompressionLevels.Normal => SevenZip.CompressionLevel.Normal, - ArchiveCompressionLevels.Low => SevenZip.CompressionLevel.Low, - ArchiveCompressionLevels.Fast => SevenZip.CompressionLevel.Fast, - ArchiveCompressionLevels.None => SevenZip.CompressionLevel.None, - _ => throw new ArgumentOutOfRangeException(nameof(CompressionLevel)), - }; - - private long SevenZipVolumeSize => SplittingSize switch - { - ArchiveSplittingSizes.None => 0L, - ArchiveSplittingSizes.Mo10 => 10 * 1000 * 1000L, - ArchiveSplittingSizes.Mo100 => 100 * 1000 * 1000L, - ArchiveSplittingSizes.Mo1024 => 1024 * 1000 * 1000L, - ArchiveSplittingSizes.Mo2048 => 2048 * 1000 * 1000L, - ArchiveSplittingSizes.Mo5120 => 5120 * 1000 * 1000L, - ArchiveSplittingSizes.Fat4092 => 4092 * 1000 * 1000L, - ArchiveSplittingSizes.Cd650 => 650 * 1000 * 1000L, - ArchiveSplittingSizes.Cd700 => 700 * 1000 * 1000L, - ArchiveSplittingSizes.Dvd4480 => 4480 * 1000 * 1000L, - ArchiveSplittingSizes.Dvd8128 => 8128 * 1000 * 1000L, - ArchiveSplittingSizes.Bd23040 => 23040 * 1000 * 1000L, - _ => throw new ArgumentOutOfRangeException(nameof(SplittingSize)), - }; - private IProgress? _Progress; public IProgress? Progress { @@ -151,307 +102,61 @@ public string GetArchivePath(string suffix = "") return Path.Combine(Directory, $"{FileName}{suffix}{ArchiveExtension}"); } - /// public async Task RunCreationAsync() { - if (_fileSystemProgress is null) - throw new InvalidOperationException("Compression progress must be initialized before archive creation starts."); - - string[] sources = Sources.ToArray(); - - var compressor = new SevenZipCompressor() + // LINUX-TODO(archives): encrypted creation, split volumes and custom 7z tuning need backend support. + if (!string.IsNullOrEmpty(Password) || SplittingSize != ArchiveSplittingSizes.None || DictionarySize != ArchiveDictionarySizes.Auto || WordSize != ArchiveWordSizes.Auto) + throw new NotSupportedException(); + var service = Ioc.Default.GetRequiredService(); + var format = FileFormat switch { - ArchiveFormat = SevenZipArchiveFormat, - CompressionLevel = SevenZipCompressionLevel, - VolumeSize = FileFormat is ArchiveFormats.SevenZip ? SevenZipVolumeSize : 0, - FastCompression = false, - IncludeEmptyDirectories = true, - EncryptHeaders = true, - PreserveDirectoryRoot = sources.Length > 1, + ArchiveFormats.Zip => ArchiveFormat.Zip, + ArchiveFormats.SevenZip => ArchiveFormat.SevenZip, + ArchiveFormats.Tar => ArchiveFormat.Tar, + ArchiveFormats.GZip => ArchiveFormat.TarGz, + _ => throw new NotSupportedException(), }; - - compressor.CustomParameters.Add("mt", CPUThreads.ToString()); - // Use UTF-8 encoding. - // References: 7-zip chm --> Command Line Version --> Switches - // --> -m --> cu=[off | on]. - // Don't add "cu" parameter for 7zip files, see https://github.com/files-community/Files/issues/17257 - if (FileFormat != ArchiveFormats.SevenZip) - compressor.CustomParameters.Add("cu", "on"); - - if (FileFormat is ArchiveFormats.SevenZip) - { - var dictParam = GetDictionarySizeParam(); - if (dictParam is not null) - compressor.CustomParameters.Add("d", dictParam); - - var wordParam = GetWordSizeParam(); - if (wordParam is not null) - compressor.CustomParameters.Add("fb", wordParam); - } - - compressor.Compressing += Compressor_Compressing; - compressor.FileCompressionStarted += Compressor_FileCompressionStarted; - compressor.FileCompressionFinished += Compressor_FileCompressionFinished; - - var cts = new CancellationTokenSource(); - - try + if (string.IsNullOrEmpty(ArchivePath)) + ArchivePath = GetArchivePath(); + var result = await service.CreateAsync(Sources.ToArray(), ArchivePath, new ArchiveCreateOptions { - var files = sources.Where(File.Exists).ToArray(); - var directories = sources.Where(SystemIO.Directory.Exists); - var skippedItems = new List(); - - _sizeCalculator = new FileSizeCalculator([.. files, .. directories]); - var sizeTask = _sizeCalculator.ComputeSizeAsync(cts.Token); - _ = sizeTask.ContinueWith(_ => + Format = format, + Progress = new Progress(progress => { - _fileSystemProgress.TotalSize = _sizeCalculator.Size; - _fileSystemProgress.ItemsCount = _sizeCalculator.ItemsCount; + if (_fileSystemProgress is null) + return; + _fileSystemProgress.FileName = Files.Shared.Helpers.ArchiveDisplayName.Escape(progress.CurrentEntry ?? string.Empty); + _fileSystemProgress.ItemsCount = progress.EntriesTotal; + _fileSystemProgress.AddProcessedItemsCount(Math.Max(0, progress.EntriesProcessed - _fileSystemProgress.ProcessedItemsCount)); + _fileSystemProgress.TotalSize = progress.BytesTotal; + _fileSystemProgress.SetProcessedSize(progress.BytesProcessed); _fileSystemProgress.EnumerationCompleted = true; _fileSystemProgress.Report(); - }); - - // Enumerate the sources ourselves, skipping items that cannot be - // archived (e.g. broken junctions or locked files), see #16240 - var directoryItems = new List<(string Path, List ArchivableItems)>(); - var archivableFiles = new List(); - - await Task.Run(() => - { - foreach (string directory in directories) - { - var directoryPath = Path.GetFullPath(directory); - var archivableItems = new List(); - - AddArchivableItems(directoryPath, archivableItems, skippedItems); - directoryItems.Add((directoryPath, archivableItems)); - } - - foreach (var file in files) - { - if (CanOpenFile(file)) - archivableFiles.Add(file); - else - skippedItems.Add(file); - } - }); - - if (skippedItems.Count > 0) - { - var logger = Ioc.Default.GetRequiredService>(); - logger?.LogWarning($"Skipped {skippedItems.Count} item(s) that could not be archived to {LogPathHelper.RedactPath(ArchivePath)}: {string.Join(", ", skippedItems.Select(LogPathHelper.RedactPath))}"); - - // Ask the user whether to skip the items or cancel the operation, see #16240 - var dialogService = Ioc.Default.GetRequiredService(); - var dialogResult = await dialogService.ShowDialogAsync(new CompressSkippedItemsDialogViewModel(skippedItems)); - - if (dialogResult is not DialogResult.Primary) - { - IsCancelled = true; - cts.Cancel(); - - return false; - } - } - - foreach ((string directoryPath, List archivableItems) in directoryItems) - { - if (archivableItems.Any(File.Exists)) - { - var commonRootLength = GetCommonRootLength(directoryPath, sources.Length > 1); - - if (string.IsNullOrEmpty(Password)) - await compressor.CompressFilesAsync(ArchivePath, commonRootLength, [.. archivableItems]); - else - await compressor.CompressFilesEncryptedAsync(ArchivePath, commonRootLength, Password, [.. archivableItems]); - } - else - { - // The directory has no files, so we need to create entries manually - var fileDictionary = new Dictionary(); - AddEntry(fileDictionary, directoryPath, ""); - - compressor.CompressFileDictionary(fileDictionary, ArchivePath, Password); - - static void AddEntry(IDictionary fileDictionary, string directory, string entryPrefix) - { - DirectoryInfo directoryInfo = new DirectoryInfo(directory); - - DirectoryInfo[] directories; - - try - { - directories = directoryInfo.GetDirectories(); - } - catch (Exception) - { - // The directory contents are inaccessible (e.g. a broken junction), skip it - return; - } - - if (directories.Length == 0) - { - AddArchiveEntry(fileDictionary, entryPrefix + directoryInfo.Name, null); - } - else - { - entryPrefix += directoryInfo.Name + Path.DirectorySeparatorChar; - foreach (DirectoryInfo directoryInfo2 in directories) - AddEntry(fileDictionary, directoryInfo2.FullName, entryPrefix); - } - } - - // SevenZipSharp uses a null source path to represent an empty directory. - static void AddArchiveEntry(IDictionary entries, string name, string? sourcePath) - => entries.Add(name, sourcePath!); - } - - compressor.CompressionMode = CompressionMode.Append; - } - - if (archivableFiles.Count > 0) - { - if (string.IsNullOrEmpty(Password)) - await compressor.CompressFilesAsync(ArchivePath, [.. archivableFiles]); - else - await compressor.CompressFilesEncryptedAsync(ArchivePath, Password, [.. archivableFiles]); - } - - cts.Cancel(); - - return true; - } - catch (Exception ex) - { - var logger = Ioc.Default.GetRequiredService>(); - logger?.LogWarning(ex, $"Error compressing folder: {LogPathHelper.RedactPath(ArchivePath)}"); - - cts.Cancel(); - - return false; - } - } - - /// - /// Recursively collects the items of a directory that can be archived, - /// skipping items that cannot be read (e.g. broken junctions or locked files). - /// - private void AddArchivableItems(string directory, List items, List skippedItems) - { - CancellationToken.ThrowIfCancellationRequested(); - - FileSystemInfo[] children; - - try - { - children = new DirectoryInfo(directory).GetFileSystemInfos(); - } - catch (Exception) - { - // The directory contents are inaccessible (e.g. a broken junction or access denied) - skippedItems.Add(directory); - return; - } - - foreach (var child in children) - { - if (child is DirectoryInfo) + }), + Level = CompressionLevel switch { - // Add the directory entry itself so empty directories are preserved - items.Add(child.FullName); - AddArchivableItems(child.FullName, items, skippedItems); - } - else if (CanOpenFile(child.FullName)) - { - items.Add(child.FullName); - } - else - { - skippedItems.Add(child.FullName); - } - } - } - - private static bool CanOpenFile(string path) - { - try - { - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - - return true; - } - catch (Exception) - { - return false; - } - } - - private static int GetCommonRootLength(string directoryPath, bool preserveRootEntry) - { - // Entries are relative to the directory itself unless the root should be preserved - var trimmedPath = directoryPath.TrimEnd(Path.DirectorySeparatorChar); - var root = preserveRootEntry ? Path.GetDirectoryName(trimmedPath) : trimmedPath; - - return string.IsNullOrEmpty(root) - ? trimmedPath.Length + 1 - : root.TrimEnd(Path.DirectorySeparatorChar).Length + 1; - } - - private void Compressor_FileCompressionStarted(object? sender, FileNameEventArgs e) - { - if (CancellationToken.IsCancellationRequested) - { - e.Cancel = true; - return; - } - - _sizeCalculator!.ForceComputeFileSize(e.FilePath); - _fileSystemProgress!.FileName = e.FileName; - _fileSystemProgress.Report(); - } - - private void Compressor_FileCompressionFinished(object? sender, EventArgs e) - { - _fileSystemProgress!.AddProcessedItemsCount(1); - _fileSystemProgress.Report(); + ArchiveCompressionLevels.None => ArchiveCompressionLevel.None, + ArchiveCompressionLevels.Fast or ArchiveCompressionLevels.Low => ArchiveCompressionLevel.Fast, + ArchiveCompressionLevels.High => ArchiveCompressionLevel.High, + ArchiveCompressionLevels.Ultra => ArchiveCompressionLevel.Ultra, + _ => ArchiveCompressionLevel.Normal, + }, + }, CancellationToken); + IsCancelled = result.Cancelled; + if (result.Succeeded) + _fileSystemProgress?.Report(100); + return result.Succeeded; } - private void Compressor_Compressing(object? _, ProgressEventArgs e) - { - if (_fileSystemProgress!.TotalSize > 0) - _fileSystemProgress.Report((_fileSystemProgress.ProcessedSize + e.PercentDelta / 100.0 * e.BytesCount) / _fileSystemProgress.TotalSize * 100); - } - - private string? GetDictionarySizeParam() => DictionarySize switch - { - ArchiveDictionarySizes.Auto => null, - ArchiveDictionarySizes.Kb64 => "64k", - ArchiveDictionarySizes.Kb256 => "256k", - ArchiveDictionarySizes.Mb1 => "1m", - ArchiveDictionarySizes.Mb2 => "2m", - ArchiveDictionarySizes.Mb4 => "4m", - ArchiveDictionarySizes.Mb8 => "8m", - ArchiveDictionarySizes.Mb16 => "16m", - ArchiveDictionarySizes.Mb32 => "32m", - ArchiveDictionarySizes.Mb64 => "64m", - ArchiveDictionarySizes.Mb128 => "128m", - ArchiveDictionarySizes.Mb256 => "256m", - ArchiveDictionarySizes.Mb512 => "512m", - ArchiveDictionarySizes.Mb1024 => "1024m", - _ => null, - }; - - private string? GetWordSizeParam() => WordSize switch + private string ArchiveExtension => FileFormat switch { - ArchiveWordSizes.Auto => null, - ArchiveWordSizes.Fb8 => "8", - ArchiveWordSizes.Fb16 => "16", - ArchiveWordSizes.Fb32 => "32", - ArchiveWordSizes.Fb64 => "64", - ArchiveWordSizes.Fb128 => "128", - ArchiveWordSizes.Fb256 => "256", - ArchiveWordSizes.Fb273 => "273", - _ => null, + ArchiveFormats.Zip => ".zip", + ArchiveFormats.SevenZip => ".7z", + ArchiveFormats.Tar => ".tar", + ArchiveFormats.GZip => ".tar.gz", + _ => throw new NotSupportedException(), }; } } + +#endif diff --git a/src/Files.App/Data/Models/PinnedFoldersManager.cs b/src/Files.App/Data/Models/PinnedFoldersManager.cs index db181e14bd98..dd4256f21f7b 100644 --- a/src/Files.App/Data/Models/PinnedFoldersManager.cs +++ b/src/Files.App/Data/Models/PinnedFoldersManager.cs @@ -138,12 +138,17 @@ public async Task CreateLocationItemFromPathAsync(string path) private static string? GetLibraryDisplayName(string libraryPath) { +#if !WINDOWS + // Windows libraries (.library-ms) don't exist on Linux + return null; +#else using var storable = WindowsStorable.TryParse(libraryPath); if (storable is null) return null; var displayName = storable.GetDisplayName(SIGDN.SIGDN_NORMALDISPLAY); return string.IsNullOrEmpty(displayName) ? null : displayName; +#endif } private async Task LoadIconForLocationItemAsync(LocationItem locationItem, string path, bool isFolder = true) diff --git a/src/Files.App/Data/Models/RemovableDevice.cs b/src/Files.App/Data/Models/RemovableDevice.Windows.cs similarity index 100% rename from src/Files.App/Data/Models/RemovableDevice.cs rename to src/Files.App/Data/Models/RemovableDevice.Windows.cs diff --git a/src/Files.App/Extensions/ShellNewEntryExtensions.cs b/src/Files.App/Extensions/ShellNewEntryExtensions.cs index b29a3badb133..77eca697b6bc 100644 --- a/src/Files.App/Extensions/ShellNewEntryExtensions.cs +++ b/src/Files.App/Extensions/ShellNewEntryExtensions.cs @@ -8,6 +8,7 @@ namespace Files.App.Extensions { public static class ShellNewEntryExtensions { +#if WINDOWS || FILES_WIN32_COMPAT public static async Task> GetNewContextMenuEntries() { var shellEntryList = new List(); @@ -25,6 +26,7 @@ public static async Task> GetNewContextMenuEntries() { return await SafetyExtensions.IgnoreExceptions(() => ShellNewMenuHelper.GetNewContextMenuEntryForType(extension), App.Logger); } +#endif public static async Task> Create(this ShellNewEntry shellEntry, string filePath, IShellPage associatedInstance) { diff --git a/src/Files.App/Extensions/ShowWindowCommandExtensions.Windows.cs b/src/Files.App/Extensions/ShowWindowCommandExtensions.Windows.cs deleted file mode 100644 index 351f32a7c4cc..000000000000 --- a/src/Files.App/Extensions/ShowWindowCommandExtensions.Windows.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Files Community -// Licensed under the MIT License. - -using Windows.Win32.UI.WindowsAndMessaging; - -namespace Files.App.Extensions -{ - /// - /// Converts between and the Win32 . - /// - public static class ShowWindowCommandExtensions - { - /// - /// Converts a Win32 show command to a ; unknown values map to . - /// - public static ShowWindowCommand ToShowWindowCommand(this SHOW_WINDOW_CMD command) - { - return command switch - { - SHOW_WINDOW_CMD.SW_MAXIMIZE => ShowWindowCommand.Maximized, - SHOW_WINDOW_CMD.SW_SHOWMINIMIZED or - SHOW_WINDOW_CMD.SW_MINIMIZE or - SHOW_WINDOW_CMD.SW_SHOWMINNOACTIVE or - SHOW_WINDOW_CMD.SW_FORCEMINIMIZE => ShowWindowCommand.Minimized, - _ => ShowWindowCommand.Normal, - }; - } - - /// - /// Converts a to the Win32 show command stored in shortcuts; - /// maps to . - /// - public static SHOW_WINDOW_CMD ToWin32(this ShowWindowCommand command) - { - return command switch - { - ShowWindowCommand.Maximized => SHOW_WINDOW_CMD.SW_MAXIMIZE, - ShowWindowCommand.Minimized => SHOW_WINDOW_CMD.SW_SHOWMINNOACTIVE, - _ => SHOW_WINDOW_CMD.SW_NORMAL, - }; - } - } -} diff --git a/src/Files.App/Extensions/ShowWindowCommandExtensions.cs b/src/Files.App/Extensions/ShowWindowCommandExtensions.cs new file mode 100644 index 000000000000..67fe7c4941a0 --- /dev/null +++ b/src/Files.App/Extensions/ShowWindowCommandExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +namespace Files.App.Extensions +{ + /// + /// Converts between and the Win32 SHOW_WINDOW_CMD values (as ints, so no Win32 types are needed). + /// + public static class ShowWindowCommandExtensions + { + /// + /// Converts a Win32 show command (SHOW_WINDOW_CMD) to a ; unknown values map to . + /// + public static ShowWindowCommand ToShowWindowCommand(this int command) + { + return command switch + { + 3 => ShowWindowCommand.Maximized, + 2 or + 6 or + 7 or + 11 => ShowWindowCommand.Minimized, + _ => ShowWindowCommand.Normal, + }; + } + + /// + /// Converts a to the Win32 show command value stored in shortcuts; + /// maps to SW_NORMAL (1). + /// + public static int ToWin32(this ShowWindowCommand command) + { + return command switch + { + ShowWindowCommand.Maximized => 3, + ShowWindowCommand.Minimized => 7, + _ => 1, + }; + } + } +} diff --git a/src/Files.App/Files.App.csproj b/src/Files.App/Files.App.csproj index 9d86c429d060..9b9b62f4df4f 100644 --- a/src/Files.App/Files.App.csproj +++ b/src/Files.App/Files.App.csproj @@ -59,6 +59,9 @@ true $(DefineConstants);DESKTOP + + true + false @@ -174,6 +177,7 @@ + @@ -241,6 +245,7 @@ + @@ -256,6 +261,7 @@ + @@ -286,6 +292,9 @@ the Windows TFM with Platform=x64, which works on Linux) so Win32-touching code gets past declaration binding and the compiler reports the remaining, Uno-specific errors. Nothing here runs on Linux; replace Win32 usage with Files.Platform interfaces, then drop this switch. Prerequisite: dotnet build src/Files.App.CsWin32 -p:EnableWindowsTargeting=true -p:Platform=x64 --> + + $(DefineConstants);FILES_WIN32_COMPAT + ..\Files.App.CsWin32\bin\x64\$(Configuration)\$(WindowsTargetFramework)\Files.App.CsWin32.dll diff --git a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs index 71f775846d65..55e01bf58ffb 100644 --- a/src/Files.App/Helpers/Application/AppLifecycleHelper.cs +++ b/src/Files.App/Helpers/Application/AppLifecycleHelper.cs @@ -369,7 +369,11 @@ public static IServiceProvider ConfigureHost(AppModel appModel) #endif .AddSingleton() .AddSingleton() +#if WINDOWS .AddSingleton() +#else + .AddSingleton() +#endif .AddSingleton() .AddSingleton() .AddSingleton() @@ -404,7 +408,11 @@ public static IServiceProvider ConfigureHost(AppModel appModel) #endif .AddSingleton() .AddSingleton() +#if WINDOWS .AddSingleton() +#else + .AddSingleton() +#endif .AddSingleton() .AddSingleton() .AddSingleton() @@ -441,6 +449,10 @@ public static IServiceProvider ConfigureHost(AppModel appModel) // Storable path resolution services.AddStorables(); services.AddFtpStorables(); +#if !WINDOWS + Files.App.Storage.Archives.ArchiveServiceCollectionExtensions.AddArchiveStorables(services); + services.AddSingleton(); +#endif // Conditional DI #if WINDOWS diff --git a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs index 2a5fc6eadecb..79372fb96256 100644 --- a/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs +++ b/src/Files.App/Helpers/Navigation/NavigationHelpers.Linux.cs @@ -7,6 +7,7 @@ using Files.Platform.Abstractions.Mime; using Files.Platform.Linux.Launching; using Files.Platform.Linux.Mime; +using Files.Shared.Helpers; using Microsoft.Extensions.Logging; using System.Globalization; using System.IO; @@ -33,6 +34,22 @@ private static async Task OpenPathLinuxAsync(string path, IShellPage assoc return true; } + // Members of an archive being browsed; the archive file itself still opens with its default application + if (FileExtensionHelpers.IsZipPath(path, includeRoot: false)) + { + var resolved = await Ioc.Default.GetRequiredService().TryGetAsync(path); + if (resolved.Item is OwlCore.Storage.IFolder) + { + var foldersSettings = Ioc.Default.GetRequiredService().FoldersSettingsService; + await OpenPath(forceOpenInNewTab, foldersSettings.OpenFoldersInNewTab, path, associatedInstance, selectItems); + 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 (!File.Exists(path)) { await DialogDisplayHelper.ShowDialogAsync(Strings.FileNotFoundDialogTitle.GetLocalizedResource(), Strings.FileNotFoundDialogText.GetLocalizedResource()); @@ -186,6 +203,34 @@ internal static async Task OpenFileLinuxAsync(string path, bool openViaApp return await ExecutePlanAsync(path, await PlanAsync(path)); } + /// + /// 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); + 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; + } + + 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) { var target = plan.Target; 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..d5bd2dc370be 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 RunWithItemsLinuxAsync(executablePath, items.Select(item => item.Path).ToArray()); +#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/Helpers/ShareItemHelpers.Windows.cs b/src/Files.App/Helpers/ShareItemHelpers.Windows.cs new file mode 100644 index 000000000000..5d4ef13f04c3 --- /dev/null +++ b/src/Files.App/Helpers/ShareItemHelpers.Windows.cs @@ -0,0 +1,105 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.UI.Xaml.Controls; +using Windows.ApplicationModel.DataTransfer; +using Windows.Foundation.Metadata; +using Windows.Storage; + +namespace Files.App.Helpers +{ + public static partial class ShareItemHelpers + { + public static async Task ShareItemsAsync(IEnumerable itemsToShare) + { + if (itemsToShare is null) + return; + + var interop = DataTransferManager.As(); + IntPtr result = interop.GetForWindow(MainWindow.Instance.WindowHandle, Win32PInvoke.DataTransferManagerInteropIID); + + var manager = WinRT.MarshalInterface.FromAbi(result); + manager.DataRequested += Manager_DataRequested; + + try + { + interop.ShowShareUIForWindow(MainWindow.Instance.WindowHandle); + } + catch (Exception ex) + { + manager.DataRequested -= Manager_DataRequested; + + var errorDialog = new ContentDialog() + { + Title = Strings.FaildToShareItems.GetLocalizedResource(), + Content = ex.Message, + PrimaryButtonText = Strings.OK.GetLocalizedResource(), + }; + + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + errorDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; + + await errorDialog.TryShowAsync(); + } + + async void Manager_DataRequested(DataTransferManager sender, DataRequestedEventArgs args) + { + sender.DataRequested -= Manager_DataRequested; + + DataRequestDeferral dataRequestDeferral = args.Request.GetDeferral(); + List items = []; + DataRequest dataRequest = args.Request; + + foreach (ListedItem item in itemsToShare) + { + if (item is IShortcutItem shItem) + { + if (shItem.IsLinkItem && !string.IsNullOrEmpty(shItem.TargetPath)) + { + dataRequest.Data.Properties.Title = string.Format(Strings.ShareDialogTitle.GetLocalizedResource(), item.Name); + dataRequest.Data.Properties.Description = Strings.ShareDialogSingleItemDescription.GetLocalizedResource(); + dataRequest.Data.SetWebLink(new Uri(shItem.TargetPath)); + dataRequestDeferral.Complete(); + + return; + } + } + else if (item.PrimaryItemAttribute == StorageItemTypes.Folder && !item.IsArchive) + { + if (await StorageHelpers.ToStorageItem(item.ItemPath!) is BaseStorageFolder folder) + items.Add(folder); + } + else + { + if (await StorageHelpers.ToStorageItem(item.ItemPath!) is BaseStorageFile file) + items.Add(file); + } + } + + if (items.Count == 1) + { + dataRequest.Data.Properties.Title = string.Format(Strings.ShareDialogTitle.GetLocalizedResource(), items.First().Name); + dataRequest.Data.Properties.Description = Strings.ShareDialogSingleItemDescription.GetLocalizedResource(); + } + else if (items.Count == 0) + { + dataRequest.FailWithDisplayText(Strings.ShareDialogFailMessage.GetLocalizedResource()); + dataRequestDeferral.Complete(); + + return; + } + else + { + dataRequest.Data.Properties.Title = string.Format( + Strings.ShareDialogTitleMultipleItems.GetLocalizedResource(), + items.Count, + "ItemsCount.Text".GetLocalizedResource()); + dataRequest.Data.Properties.Description = Strings.ShareDialogMultipleItemsDescription.GetLocalizedResource(); + } + + dataRequest.Data.SetStorageItems(items, false); + dataRequestDeferral.Complete(); + } + } + } +} diff --git a/src/Files.App/Helpers/ShareItemHelpers.cs b/src/Files.App/Helpers/ShareItemHelpers.cs index ce8b859dbf11..2cb46e8ff38b 100644 --- a/src/Files.App/Helpers/ShareItemHelpers.cs +++ b/src/Files.App/Helpers/ShareItemHelpers.cs @@ -1,4 +1,4 @@ -ο»Ώ// Copyright (c) Files Community +// Copyright (c) Files Community // Licensed under the MIT License. using Microsoft.UI.Xaml.Controls; @@ -8,7 +8,7 @@ namespace Files.App.Helpers { - public static class ShareItemHelpers + public static partial class ShareItemHelpers { public static bool IsItemShareable(ListedItem item) => !item.IsHiddenItem && @@ -25,98 +25,6 @@ public static bool IsSupported() #if !WINDOWS public static Task ShareItemsAsync(IEnumerable itemsToShare) => Task.CompletedTask; // LINUX-TODO(share): see IsSupported -#else - public static async Task ShareItemsAsync(IEnumerable itemsToShare) - { - if (itemsToShare is null) - return; - - var interop = DataTransferManager.As(); - IntPtr result = interop.GetForWindow(MainWindow.Instance.WindowHandle, Win32PInvoke.DataTransferManagerInteropIID); - - var manager = WinRT.MarshalInterface.FromAbi(result); - manager.DataRequested += Manager_DataRequested; - - try - { - interop.ShowShareUIForWindow(MainWindow.Instance.WindowHandle); - } - catch (Exception ex) - { - manager.DataRequested -= Manager_DataRequested; - - var errorDialog = new ContentDialog() - { - Title = Strings.FaildToShareItems.GetLocalizedResource(), - Content = ex.Message, - PrimaryButtonText = Strings.OK.GetLocalizedResource(), - }; - - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - errorDialog.XamlRoot = MainWindow.Instance.Content.XamlRoot; - - await errorDialog.TryShowAsync(); - } - - async void Manager_DataRequested(DataTransferManager sender, DataRequestedEventArgs args) - { - sender.DataRequested -= Manager_DataRequested; - - DataRequestDeferral dataRequestDeferral = args.Request.GetDeferral(); - List items = []; - DataRequest dataRequest = args.Request; - - foreach (ListedItem item in itemsToShare) - { - if (item is IShortcutItem shItem) - { - if (shItem.IsLinkItem && !string.IsNullOrEmpty(shItem.TargetPath)) - { - dataRequest.Data.Properties.Title = string.Format(Strings.ShareDialogTitle.GetLocalizedResource(), item.Name); - dataRequest.Data.Properties.Description = Strings.ShareDialogSingleItemDescription.GetLocalizedResource(); - dataRequest.Data.SetWebLink(new Uri(shItem.TargetPath)); - dataRequestDeferral.Complete(); - - return; - } - } - else if (item.PrimaryItemAttribute == StorageItemTypes.Folder && !item.IsArchive) - { - if (await StorageHelpers.ToStorageItem(item.ItemPath!) is BaseStorageFolder folder) - items.Add(folder); - } - else - { - if (await StorageHelpers.ToStorageItem(item.ItemPath!) is BaseStorageFile file) - items.Add(file); - } - } - - if (items.Count == 1) - { - dataRequest.Data.Properties.Title = string.Format(Strings.ShareDialogTitle.GetLocalizedResource(), items.First().Name); - dataRequest.Data.Properties.Description = Strings.ShareDialogSingleItemDescription.GetLocalizedResource(); - } - else if (items.Count == 0) - { - dataRequest.FailWithDisplayText(Strings.ShareDialogFailMessage.GetLocalizedResource()); - dataRequestDeferral.Complete(); - - return; - } - else - { - dataRequest.Data.Properties.Title = string.Format( - Strings.ShareDialogTitleMultipleItems.GetLocalizedResource(), - items.Count, - "ItemsCount.Text".GetLocalizedResource()); - dataRequest.Data.Properties.Description = Strings.ShareDialogMultipleItemsDescription.GetLocalizedResource(); - } - - dataRequest.Data.SetStorageItems(items, false); - dataRequestDeferral.Complete(); - } - } #endif } } diff --git a/src/Files.App/Helpers/TransferHelpers.Windows.cs b/src/Files.App/Helpers/TransferHelpers.Windows.cs new file mode 100644 index 000000000000..e3550da46afa --- /dev/null +++ b/src/Files.App/Helpers/TransferHelpers.Windows.cs @@ -0,0 +1,206 @@ +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; +using WinRT; + +namespace Files.App.Helpers +{ + public static partial class TransferHelpers + { + [DynamicWindowsRuntimeCast(typeof(StorageFile))] + [DynamicWindowsRuntimeCast(typeof(StorageFolder))] + public static async Task ExecuteTransferAsync(IReadOnlyList itemsToTransfer, ShellViewModel shellViewModel, StatusCenterViewModel statusViewModel, DataPackageOperation type = DataPackageOperation.Copy) + { + if (OperatingSystem.IsLinux()) + { + // LINUX-TODO(clipboard): W-CLIP replaces the in-app FileClipboard with the system clipboard + FileClipboard.Set(itemsToTransfer.Select(x => x.Id), type); + return; + } + + ConcurrentBag items = []; + var itemsCount = itemsToTransfer.Count; + var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; + var dataPackage = new DataPackage() { RequestedOperation = type }; + + try + { + // Update the status to in-progress + if (statusCenterItem is not null) + { + statusCenterItem.Progress.EnumerationCompleted = true; + statusCenterItem.Progress.ItemsCount = items.Count; + statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); + } + + await itemsToTransfer.ParallelForEachAsync(async storable => + { + // Update the status to increase processed count by one + if (statusCenterItem is not null) + { + statusCenterItem.Progress.AddProcessedItemsCount(1); + statusCenterItem.Progress.Report(); + } + + var result = storable switch + { + IFile => await shellViewModel.GetFileFromPathAsync(storable.Id).OnSuccess(x => items.Add(x + ?? throw new InvalidOperationException($"The file '{storable.Id}' could not be opened."))), + IFolder => await shellViewModel.GetFolderFromPathAsync(storable.Id).OnSuccess(x => items.Add(x + ?? throw new InvalidOperationException($"The folder '{storable.Id}' could not be opened."))), + _ => throw new ArgumentOutOfRangeException(nameof(storable)), + }; + + if (!result) + throw new SystemIO.IOException($"Failed to process {storable.Id} in cutting/copying to the clipboard.", (int)result.ErrorCode); + }, 10, statusCenterItem?.CancellationToken ?? CancellationToken.None); + + var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); + if (standardObjectsOnly) + items = new(await items.ToStandardStorageItemsAsync()); + + if (items.IsEmpty) + return; + + dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; + dataPackage.SetStorageItems(items, false); + + Clipboard.SetContent(dataPackage); + } + catch (Exception ex) + { + if (ex is not SystemIO.IOException) + App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); + + if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) + { + var filePaths = itemsToTransfer.Select(x => x.Id).ToArray(); + await FileOperationsHelpers.SetClipboard(filePaths, type); + } + } + finally + { + if (statusCenterItem is not null) + statusViewModel.RemoveItem(statusCenterItem); + } + } + + [DynamicWindowsRuntimeCast(typeof(StorageFile))] + [DynamicWindowsRuntimeCast(typeof(StorageFolder))] + public static async Task ExecuteTransferAsync(IContentPageContext context, StatusCenterViewModel statusViewModel, DataPackageOperation type = DataPackageOperation.Copy) + { + if (context.ShellPage?.SlimContentPage is not { } contentPage || + contentPage.IsItemSelected is false) + return; + + // Reset cut mode + contentPage.ItemManipulationModel.RefreshItemsOpacity(); + + if (OperatingSystem.IsLinux()) + { + // LINUX-TODO(clipboard): W-CLIP replaces the in-app FileClipboard with the system clipboard + var selected = context.SelectedItems.ToList(); + FileClipboard.Set(selected.Select(x => x.ItemPath), type); + if (type is DataPackageOperation.Move) + { + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => + { + foreach (var item in selected) + item.Opacity = Constants.UI.DimItemOpacity; + }); + } + + return; + } + + ConcurrentBag items = []; + var itemsCount = context.SelectedItems.Count; + var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; + var dataPackage = new DataPackage() { RequestedOperation = type }; + + try + { + // Update the status to in-progress + if (statusCenterItem is not null) + { + statusCenterItem.Progress.EnumerationCompleted = true; + statusCenterItem.Progress.ItemsCount = items.Count; + statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); + } + + await context.SelectedItems.ToList().ParallelForEachAsync(async listedItem => + { + // Update the status to increase processed count by one + if (statusCenterItem is not null) + { + statusCenterItem.Progress.AddProcessedItemsCount(1); + statusCenterItem.Progress.Report(); + } + + if (listedItem is FtpItem ftpItem) + { + // Don't dim selected items here since FTP doesn't support cut + if (ftpItem.PrimaryItemAttribute is StorageItemTypes.File or StorageItemTypes.Folder) + items.Add(await ftpItem.ToStorageItem()); + } + else + { + if (type is DataPackageOperation.Move) + { + // Dim opacities accordingly + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => + { + listedItem.Opacity = Constants.UI.DimItemOpacity; + }); + } + + var shellViewModel = context.ShellPage.GetRequiredShellViewModel(); + var itemPath = listedItem.GetRequiredPath(); + var result = listedItem.PrimaryItemAttribute == StorageItemTypes.File || listedItem is ZipItem + ? await shellViewModel.GetFileFromPathAsync(itemPath).OnSuccess(t => items.Add(t + ?? throw new InvalidOperationException($"The file '{itemPath}' could not be opened."))) + : await shellViewModel.GetFolderFromPathAsync(itemPath).OnSuccess(t => items.Add(t + ?? throw new InvalidOperationException($"The folder '{itemPath}' could not be opened."))); + + if (!result) + throw new SystemIO.IOException($"Failed to process {listedItem.ItemPath} in cutting/copying to the clipboard.", (int)result.ErrorCode); + } + }, 10, statusCenterItem?.CancellationToken ?? CancellationToken.None); + + var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); + if (standardObjectsOnly) + items = new(await items.ToStandardStorageItemsAsync()); + + if (items.IsEmpty) + return; + + dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; + dataPackage.SetStorageItems(items, false); + + Clipboard.SetContent(dataPackage); + } + catch (Exception ex) + { + if (ex is not SystemIO.IOException) + App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); + + if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) + { + var filePaths = context.SelectedItems.Select(x => x.ItemPath!).ToArray(); + await FileOperationsHelpers.SetClipboard(filePaths, type); + + return; + } + + // Reset cut mode + context.ShellPage.SlimContentPage.ItemManipulationModel.RefreshItemsOpacity(); + } + finally + { + if (statusCenterItem is not null) + statusViewModel.RemoveItem(statusCenterItem); + } + } + } +} diff --git a/src/Files.App/Helpers/TransferHelpers.cs b/src/Files.App/Helpers/TransferHelpers.cs index 0906260bc28c..31a2c7147ffb 100644 --- a/src/Files.App/Helpers/TransferHelpers.cs +++ b/src/Files.App/Helpers/TransferHelpers.cs @@ -1,212 +1,53 @@ -ο»Ώusing Microsoft.Extensions.Logging; -using System.Collections.Concurrent; +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if !WINDOWS +using Microsoft.Extensions.Logging; using Windows.ApplicationModel.DataTransfer; -using Windows.Storage; -using WinRT; +using Files.App.Services.Desktop; namespace Files.App.Helpers { - public static class TransferHelpers + public static partial class TransferHelpers { - [DynamicWindowsRuntimeCast(typeof(StorageFile))] - [DynamicWindowsRuntimeCast(typeof(StorageFolder))] public static async Task ExecuteTransferAsync(IReadOnlyList itemsToTransfer, ShellViewModel shellViewModel, StatusCenterViewModel statusViewModel, DataPackageOperation type = DataPackageOperation.Copy) { - if (OperatingSystem.IsLinux()) - { - // LINUX-TODO(clipboard): W-CLIP replaces the in-app FileClipboard with the system clipboard - FileClipboard.Set(itemsToTransfer.Select(x => x.Id), type); - return; - } - - ConcurrentBag items = []; - var itemsCount = itemsToTransfer.Count; - var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; - var dataPackage = new DataPackage() { RequestedOperation = type }; - - try - { - // Update the status to in-progress - if (statusCenterItem is not null) - { - statusCenterItem.Progress.EnumerationCompleted = true; - statusCenterItem.Progress.ItemsCount = items.Count; - statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); - } - - await itemsToTransfer.ParallelForEachAsync(async storable => - { - // Update the status to increase processed count by one - if (statusCenterItem is not null) - { - statusCenterItem.Progress.AddProcessedItemsCount(1); - statusCenterItem.Progress.Report(); - } - - var result = storable switch - { - IFile => await shellViewModel.GetFileFromPathAsync(storable.Id).OnSuccess(x => items.Add(x - ?? throw new InvalidOperationException($"The file '{storable.Id}' could not be opened."))), - IFolder => await shellViewModel.GetFolderFromPathAsync(storable.Id).OnSuccess(x => items.Add(x - ?? throw new InvalidOperationException($"The folder '{storable.Id}' could not be opened."))), - _ => throw new ArgumentOutOfRangeException(nameof(storable)), - }; - - if (!result) - throw new SystemIO.IOException($"Failed to process {storable.Id} in cutting/copying to the clipboard.", (int)result.ErrorCode); - }, 10, statusCenterItem?.CancellationToken ?? CancellationToken.None); - - var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); - if (standardObjectsOnly) - items = new(await items.ToStandardStorageItemsAsync()); - - if (items.IsEmpty) - return; - - dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; - dataPackage.SetStorageItems(items, false); - - Clipboard.SetContent(dataPackage); -#if !WINDOWS - await Files.App.Services.Desktop.DesktopFileDragHelper.PublishFilesAsync(itemsToTransfer.Select(x => x.Id), type); -#endif - } - catch (Exception ex) - { - if (ex is not SystemIO.IOException) - App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); - - if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) - { - var filePaths = itemsToTransfer.Select(x => x.Id).ToArray(); - await FileOperationsHelpers.SetClipboard(filePaths, type); - } - } - finally - { - if (statusCenterItem is not null) - statusViewModel.RemoveItem(statusCenterItem); - } + var paths = itemsToTransfer.Select(x => x.Id).ToArray(); + FileClipboard.Set(paths, type); + await PublishToSystemClipboardAsync(paths, type); } - [DynamicWindowsRuntimeCast(typeof(StorageFile))] - [DynamicWindowsRuntimeCast(typeof(StorageFolder))] public static async Task ExecuteTransferAsync(IContentPageContext context, StatusCenterViewModel statusViewModel, DataPackageOperation type = DataPackageOperation.Copy) { - if (context.ShellPage?.SlimContentPage is not { } contentPage || - contentPage.IsItemSelected is false) + if (context.ShellPage?.SlimContentPage is not { } contentPage || !contentPage.IsItemSelected) return; - // Reset cut mode contentPage.ItemManipulationModel.RefreshItemsOpacity(); - - if (OperatingSystem.IsLinux()) + var selected = context.SelectedItems.ToList(); + var paths = selected.Select(x => x.ItemPath).ToArray(); + FileClipboard.Set(paths, type); + await PublishToSystemClipboardAsync(paths, type); + if (type is DataPackageOperation.Move) { - // LINUX-TODO(clipboard): W-CLIP replaces the in-app FileClipboard with the system clipboard - var selected = context.SelectedItems.ToList(); - FileClipboard.Set(selected.Select(x => x.ItemPath), type); - if (type is DataPackageOperation.Move) + await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => { - await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => - { - foreach (var item in selected) - item.Opacity = Constants.UI.DimItemOpacity; - }); - } - - return; + foreach (var item in selected) + item.Opacity = Constants.UI.DimItemOpacity; + }); } + } - ConcurrentBag items = []; - var itemsCount = context.SelectedItems.Count; - var statusCenterItem = itemsCount > 50 ? StatusCenterHelper.AddCard_Prepare() : null; - var dataPackage = new DataPackage() { RequestedOperation = type }; - + private static async Task PublishToSystemClipboardAsync(string[] paths, DataPackageOperation type) + { try { - // Update the status to in-progress - if (statusCenterItem is not null) - { - statusCenterItem.Progress.EnumerationCompleted = true; - statusCenterItem.Progress.ItemsCount = items.Count; - statusCenterItem.Progress.ReportStatus(FileSystemStatusCode.InProgress); - } - - await context.SelectedItems.ToList().ParallelForEachAsync(async listedItem => - { - // Update the status to increase processed count by one - if (statusCenterItem is not null) - { - statusCenterItem.Progress.AddProcessedItemsCount(1); - statusCenterItem.Progress.Report(); - } - - if (listedItem is FtpItem ftpItem) - { - // Don't dim selected items here since FTP doesn't support cut - if (ftpItem.PrimaryItemAttribute is StorageItemTypes.File or StorageItemTypes.Folder) - items.Add(await ftpItem.ToStorageItem()); - } - else - { - if (type is DataPackageOperation.Move) - { - // Dim opacities accordingly - await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => - { - listedItem.Opacity = Constants.UI.DimItemOpacity; - }); - } - - var shellViewModel = context.ShellPage.GetRequiredShellViewModel(); - var itemPath = listedItem.GetRequiredPath(); - var result = listedItem.PrimaryItemAttribute == StorageItemTypes.File || listedItem is ZipItem - ? await shellViewModel.GetFileFromPathAsync(itemPath).OnSuccess(t => items.Add(t - ?? throw new InvalidOperationException($"The file '{itemPath}' could not be opened."))) - : await shellViewModel.GetFolderFromPathAsync(itemPath).OnSuccess(t => items.Add(t - ?? throw new InvalidOperationException($"The folder '{itemPath}' could not be opened."))); - - if (!result) - throw new SystemIO.IOException($"Failed to process {listedItem.ItemPath} in cutting/copying to the clipboard.", (int)result.ErrorCode); - } - }, 10, statusCenterItem?.CancellationToken ?? CancellationToken.None); - - var standardObjectsOnly = items.All(x => x is StorageFile or StorageFolder or SystemStorageFile or SystemStorageFolder); - if (standardObjectsOnly) - items = new(await items.ToStandardStorageItemsAsync()); - - if (items.IsEmpty) - return; - - dataPackage.Properties.PackageFamilyName = Windows.ApplicationModel.Package.Current.Id.FamilyName; - dataPackage.SetStorageItems(items, false); - - Clipboard.SetContent(dataPackage); -#if !WINDOWS - await Files.App.Services.Desktop.DesktopFileDragHelper.PublishFilesAsync(context.SelectedItems.Select(x => x.ItemPath), type); -#endif + await DesktopFileDragHelper.PublishFilesAsync(paths, type); } catch (Exception ex) { - if (ex is not SystemIO.IOException) - App.Logger.LogWarning(ex, "Failed to process cutting/copying due to an unknown error."); - - if ((FileSystemStatusCode)ex.HResult is FileSystemStatusCode.Unauthorized) - { - var filePaths = context.SelectedItems.Select(x => x.ItemPath!).ToArray(); - await FileOperationsHelpers.SetClipboard(filePaths, type); - - return; - } - - // Reset cut mode - context.ShellPage.SlimContentPage.ItemManipulationModel.RefreshItemsOpacity(); - } - finally - { - if (statusCenterItem is not null) - statusViewModel.RemoveItem(statusCenterItem); + App.Logger.LogWarning(ex, "Failed to publish the copied files to the system clipboard"); } } } } +#endif diff --git a/src/Files.App/Helpers/UI/UIFilesystemHelpers.cs b/src/Files.App/Helpers/UI/UIFilesystemHelpers.cs index 4daa9f5780e3..3b694dacc8a3 100644 --- a/src/Files.App/Helpers/UI/UIFilesystemHelpers.cs +++ b/src/Files.App/Helpers/UI/UIFilesystemHelpers.cs @@ -8,7 +8,6 @@ using System.Text; using Windows.ApplicationModel.DataTransfer; using Windows.Storage; -using Windows.Win32.UI.WindowsAndMessaging; namespace Files.App.Helpers { @@ -330,7 +329,7 @@ public static async Task HandleShortcutCannotBeCreated(string shortcutName /// /// Updates ListedItem properties for a shortcut /// - public static void UpdateShortcutItemProperties(IShortcutItem item, string? targetPath, string? arguments, string? workingDir, bool runAsAdmin, SHOW_WINDOW_CMD showWindowCommand) + public static void UpdateShortcutItemProperties(IShortcutItem item, string? targetPath, string? arguments, string? workingDir, bool runAsAdmin, ShowWindowCommand showWindowCommand) { ArgumentNullException.ThrowIfNull(targetPath); item.TargetPath = Environment.ExpandEnvironmentVariables(targetPath); @@ -347,6 +346,7 @@ public async static Task RequestPassword(IPasswordProtectedIt var credentialDialogViewModel = new CredentialDialogViewModel() { CanBeAnonymous = isFtp, PasswordOnly = !isFtp }; +#if WINDOWS if (sender is ZipStorageFolder zipFolder) { credentialDialogViewModel.PasswordValidator = async (password) => @@ -356,6 +356,7 @@ public async static Task RequestPassword(IPasswordProtectedIt }; } +#endif IDialogService dialogService = Ioc.Default.GetRequiredService(); var dialogResult = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => dialogService.ShowDialogAsync(credentialDialogViewModel)); diff --git a/src/Files.App/Platforms/Desktop/Services/DesktopRecentItemsService.cs b/src/Files.App/Platforms/Desktop/Services/DesktopRecentItemsService.cs index 3c96e30a41d2..a5f01553de96 100644 --- a/src/Files.App/Platforms/Desktop/Services/DesktopRecentItemsService.cs +++ b/src/Files.App/Platforms/Desktop/Services/DesktopRecentItemsService.cs @@ -91,8 +91,6 @@ private bool Update(bool isFolder) Path = entry.Path, Name = string.IsNullOrEmpty(name) ? entry.Path : name, LastModified = entry.LastUsedUtc.ToLocalTime(), - // The Windows shell item is not used on Linux - ShellItem = null!, }); if (items.Count == MaxItems) diff --git a/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs b/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs index 8458104c5de7..1664d6f0ddab 100644 --- a/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs +++ b/src/Files.App/Platforms/Desktop/Services/DesktopStubServices.cs @@ -58,4 +58,35 @@ internal sealed class DesktopJumpListService : IWindowsJumpListService public Task> GetFoldersAsync() => Task.FromResult>([]); } + + // LINUX-TODO(pickers): open file/folder choosers through the xdg-desktop-portal FileChooser; until then pickers report "cancelled" + internal sealed class DesktopCommonDialogService : ICommonDialogService + { + public bool Open_FileOpenDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath, Guid? clientGuid = null) + { + filePath = string.Empty; + return false; + } + + public bool Open_FileSaveDialog(nint hWnd, bool pickFoldersOnly, string[] filters, Environment.SpecialFolder defaultFolder, out string filePath) + { + filePath = string.Empty; + return false; + } + + public bool Open_NetworkConnectionDialog(nint hWnd, bool hideRestoreConnectionCheckBox = false, bool persistConnectionAtLogon = false, bool readOnlyPath = false, string? remoteNetworkName = null, bool useMostRecentPath = false) => false; + } + + // Linux has no Start Menu pins + internal sealed class DesktopStartMenuService : IStartMenuService + { + [Obsolete("Use IsPinnedAsync instead. This method is used for a workaround in ListedItem class to avoid major refactoring.")] + public bool IsPinned(string itemPath) => false; + + public Task IsPinnedAsync(IStorable storable) => Task.FromResult(false); + + public Task PinAsync(IStorable storable, string? displayName = null) => Task.CompletedTask; + + public Task UnpinAsync(IStorable storable) => Task.CompletedTask; + } } diff --git a/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs b/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs index 92867f1daa85..5357ef217b0d 100644 --- a/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs +++ b/src/Files.App/Platforms/Desktop/Services/LinuxIconHelper.cs @@ -21,6 +21,8 @@ namespace Files.App.Utils.Storage internal static class LinuxIconHelper { private static readonly ConcurrentDictionary<(string Name, uint Size), byte[]?> _themeCache = new(); + private static readonly ConcurrentDictionary _mimeIconNames = new(StringComparer.Ordinal); + private const long MimeIconNamesTtlMs = 5000; private static IThumbnailService Thumbnails => Ioc.Default.GetRequiredService(); private static IIconThemeProvider Theme => Ioc.Default.GetRequiredService(); @@ -150,9 +152,14 @@ private static async Task> GetIconNamesAsync(string? path, } var mimeType = await Mime.GetMimeTypeAsync(path); + if (_mimeIconNames.TryGetValue(mimeType, out var cached) && Environment.TickCount64 - cached.At < MimeIconNamesTtlMs) + return cached.Names; + var iconName = await Mime.GetIconNameAsync(mimeType); var generic = await Mime.GetGenericIconNameAsync(mimeType); - return [iconName, generic, "text-x-generic", "unknown"]; + string[] names = [iconName, generic, "text-x-generic", "unknown"]; + _mimeIconNames[mimeType] = (names, Environment.TickCount64); + return names; } private static string? GetSpecialFolderIconName(string? path) diff --git a/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs b/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs index 3db8d1d43ea5..688552bbca31 100644 --- a/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs +++ b/src/Files.App/Platforms/Desktop/ViewModels/ShellViewModel.Linux.cs @@ -1,13 +1,18 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using Files.App.Storage.Archives; +using Files.Core.Storage.Contracts; +using Files.Core.Storage.Enums; using Files.Platform.Abstractions.Enumeration; using Files.Platform.Abstractions.Trash; using Files.Platform.Abstractions.Watching; using Files.Platform.Linux.Enumeration; using Files.Platform.Linux.Mime; using Files.Platform.Linux.Watching; +using Files.Shared.Helpers; using Microsoft.Extensions.Logging; +using OwlCore.Storage; using System.Globalization; using System.IO; using Windows.Storage; @@ -30,6 +35,9 @@ public sealed partial class ShellViewModel private CancellationTokenSource? _linuxChangeCts; private int _linuxChangePending; + // The listed location as a storable; set for archive folders, which have no WinRT equivalent + private StorableWithPath? currentStorable; + /// /// Lists into filesAndFolders. Returns 3 on success and -1 on failure. /// @@ -38,6 +46,10 @@ private async Task EnumerateLinuxFolderAsync(string path, CancellationToken if (path.StartsWith(Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.Ordinal)) return await EnumerateLinuxTrashAsync(path, cancellationToken); + currentStorable = null; + if (FileExtensionHelpers.IsZipPath(path)) + return await EnumerateLinuxArchiveAsync(path, cancellationToken, library); + if (!Directory.Exists(path)) { ShowLocationInaccessibleOrMissing(path); @@ -148,6 +160,127 @@ await Task.Run(async () => return 3; } + /// + /// Lists an archive root or a folder inside an archive through (read-only, no watcher). + /// Returns 5 on success and -1 on failure. + /// + private async Task EnumerateLinuxArchiveAsync(string path, CancellationToken cancellationToken, LibraryItem? library) + { + HasNoWatcher = true; + + StorableResult resolved; + try + { + resolved = await Ioc.Default.GetRequiredService().TryGetAsync(path, cancellationToken); + } + catch (OperationCanceledException) + { + // Not our token: the password prompt was dismissed + if (!cancellationToken.IsCancellationRequested) + ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); + return -1; + } + + if (resolved.Item is not ArchiveFolder folder) + { + if (resolved.Status is StorableStatus.Error) + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); + else + ShowLocationInaccessibleOrMissing(path); + return -1; + } + + currentStorable = StorableWithPath.FromStorable(path, folder); + + var containerPath = FileExtensionHelpers.GetArchiveContainerPath(path) ?? path; + var containerModified = File.GetLastWriteTime(containerPath); + CurrentFolder = library ?? new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemPropertiesInitialized = true, + ItemNameRaw = folder.Name, + ItemDateModifiedReal = containerModified, + ItemDateCreatedReal = containerModified, + ItemType = folderTypeTextLocalized, + FileImage = null, + LoadFileIcon = false, + ItemPath = path, + FileSize = null, + FileSizeBytes = 0, + }; + + var iconCache = Ioc.Default.GetRequiredService(); + var iconSize = GetPreloadIconSize(); + var fileTypeText = Strings.File.GetLocalizedResource(); + + try + { + await Task.Run(async () => + { + var items = new List(); + await foreach (var child in folder.GetItemsAsync(StorableType.All, cancellationToken)) + { + var isFolder = child is IFolder; + var modified = child is ArchiveEntryFile { Entry.Modified: { } entryModified } ? entryModified.ToLocalTime() : containerModified; + var item = new ListedItem(null) + { + PrimaryItemAttribute = isFolder ? StorageItemTypes.Folder : StorageItemTypes.File, + ItemNameRaw = child.Name, + ItemDateModifiedReal = modified, + ItemDateCreatedReal = modified, + FileImage = null, + Opacity = 1d, + LoadFileIcon = false, + ItemPath = child.Id, + FileSize = null, + FileSizeBytes = 0, + ItemType = folderTypeTextLocalized, + }; + + if (child is ArchiveEntryFile file) + { + item.FileExtension = Path.GetExtension(child.Name); + var localizedType = string.IsNullOrEmpty(item.FileExtension) ? null : FileTypesHelper.GetLocalizedTypeName(item.FileExtension); + item.ItemType = !string.IsNullOrEmpty(localizedType) ? localizedType + : string.IsNullOrEmpty(item.FileExtension) ? fileTypeText : item.FileExtension.Trim('.') + " " + fileTypeText; + item.FileSizeBytes = file.Entry.Size; + item.FileSize = file.Entry.Size.ToSizeString(); + } + + try { item.PreloadedIconData = await iconCache.GetIconAsync(item.ItemPath, item.FileExtension, isFolder, iconSize); } + catch (Exception ex) { App.Logger.LogWarning(ex, "Could not load icon for an archive entry"); } + + items.Add(item); + } + + if (cancellationToken.IsCancellationRequested) + return; + + filesAndFolders.AddRange(items); + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + }, cancellationToken); + } + catch (OperationCanceledException) + { + if (!cancellationToken.IsCancellationRequested) + ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); + return -1; + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Could not list an archive"); + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); + return -1; + } + + if (cancellationToken.IsCancellationRequested) + return -1; + + IsLocationUnavailable = false; + return 5; + } + private async Task LoadLinuxRepositoryPropertiesAsync(CancellationToken cancellationToken) { if (!IsValidGitDirectory || EnabledGitProperties is GitProperties.None) @@ -540,6 +673,41 @@ private void ScheduleLinuxChanges(string folder) }); } + /// + /// Brings to with individual remove and insert notifications when only + /// a few items differ and the rest keep their order. Returns false when the caller must rebuild the list instead. + /// + private bool TryApplyIncrementalDisplayChanges(List desired) + { + const int MaxIncrementalChanges = 64; + + if (folderSettings.DirectoryGroupOption != GroupOption.None || FilesAndFolders.Count == 0) + return false; + + var current = FilesAndFolders.ToList(); + var currentSet = new HashSet(current); + var desiredSet = new HashSet(desired); + var removed = current.Where(i => !desiredSet.Contains(i)).ToList(); + var added = desired.Count(i => !currentSet.Contains(i)); + if (removed.Count + added > MaxIncrementalChanges) + return false; + + // Items present on both sides must already be in the same relative order (no sort change) + if (!current.Where(desiredSet.Contains).SequenceEqual(desired.Where(currentSet.Contains))) + return false; + + foreach (var item in removed) + FilesAndFolders.Remove(item); + + for (var i = 0; i < desired.Count; i++) + { + if (!currentSet.Contains(desired[i])) + FilesAndFolders.Insert(i, desired[i]); + } + + return true; + } + /// /// Applies a burst of watcher events as add, remove, rename and update operations on the existing items. Falls back to a /// full reload when events were lost or the burst was too large. diff --git a/src/Files.App/Services/Git/LibGit2Service.cs b/src/Files.App/Services/Git/LibGit2Service.cs index 37670435a13a..5a5c7a5937ab 100644 --- a/src/Files.App/Services/Git/LibGit2Service.cs +++ b/src/Files.App/Services/Git/LibGit2Service.cs @@ -55,7 +55,9 @@ public bool IsExecutingGitAction if (string.IsNullOrWhiteSpace(path) || path.Equals(root, StringComparison.OrdinalIgnoreCase) || path.Equals("Home", StringComparison.OrdinalIgnoreCase) || - ShellStorageFolder.IsShellPath(path)) + path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("::{", StringComparison.Ordinal) || + path.StartsWith(@"\\SHELL\", StringComparison.Ordinal)) { return null; } diff --git a/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs b/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs index f110042cd1a6..7d13aa56974f 100644 --- a/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs +++ b/src/Files.App/Services/PreviewPopupProviders/PreviewPopupService.cs @@ -10,8 +10,10 @@ internal sealed partial class PreviewPopupService : ObservableObject, IPreviewPo { if (await QuickLookProvider.Instance.DetectAvailability()) return await Task.FromResult(QuickLookProvider.Instance); +#if WINDOWS if (await SeerProProvider.Instance.DetectAvailability()) return await Task.FromResult(SeerProProvider.Instance); +#endif if (await PowerToysPeekProvider.Instance.DetectAvailability()) return await Task.FromResult(PowerToysPeekProvider.Instance); else diff --git a/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs b/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs index 188c998e74bd..2334291ec06c 100644 --- a/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs +++ b/src/Files.App/Services/Storage/StorageArchiveService.Desktop.cs @@ -226,21 +226,7 @@ public async Task HasMultipleTopLevelEntriesAsync(string archiveFilePath, { try { - var listing = await ArchiveService.ListAsync(archiveFilePath, string.IsNullOrEmpty(password) ? null : password); - string? first = null; - foreach (var entry in listing.Entries) - { - var segment = entry.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(s => s is not "."); - if (segment is null) - continue; - - if (first is null) - first = segment; - else if (segment != first) - return true; - } - - return false; + return await ArchiveService.HasMultipleTopLevelEntriesAsync(archiveFilePath, string.IsNullOrEmpty(password) ? null : password); } catch (Exception) { diff --git a/src/Files.App/Services/Windows/WindowsQuickAccessService.cs b/src/Files.App/Services/Windows/WindowsQuickAccessService.cs index 173518985fb6..4a479cc0dea1 100644 --- a/src/Files.App/Services/Windows/WindowsQuickAccessService.cs +++ b/src/Files.App/Services/Windows/WindowsQuickAccessService.cs @@ -54,6 +54,7 @@ private async Task UnpinFromSidebarAsync(string[] folderPaths, bool doUpda ?? throw new InvalidOperationException("The Windows Shell Home namespace returned an item without a path."); bool shouldUnpin = folderPaths.Contains(pathStr); +#if WINDOWS if (ShellStorageFolder.IsShellPath(pathStr)) { var folder = await ShellStorageFolder.FromPathAsync(pathStr); @@ -64,6 +65,7 @@ private async Task UnpinFromSidebarAsync(string[] folderPaths, bool doUpda (path.StartsWith(@"\\SHELL\\") && folderPaths.Any(x => x.StartsWith(@"\\SHELL\\")))); } +#endif if (!shouldUnpin) continue; diff --git a/src/Files.App/Services/Windows/WindowsRecentItemsService.cs b/src/Files.App/Services/Windows/WindowsRecentItemsService.cs index 7fd74ed04cc6..5f71024b8373 100644 --- a/src/Files.App/Services/Windows/WindowsRecentItemsService.cs +++ b/src/Files.App/Services/Windows/WindowsRecentItemsService.cs @@ -121,7 +121,7 @@ public unsafe bool Remove(RecentItem item) { try { - HRESULT hr = item.ShellItem.BindToHandler(null, PInvoke.BHID_SFUIObject, out IContextMenu? pContextMenu); + HRESULT hr = item.ShellItem!.BindToHandler(null, PInvoke.BHID_SFUIObject, out IContextMenu? pContextMenu); if (hr.Failed || pContextMenu is null) return false; diff --git a/src/Files.App/UserControls/NavigationToolbar.xaml.cs b/src/Files.App/UserControls/NavigationToolbar.xaml.cs index 65ad3c288528..3b44526042e1 100644 --- a/src/Files.App/UserControls/NavigationToolbar.xaml.cs +++ b/src/Files.App/UserControls/NavigationToolbar.xaml.cs @@ -326,6 +326,7 @@ private async void BreadcrumbBar_ItemDropDownFlyoutOpening(object sender, Breadc { if (e.IsRootItem) { +#if WINDOWS IHomeFolder homeFolder = new HomeFolder(); IContentPageContext contentPageContext = Ioc.Default.GetRequiredService(); @@ -392,6 +393,35 @@ private async void BreadcrumbBar_ItemDropDownFlyoutOpening(object sender, Breadc shellPage.NavigateToPath(path); }; } +#else + IHomeFolder homeFolder = Ioc.Default.GetRequiredService().HomeFolder; + IContentPageContext contentPageContext = Ioc.Default.GetRequiredService(); + + async Task AddItemsAsync(string header, IAsyncEnumerable storables) + { + e.Flyout.Items.Add(new MenuFlyoutHeaderItem() { Text = header }); + + await foreach (var storable in storables) + { + var path = storable.Id; + var flyoutItem = new MenuFlyoutItem() + { + Text = string.IsNullOrEmpty(storable.Name) ? path : storable.Name, + Icon = new FontIcon() { Glyph = "\uE8B7" }, // As a placeholder + }; + e.Flyout.Items.Add(flyoutItem); + + var icon = await FileThumbnailHelper.GetIconAsync(path, 16u, true, IconOptions.ReturnIconOnly); + if (icon is not null) + flyoutItem.Icon = new ImageIcon() { Source = await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => icon.ToBitmapAsync(), Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal) }; + + flyoutItem.Click += (sender, args) => contentPageContext.ShellPage?.NavigateToPath(path); + } + } + + await AddItemsAsync(Strings.QuickAccess.GetLocalizedResource(), homeFolder.GetQuickAccessFolderAsync()); + await AddItemsAsync(Strings.Drives.GetLocalizedResource(), homeFolder.GetLogicalDrivesAsync()); +#endif return; } diff --git a/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs b/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs index 7896d528d635..52d52a0b702c 100644 --- a/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs +++ b/src/Files.App/UserControls/Pane/ShelfPane.xaml.cs @@ -7,8 +7,10 @@ using System.Runtime.InteropServices; using System.Windows.Input; using Windows.ApplicationModel.DataTransfer; +#if WINDOWS using Windows.Win32.System.Com; using Windows.Win32.UI.Shell; +#endif using WinRT; using DragEventArgs = Microsoft.UI.Xaml.DragEventArgs; using Visibility = Microsoft.UI.Xaml.Visibility; @@ -41,7 +43,11 @@ private async void Shelf_Drop(object sender, DragEventArgs e) return; // Get items +#if WINDOWS var storageService = Ioc.Default.GetRequiredService(); +#else + var resolver = Ioc.Default.GetRequiredService(); +#endif var storageItems = (await FilesystemHelpers.GetDraggedStorageItems(e.DataView)).ToArray(); // Add to list @@ -51,12 +57,17 @@ private async void Shelf_Drop(object sender, DragEventArgs e) if (ItemsSource.Any(x => x.Inner.Id == item.Path)) continue; +#if WINDOWS var storable = item switch { StorageFileWithPath => (IStorableChild?)await storageService.TryGetFileAsync(item.Path), StorageFolderWithPath => (IStorableChild?)await storageService.TryGetFolderAsync(item.Path), _ => null }; +#else + var resolved = await resolver.TryGetAsync(item.Path); + var storable = resolved.Item as IStorableChild; +#endif if (storable is null) continue; @@ -93,8 +104,8 @@ private void ListView_DragItemsStarting(object sender, DragItemsStartingEventArg item.Dispose(); } #else - // LINUX-TODO(dnd): shell data object drag from the shelf; use Uno DataPackage storage items / text/uri-list instead e.Data.Properties["Files_ActionBinder"] = "Files_ShelfBinder"; + Files.App.Services.Desktop.DesktopFileDragHelper.StartExternalDrag(paths); #endif } 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..7ceb512871d1 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,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 @@ -531,63 +536,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/Cloud/CloudDrivesDetector.cs b/src/Files.App/Utils/Cloud/CloudDrivesDetector.Windows.cs similarity index 100% rename from src/Files.App/Utils/Cloud/CloudDrivesDetector.cs rename to src/Files.App/Utils/Cloud/CloudDrivesDetector.Windows.cs diff --git a/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs b/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs index f04791e043e8..350dead580f1 100644 --- a/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs +++ b/src/Files.App/Utils/Cloud/Detector/CloudDetector.cs @@ -44,9 +44,14 @@ private static IEnumerable EnumerateDetectors() yield return new GoogleDriveCloudDetector(); yield return new DropBoxCloudDetector(); yield return new BoxCloudDetector(); +#if WINDOWS + // Registry and executable icon based yield return new GenericCloudDetector(); +#endif yield return new SynologyDriveCloudDetector(); +#if WINDOWS yield return new OXDriveCloudDetector(); +#endif } } } diff --git a/src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.cs b/src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.Windows.cs similarity index 100% rename from src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.cs rename to src/Files.App/Utils/Cloud/Detector/GenericCloudDetector.Windows.cs diff --git a/src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.cs b/src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.Windows.cs similarity index 100% rename from src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.cs rename to src/Files.App/Utils/Cloud/Detector/OXDriveCloudDetector.Windows.cs 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/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/Shell/ShellHelpers.cs b/src/Files.App/Utils/Shell/ShellHelpers.cs index 812b48424707..3a2c6d663b9e 100644 --- a/src/Files.App/Utils/Shell/ShellHelpers.cs +++ b/src/Files.App/Utils/Shell/ShellHelpers.cs @@ -9,6 +9,7 @@ public static class ShellHelpers { public static string ResolveShellPath(string shPath) { +#if WINDOWS if (!ShellStorageFolder.IsShellPath(shPath)) return shPath; @@ -26,12 +27,19 @@ public static string ResolveShellPath(string shPath) return Constants.UserEnvironmentPaths.NetworkFolderPath; return shPath; +#else + return shPath; +#endif } public static bool IsSupportedShellPath(string shPath) { +#if WINDOWS return ShellStorageFolder.IsShellPath(shPath) && Constants.UserEnvironmentPaths.ShellPlaces.ContainsKey(GetShellPathLookupKey(shPath)); +#else + return false; +#endif } private static string GetShellPathLookupKey(string shPath) diff --git a/src/Files.App/Utils/Storage/Enumerators/UniversalStorageEnumerator.cs b/src/Files.App/Utils/Storage/Enumerators/UniversalStorageEnumerator.Windows.cs similarity index 98% rename from src/Files.App/Utils/Storage/Enumerators/UniversalStorageEnumerator.cs rename to src/Files.App/Utils/Storage/Enumerators/UniversalStorageEnumerator.Windows.cs index 32ba5d4b8eae..22d7d28779e1 100644 --- a/src/Files.App/Utils/Storage/Enumerators/UniversalStorageEnumerator.cs +++ b/src/Files.App/Utils/Storage/Enumerators/UniversalStorageEnumerator.Windows.cs @@ -224,7 +224,7 @@ ex is FileNotFoundException || Arguments = linkFolder.Arguments, WorkingDirectory = linkFolder.WorkingDirectory, RunAsAdmin = linkFolder.RunAsAdmin, - ShowWindowCommand = linkFolder.ShowWindowCommand + ShowWindowCommand = ((int)linkFolder.ShowWindowCommand).ToShowWindowCommand() }; } else if (folder is BinStorageFolder binFolder) @@ -324,7 +324,7 @@ ex is FileNotFoundException || Arguments = linkFile.Arguments, WorkingDirectory = linkFile.WorkingDirectory, RunAsAdmin = linkFile.RunAsAdmin, - ShowWindowCommand = linkFile.ShowWindowCommand, + ShowWindowCommand = ((int)linkFile.ShowWindowCommand).ToShowWindowCommand(), IsUrl = isUrl, }; } diff --git a/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs b/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.Windows.cs similarity index 99% rename from src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs rename to src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.Windows.cs index 4cea4acfa92d..9ffbeac5944a 100644 --- a/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.cs +++ b/src/Files.App/Utils/Storage/Enumerators/Win32StorageEnumerator.Windows.cs @@ -385,7 +385,7 @@ CancellationToken cancellationToken Arguments = shInfo.Arguments, WorkingDirectory = shInfo.WorkingDirectory, RunAsAdmin = shInfo.RunAsAdmin, - ShowWindowCommand = shInfo.ShowWindowCommand, + ShowWindowCommand = ((int)shInfo.ShowWindowCommand).ToShowWindowCommand(), IsUrl = isUrl, }; } @@ -411,7 +411,7 @@ CancellationToken cancellationToken Arguments = shInfo.Arguments, WorkingDirectory = shInfo.WorkingDirectory, RunAsAdmin = shInfo.RunAsAdmin, - ShowWindowCommand = shInfo.ShowWindowCommand, + ShowWindowCommand = ((int)shInfo.ShowWindowCommand).ToShowWindowCommand(), IsUrl = isUrl, }; } diff --git a/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs b/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs index 11752c52f906..f09a95673f38 100644 --- a/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs +++ b/src/Files.App/Utils/Storage/Helpers/ArchiveEntryHelpers.cs @@ -1,6 +1,7 @@ // Copyright (c) Files Community // SPDX-License-Identifier: MPL-2.0 +#if WINDOWS using SevenZip; using IO = System.IO; @@ -38,3 +39,5 @@ public static string GetEntryName(ArchiveFileInfo entry, string containerPath) } } } + +#endif diff --git a/src/Files.App/Utils/Storage/Helpers/ErrorCodeConverter.cs b/src/Files.App/Utils/Storage/Helpers/ErrorCodeConverter.cs index a3be94bca180..4eb952ca63af 100644 --- a/src/Files.App/Utils/Storage/Helpers/ErrorCodeConverter.cs +++ b/src/Files.App/Utils/Storage/Helpers/ErrorCodeConverter.cs @@ -1,10 +1,21 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using Files.Core.Storage.Enums; + namespace Files.App.Utils.Storage { public static class ErrorCodeConverter { + public static FileSystemStatusCode ToFileSystemStatusCode(this StorableStatus status) => status switch + { + StorableStatus.Success => FileSystemStatusCode.Success, + StorableStatus.NotFound => FileSystemStatusCode.NotFound, + StorableStatus.AccessDenied => FileSystemStatusCode.Unauthorized, + StorableStatus.NotMine or StorableStatus.Error => FileSystemStatusCode.Generic, + _ => FileSystemStatusCode.Generic, + }; + public static ReturnResult ToStatus(this FileSystemStatusCode errorCode) { switch (errorCode) diff --git a/src/Files.App/Utils/Storage/Helpers/FilePropertiesHelpers.cs b/src/Files.App/Utils/Storage/Helpers/FilePropertiesHelpers.cs index e11f3ed1a156..8c865d511b94 100644 --- a/src/Files.App/Utils/Storage/Helpers/FilePropertiesHelpers.cs +++ b/src/Files.App/Utils/Storage/Helpers/FilePropertiesHelpers.cs @@ -9,7 +9,9 @@ using Microsoft.UI.Xaml.Media.Animation; using System.Collections.Concurrent; using Windows.Graphics; +#if WINDOWS using Windows.Win32; +#endif namespace Files.App.Utils.Storage { @@ -135,8 +137,10 @@ public static void OpenPropertiesWindow(object item, IShellPage associatedInstan // WINUI3: Move window to cursor position (the window manager places the window on Linux) var pointerPosition = default(System.Drawing.Point); +#if WINDOWS if (!OperatingSystem.IsLinux()) PInvoke.GetCursorPos(out pointerPosition); +#endif // Null when no display is available, e.g. while monitors are detached var displayArea = OperatingSystem.IsLinux() diff --git a/src/Files.App/Utils/Storage/Helpers/FileTypesHelper.cs b/src/Files.App/Utils/Storage/Helpers/FileTypesHelper.cs index f20ea01fda1b..ec35c9171723 100644 --- a/src/Files.App/Utils/Storage/Helpers/FileTypesHelper.cs +++ b/src/Files.App/Utils/Storage/Helpers/FileTypesHelper.cs @@ -2,9 +2,11 @@ // SPDX-License-Identifier: MPL-2.0 using System.Collections.Concurrent; +#if WINDOWS using Windows.Win32; using Windows.Win32.Storage.FileSystem; using Windows.Win32.UI.Shell; +#endif namespace Files.App.Utils.Storage { @@ -25,6 +27,7 @@ public static unsafe string GetLocalizedTypeName(string? extension) if (!OperatingSystem.IsWindows()) return string.Empty; +#if WINDOWS if (typeNameCache.TryGetValue(extension, out var cached)) return cached; @@ -47,6 +50,9 @@ public static unsafe string GetLocalizedTypeName(string? extension) typeNameCache[extension] = typeName; return typeName; +#else + return string.Empty; +#endif } } } diff --git a/src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs b/src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs index c6a0eb1dfdc8..e0550dbbefbf 100644 --- a/src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs +++ b/src/Files.App/Utils/Storage/Helpers/FilesystemResult.cs @@ -1,7 +1,9 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if WINDOWS using Windows.Win32.Foundation; +#endif namespace Files.App.Utils.Storage { @@ -18,8 +20,10 @@ public class FilesystemResult public static explicit operator FilesystemResult(bool res) => new(res ? FileSystemStatusCode.Success : FileSystemStatusCode.Generic); +#if WINDOWS public static implicit operator BOOL(FilesystemResult? res) => res?.ErrorCode is FileSystemStatusCode.Success; public static explicit operator FilesystemResult(BOOL res) => new(res ? FileSystemStatusCode.Success : FileSystemStatusCode.Generic); +#endif } public sealed class FilesystemResult : FilesystemResult diff --git a/src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs b/src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs index d0de8edac9b6..6dfe0e8e89bc 100644 --- a/src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs +++ b/src/Files.App/Utils/Storage/Helpers/FolderHelpers.cs @@ -2,8 +2,12 @@ // Licensed under the MIT License. using System.IO; +#if WINDOWS using Windows.Win32; +#endif +#if WINDOWS using Windows.Win32.Storage.FileSystem; +#endif namespace Files.App.Utils.Storage { diff --git a/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs b/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs index f1001c78a214..a1f38d43f438 100644 --- a/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/IStorageItemWithPath.cs @@ -13,8 +13,9 @@ public interface IStorageItemWithPath public string Path { get; } - // LINUX-TODO(storage): move under #if WINDOWS in P4-Z; desktop consumers (e.g. StorageHelpers.ToStorageItemResult) still read it +#if WINDOWS public IStorageItem? Item { get; } +#endif /// /// Gets the storable for this item, if one has been resolved; otherwise, . diff --git a/src/Files.App/Utils/Storage/Helpers/MtpHelpers.cs b/src/Files.App/Utils/Storage/Helpers/MtpHelpers.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/Helpers/MtpHelpers.cs rename to src/Files.App/Utils/Storage/Helpers/MtpHelpers.Windows.cs diff --git a/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs b/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs index 35339d6428a4..68503787eaab 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorableWithPath.cs @@ -27,7 +27,9 @@ public sealed record StorableWithPath : IStorageItemWithPath /// public string Name => Storable?.Name ?? IO.Path.GetFileName(IO.Path.TrimEndingDirectorySeparator(Path)); +#if WINDOWS IStorageItem? IStorageItemWithPath.Item => null; +#endif /// /// Initializes a new instance of the record. diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.Linux.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.Linux.cs new file mode 100644 index 000000000000..ee289c28dcdc --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.Linux.cs @@ -0,0 +1,94 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if !WINDOWS +using System.IO; +using Windows.Storage; + +namespace Files.App.Utils.Storage +{ + public static partial class StorageFileExtensions + { + // LINUX-TODO(storage): retain legacy signatures until P4-B/E/G/H migrate to the resolver. + public static BaseStorageFile? AsBaseStorageFile(this IStorageItem? item) => item as BaseStorageFile; + public static BaseStorageFolder? AsBaseStorageFolder(this IStorageItem? item) => item as BaseStorageFolder; + + public static Task> ToStandardStorageItemsAsync(this IEnumerable items) + => Task.FromResult(items.ToList()); + + public static async Task DangerousGetFileFromPathAsync(string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + { + var path = ResolveLegacyPath(value, parentFolder); + var result = await StorageHelpers.GetFileAsync(path); + ThrowLegacyResolutionError(result.ErrorCode, path); + return await OpenLegacyFileAsync(path); + } + + public static async Task DangerousGetFileWithPathFromPathAsync(string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + { + var path = ResolveLegacyPath(value, parentFolder); + ThrowLegacyResolutionError((await StorageHelpers.GetFileAsync(path)).ErrorCode, path); + return new(await OpenLegacyFileAsync(path), path); + } + + public static async Task DangerousGetFolderFromPathAsync(string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + { + var path = ResolveLegacyPath(value, parentFolder); + var result = await StorageHelpers.GetFolderAsync(path); + ThrowLegacyResolutionError(result.ErrorCode, path); + return await OpenLegacyFolderAsync(path); + } + + public static async Task DangerousGetFolderWithPathFromPathAsync(string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + { + var path = ResolveLegacyPath(value, parentFolder); + ThrowLegacyResolutionError((await StorageHelpers.GetFolderAsync(path)).ErrorCode, path); + return new(await OpenLegacyFolderAsync(path), path); + } + + /// Opens a path the resolver already accepted as a legacy item; null when no legacy type can represent it (e.g. archive members). + internal static async Task OpenLegacyFileAsync(string path) + { + // Uno does not check existence or type, so only local regular files qualify + if (!Path.IsPathRooted(path) || !File.Exists(path)) + return null; + try { return await BaseStorageFile.GetFileFromPathAsync(path); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { return null; } + } + + internal static async Task OpenLegacyFolderAsync(string path) + { + if (!Path.IsPathRooted(path) || !Directory.Exists(path)) + return null; + try { return await BaseStorageFolder.GetFolderFromPathAsync(path); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { return null; } + } + + private static string ResolveLegacyPath(string path, StorageFolderWithPath? parentFolder) + => parentFolder is not null && !Path.IsPathRooted(path) && !FtpHelpers.IsFtpPath(path) + ? Path.GetFullPath(Path.Combine(parentFolder.Path, path)) : path; + + private static void ThrowLegacyResolutionError(FileSystemStatusCode status, string path) + { + if (status is FileSystemStatusCode.Success) + return; + if (status is FileSystemStatusCode.NotFound) + throw new FileNotFoundException("The storage item was not found.", path); + if (status is FileSystemStatusCode.Unauthorized) + throw new UnauthorizedAccessException("The storage item cannot be accessed."); + if (status is FileSystemStatusCode.NotAFile or FileSystemStatusCode.NotAFolder) + throw new ArgumentException("The storage item has a different kind.", nameof(path)); + throw new NotSupportedException("Use IStorableResolver instead of the legacy WinRT storage layer."); + } + + public static Task> GetFilesWithPathAsync(this StorageFolderWithPath parentFolder, uint maxNumberOfItems = uint.MaxValue) + => throw new NotSupportedException("Use IFolder.GetItemsAsync instead of the legacy WinRT storage layer."); + + public static Task> GetFoldersWithPathAsync(this StorageFolderWithPath parentFolder, uint maxNumberOfItems = uint.MaxValue) + => throw new NotSupportedException("Use IFolder.GetItemsAsync instead of the legacy WinRT storage layer."); + + public static Task?> GetFoldersWithPathAsync(this StorageFolderWithPath parentFolder, string nameFilter, uint maxNumberOfItems = uint.MaxValue) + => throw new NotSupportedException("Use IFolder.GetItemsAsync instead of the legacy WinRT storage layer."); + } +} +#endif diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.Windows.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.Windows.cs new file mode 100644 index 000000000000..e430fe26d8a1 --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.Windows.cs @@ -0,0 +1,188 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.IO; +using Windows.Storage; +using Windows.Storage.Search; +using WinRT; + +namespace Files.App.Utils.Storage +{ + public static partial class StorageFileExtensions + { + [DynamicWindowsRuntimeCast(typeof(StorageFile))] + public static BaseStorageFile? AsBaseStorageFile(this IStorageItem? item) + { + if (item is null || !item.IsOfType(StorageItemTypes.File)) + return null; + + return item is StorageFile file ? new SystemStorageFile(file) : item as BaseStorageFile; + } + + public static async Task> ToStandardStorageItemsAsync(this IEnumerable items) + { + var newItems = new List(); + foreach (var item in items) + { + try + { + if (item is null) + { + } + else if (item.IsOfType(StorageItemTypes.File)) + { + newItems.Add(await item.AsBaseStorageFile()!.ToStorageFileAsync()); + } + else if (item.IsOfType(StorageItemTypes.Folder)) + { + newItems.Add(await item.AsBaseStorageFolder()!.ToStorageFolderAsync()); + } + } + catch (NotSupportedException) + { + // Ignore items that can't be converted + } + } + return newItems; + } + + [DynamicWindowsRuntimeCast(typeof(StorageFolder))] + public static BaseStorageFolder? AsBaseStorageFolder(this IStorageItem? item) + { + if (item is not null && item.IsOfType(StorageItemTypes.Folder)) + return item is StorageFolder folder ? new SystemStorageFolder(folder) : item as BaseStorageFolder; + + return null; + } + + public async static Task DangerousGetFileFromPathAsync + (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + => (await DangerousGetFileWithPathFromPathAsync(value, rootFolder, parentFolder)).Item; + public async static Task DangerousGetFileWithPathFromPathAsync + (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + { + if (rootFolder is not null) + { + var rootItem = rootFolder.Item!; + var currComponents = GetDirectoryPathComponents(value); + + if (parentFolder is not null && value.IsSubPathOf(parentFolder.Path)) + { + var folder = parentFolder.Item!; + var prevComponents = GetDirectoryPathComponents(parentFolder.Path); + var path = parentFolder.Path; + foreach (var component in currComponents.ExceptBy(prevComponents, c => c.Path).SkipLast(1)) + { + folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); + path = PathNormalization.Combine(path, folder.Name); + } + var file = await folder.GetFileAsync(currComponents.Last().Title ?? throw new InvalidOperationException("A path component is missing its title.")); + path = PathNormalization.Combine(path, file.Name); + return new StorageFileWithPath(file, path); + } + else if (value.IsSubPathOf(rootFolder.Path)) + { + var folder = rootItem; + var path = rootFolder.Path; + foreach (var component in currComponents.Skip(1).SkipLast(1)) + { + folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); + path = PathNormalization.Combine(path, folder.Name); + } + var file = await folder.GetFileAsync(currComponents.Last().Title ?? throw new InvalidOperationException("A path component is missing its title.")); + path = PathNormalization.Combine(path, file.Name); + return new StorageFileWithPath(file, path); + } + } + + var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root + ? Path.GetFullPath(Path.Combine(parentFolder.Path, value)) // Relative path + : value; + var item = await BaseStorageFile.GetFileFromPathAsync(fullPath); + + if (parentFolder is not null && parentFolder.Item is IPasswordProtectedItem ppis && item is IPasswordProtectedItem ppid) + ppid.Credentials = ppis.Credentials; + + return new StorageFileWithPath(item); + } + public async static Task> GetFilesWithPathAsync + (this StorageFolderWithPath parentFolder, uint maxNumberOfItems = uint.MaxValue) + => (await parentFolder.Item!.GetFilesAsync(CommonFileQuery.DefaultQuery, 0, maxNumberOfItems)) + .Select(x => new StorageFileWithPath(x, string.IsNullOrEmpty(x.Path) ? PathNormalization.Combine(parentFolder.Path, x.Name) : x.Path)).ToList(); + + public async static Task DangerousGetFolderFromPathAsync + (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + => (await DangerousGetFolderWithPathFromPathAsync(value, rootFolder, parentFolder)).Item; + public async static Task DangerousGetFolderWithPathFromPathAsync + (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) + { + // Archive paths can't be resolved by chaining WinRT GetFolderAsync from a network root/parent + // (an archive is not a real subfolder of the share); resolve them directly like local archives. + if (rootFolder is not null && !ZipStorageFolder.IsZipPath(value)) + { + var rootItem = rootFolder.Item!; + var currComponents = GetDirectoryPathComponents(value); + + if (rootFolder.Path == value) + { + return rootFolder; + } + else if (parentFolder is not null && value.IsSubPathOf(parentFolder.Path)) + { + var folder = parentFolder.Item!; + var prevComponents = GetDirectoryPathComponents(parentFolder.Path); + var path = parentFolder.Path; + foreach (var component in currComponents.ExceptBy(prevComponents, c => c.Path)) + { + folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); + path = PathNormalization.Combine(path, folder.Name); + } + return new StorageFolderWithPath(folder, path); + } + else if (value.IsSubPathOf(rootFolder.Path)) + { + var folder = rootItem; + var path = rootFolder.Path; + foreach (var component in currComponents.Skip(1)) + { + folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); + path = PathNormalization.Combine(path, folder.Name); + } + return new StorageFolderWithPath(folder, path); + } + } + + var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root + ? Path.GetFullPath(Path.Combine(parentFolder.Path, value)) // Relative path + : value; + var item = await BaseStorageFolder.GetFolderFromPathAsync(fullPath); + + if (parentFolder is not null && parentFolder.Item is IPasswordProtectedItem ppis && item is IPasswordProtectedItem ppid) + ppid.Credentials = ppis.Credentials; + + return new StorageFolderWithPath(item); + } + public async static Task> GetFoldersWithPathAsync + (this StorageFolderWithPath parentFolder, uint maxNumberOfItems = uint.MaxValue) + => (await parentFolder.Item!.GetFoldersAsync(CommonFolderQuery.DefaultQuery, 0, maxNumberOfItems)) + .Select(x => new StorageFolderWithPath(x, string.IsNullOrEmpty(x.Path) ? PathNormalization.Combine(parentFolder.Path, x.Name) : x.Path)).ToList(); + public async static Task?> GetFoldersWithPathAsync + (this StorageFolderWithPath parentFolder, string nameFilter, uint maxNumberOfItems = uint.MaxValue) + { + if (parentFolder is null) + return null; + + var folder = parentFolder.Item!; + + var queryOptions = new QueryOptions + { + ApplicationSearchFilter = $"System.FileName:{nameFilter}*" + }; + BaseStorageFolderQueryResult queryResult = folder.CreateFolderQueryWithOptions(queryOptions); + + return (await queryResult.GetFoldersAsync(0, maxNumberOfItems)) + .Select(x => new StorageFolderWithPath(x, string.IsNullOrEmpty(x.Path) ? PathNormalization.Combine(parentFolder.Path, x.Name) : x.Path)).ToList(); + } + + } +} diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs index 16e1e96cab24..3956695ee652 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileExtensions.cs @@ -5,12 +5,10 @@ using System.IO; using System.Text; using Windows.Storage; -using Windows.Storage.Search; -using WinRT; namespace Files.App.Utils.Storage { - public static class StorageFileExtensions + public static partial class StorageFileExtensions { private const int SINGLE_DOT_DIRECTORY_LENGTH = 2; private const int DOUBLE_DOT_DIRECTORY_LENGTH = 3; @@ -18,42 +16,6 @@ public static class StorageFileExtensions public static readonly ImmutableHashSet _ftpPaths = new HashSet() { "ftp:/", "ftps:/", "ftpes:/" }.ToImmutableHashSet(); - [DynamicWindowsRuntimeCast(typeof(StorageFile))] - public static BaseStorageFile? AsBaseStorageFile(this IStorageItem? item) - { - if (item is null || !item.IsOfType(StorageItemTypes.File)) - return null; - - return item is StorageFile file ? new SystemStorageFile(file) : item as BaseStorageFile; - } - - public static async Task> ToStandardStorageItemsAsync(this IEnumerable items) - { - var newItems = new List(); - foreach (var item in items) - { - try - { - if (item is null) - { - } - else if (item.IsOfType(StorageItemTypes.File)) - { - newItems.Add(await item.AsBaseStorageFile()!.ToStorageFileAsync()); - } - else if (item.IsOfType(StorageItemTypes.Folder)) - { - newItems.Add(await item.AsBaseStorageFolder()!.ToStorageFolderAsync()); - } - } - catch (NotSupportedException) - { - // Ignore items that can't be converted - } - } - return newItems; - } - public static bool AreItemsInSameDrive(this IEnumerable itemsPath, string? destinationPath) { try @@ -129,15 +91,6 @@ private static bool IsSamePathOrAncestor(string? trimmedItemPath, string trimmed trimmedDestinationPath[trimmedItemPath.Length] == Path.AltDirectorySeparatorChar); } - [DynamicWindowsRuntimeCast(typeof(StorageFolder))] - public static BaseStorageFolder? AsBaseStorageFolder(this IStorageItem? item) - { - if (item is not null && item.IsOfType(StorageItemTypes.Folder)) - return item is StorageFolder folder ? new SystemStorageFolder(folder) : item as BaseStorageFolder; - - return null; - } - public static List GetDirectoryPathComponents(string value) { List pathBoxItems = []; @@ -204,11 +157,19 @@ public static async Task> GetDirectoryPathComponentsWithDispla } else { +#if WINDOWS var path = item.Path!; BaseStorageFolder? folder = await FilesystemTasks.WrapNullable(() => DangerousGetFolderFromPathAsync(path)); if (!string.IsNullOrEmpty(folder?.DisplayName)) item.Title = folder.DisplayName; +#else + var path = item.Path!; + var folder = (await StorageHelpers.GetFolderAsync(path)).Result; + + if (!string.IsNullOrEmpty(folder?.Name)) + item.Title = folder.Name; +#endif } item.ChevronToolTip = string.Format(Strings.BreadcrumbBarChevronButtonToolTip.GetLocalizedResource(), item.Title); @@ -223,135 +184,6 @@ public static string GetResolvedPath(string path, bool isFtp) return ResolvePath(withoutEnvirnment, isFtp); } - public async static Task DangerousGetFileFromPathAsync - (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) - => (await DangerousGetFileWithPathFromPathAsync(value, rootFolder, parentFolder)).Item; - public async static Task DangerousGetFileWithPathFromPathAsync - (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) - { - if (rootFolder is not null) - { - var rootItem = rootFolder.Item!; - var currComponents = GetDirectoryPathComponents(value); - - if (parentFolder is not null && value.IsSubPathOf(parentFolder.Path)) - { - var folder = parentFolder.Item!; - var prevComponents = GetDirectoryPathComponents(parentFolder.Path); - var path = parentFolder.Path; - foreach (var component in currComponents.ExceptBy(prevComponents, c => c.Path).SkipLast(1)) - { - folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); - path = PathNormalization.Combine(path, folder.Name); - } - var file = await folder.GetFileAsync(currComponents.Last().Title ?? throw new InvalidOperationException("A path component is missing its title.")); - path = PathNormalization.Combine(path, file.Name); - return new StorageFileWithPath(file, path); - } - else if (value.IsSubPathOf(rootFolder.Path)) - { - var folder = rootItem; - var path = rootFolder.Path; - foreach (var component in currComponents.Skip(1).SkipLast(1)) - { - folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); - path = PathNormalization.Combine(path, folder.Name); - } - var file = await folder.GetFileAsync(currComponents.Last().Title ?? throw new InvalidOperationException("A path component is missing its title.")); - path = PathNormalization.Combine(path, file.Name); - return new StorageFileWithPath(file, path); - } - } - - var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root - ? Path.GetFullPath(Path.Combine(parentFolder.Path, value)) // Relative path - : value; - var item = await BaseStorageFile.GetFileFromPathAsync(fullPath); - - if (parentFolder is not null && parentFolder.Item is IPasswordProtectedItem ppis && item is IPasswordProtectedItem ppid) - ppid.Credentials = ppis.Credentials; - - return new StorageFileWithPath(item); - } - public async static Task> GetFilesWithPathAsync - (this StorageFolderWithPath parentFolder, uint maxNumberOfItems = uint.MaxValue) - => (await parentFolder.Item!.GetFilesAsync(CommonFileQuery.DefaultQuery, 0, maxNumberOfItems)) - .Select(x => new StorageFileWithPath(x, string.IsNullOrEmpty(x.Path) ? PathNormalization.Combine(parentFolder.Path, x.Name) : x.Path)).ToList(); - - public async static Task DangerousGetFolderFromPathAsync - (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) - => (await DangerousGetFolderWithPathFromPathAsync(value, rootFolder, parentFolder)).Item; - public async static Task DangerousGetFolderWithPathFromPathAsync - (string value, StorageFolderWithPath? rootFolder = null, StorageFolderWithPath? parentFolder = null) - { - // Archive paths can't be resolved by chaining WinRT GetFolderAsync from a network root/parent - // (an archive is not a real subfolder of the share); resolve them directly like local archives. - if (rootFolder is not null && !ZipStorageFolder.IsZipPath(value)) - { - var rootItem = rootFolder.Item!; - var currComponents = GetDirectoryPathComponents(value); - - if (rootFolder.Path == value) - { - return rootFolder; - } - else if (parentFolder is not null && value.IsSubPathOf(parentFolder.Path)) - { - var folder = parentFolder.Item!; - var prevComponents = GetDirectoryPathComponents(parentFolder.Path); - var path = parentFolder.Path; - foreach (var component in currComponents.ExceptBy(prevComponents, c => c.Path)) - { - folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); - path = PathNormalization.Combine(path, folder.Name); - } - return new StorageFolderWithPath(folder, path); - } - else if (value.IsSubPathOf(rootFolder.Path)) - { - var folder = rootItem; - var path = rootFolder.Path; - foreach (var component in currComponents.Skip(1)) - { - folder = await folder.GetFolderAsync(component.Title ?? throw new InvalidOperationException("A path component is missing its title.")); - path = PathNormalization.Combine(path, folder.Name); - } - return new StorageFolderWithPath(folder, path); - } - } - - var fullPath = (parentFolder is not null && !FtpHelpers.IsFtpPath(value) && !Path.IsPathRooted(value) && !ShellStorageFolder.IsShellPath(value)) // "::{" not a valid root - ? Path.GetFullPath(Path.Combine(parentFolder.Path, value)) // Relative path - : value; - var item = await BaseStorageFolder.GetFolderFromPathAsync(fullPath); - - if (parentFolder is not null && parentFolder.Item is IPasswordProtectedItem ppis && item is IPasswordProtectedItem ppid) - ppid.Credentials = ppis.Credentials; - - return new StorageFolderWithPath(item); - } - public async static Task> GetFoldersWithPathAsync - (this StorageFolderWithPath parentFolder, uint maxNumberOfItems = uint.MaxValue) - => (await parentFolder.Item!.GetFoldersAsync(CommonFolderQuery.DefaultQuery, 0, maxNumberOfItems)) - .Select(x => new StorageFolderWithPath(x, string.IsNullOrEmpty(x.Path) ? PathNormalization.Combine(parentFolder.Path, x.Name) : x.Path)).ToList(); - public async static Task?> GetFoldersWithPathAsync - (this StorageFolderWithPath parentFolder, string nameFilter, uint maxNumberOfItems = uint.MaxValue) - { - if (parentFolder is null) - return null; - - var folder = parentFolder.Item!; - - var queryOptions = new QueryOptions - { - ApplicationSearchFilter = $"System.FileName:{nameFilter}*" - }; - BaseStorageFolderQueryResult queryResult = folder.CreateFolderQueryWithOptions(queryOptions); - - return (await queryResult.GetFoldersAsync(0, maxNumberOfItems)) - .Select(x => new StorageFolderWithPath(x, string.IsNullOrEmpty(x.Path) ? PathNormalization.Combine(parentFolder.Path, x.Name) : x.Path)).ToList(); - } - private static PathBoxItem GetPathItem(string component, string path) { var title = string.Empty; @@ -419,8 +251,10 @@ private static string ResolvePath(string path, bool isFtp) if (path.StartsWith("Settings")) return "Settings"; +#if WINDOWS if (ShellStorageFolder.IsShellPath(path)) return ShellHelpers.ResolveShellPath(path); +#endif var pathBuilder = new StringBuilder(path); var lastPathIndex = path.Length - 1; diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs b/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs index 880213b48dd6..43fdebec4377 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFileWithPath.cs @@ -11,7 +11,9 @@ public sealed class StorageFileWithPath : IStorageItemWithPath public string Path { get; } public string Name => Item?.Name ?? IO.Path.GetFileName(Path)!; +#if WINDOWS IStorageItem? IStorageItemWithPath.Item => Item; +#endif public BaseStorageFile? Item { get; } public FilesystemItemType ItemType => FilesystemItemType.File; diff --git a/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs b/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs index 7a93240dc75e..ee8e81b08ac5 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageFolderWithPath.cs @@ -11,7 +11,9 @@ public sealed class StorageFolderWithPath : IStorageItemWithPath public string Path { get; } public string Name => Item?.Name ?? IO.Path.GetFileName(Path)!; +#if WINDOWS IStorageItem? IStorageItemWithPath.Item => Item; +#endif public BaseStorageFolder? Item { get; } public FilesystemItemType ItemType => FilesystemItemType.Directory; diff --git a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs new file mode 100644 index 000000000000..1ec0416f9fb4 --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Storables.cs @@ -0,0 +1,52 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Core.Storage.Contracts; + +namespace Files.App.Helpers +{ + public static partial class StorageHelpers + { + public static async Task> GetStorableAsync(string path, CancellationToken cancellationToken = default) + { + var result = await Ioc.Default.GetRequiredService().TryGetAsync(path, cancellationToken); + return new(result.Item, result.Status.ToFileSystemStatusCode()); + } + + public static async Task> GetFileAsync(string path, CancellationToken cancellationToken = default) + { + var result = await GetStorableAsync(path, cancellationToken); + return new(result.Result as IFile, result && result.Result is not IFile ? FileSystemStatusCode.NotAFile : result.ErrorCode); + } + + public static async Task> GetFolderAsync(string path, CancellationToken cancellationToken = default) + { + var result = await GetStorableAsync(path, cancellationToken); + return new(result.Result as IFolder, result && result.Result is not IFolder ? FileSystemStatusCode.NotAFolder : result.ErrorCode); + } + + public static async Task> ToStorableResult(this IStorageItemWithPath item, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = item.Storable is { } storable + ? new FilesystemResult(storable, FileSystemStatusCode.Success) + : await GetStorableAsync(item.Path, cancellationToken); + if (result && item.ItemType is FilesystemItemType.File && result.Result is not IFile) + return new(null, FileSystemStatusCode.NotAFile); + if (result && item.ItemType is FilesystemItemType.Directory or FilesystemItemType.Library && result.Result is not IFolder) + return new(null, FileSystemStatusCode.NotAFolder); + return result; + } + + /// + /// Gets whether the item is a member of an archive; such items can only be copied out. + /// + public static bool IsArchiveMember(this IStorageItemWithPath item) +#if WINDOWS + => item.Item is ZipStorageFile || item.Item is ZipStorageFolder; +#else + => item.Storable is Files.App.Storage.Archives.ArchiveEntryFile or Files.App.Storage.Archives.ArchiveFolder + || Files.Shared.Helpers.FileExtensionHelpers.IsZipPath(item.Path, includeRoot: false); +#endif + } +} diff --git a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Windows.cs b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Windows.cs new file mode 100644 index 000000000000..2db99bf26d57 --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.Windows.cs @@ -0,0 +1,206 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using System.Runtime.InteropServices; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Win32; +using Windows.Win32.Storage.FileSystem; + +namespace Files.App.Helpers +{ + /// + /// related Helpers + /// + public static partial class StorageHelpers + { + public static async Task ToStorageItem(this IStorageItemWithPath item) + { + return (await item.ToStorageItemResult()).Result; + } + + public static async Task ToStorageItem(string path) where TRequested : IStorageItem + { + FilesystemResult? file = null; + FilesystemResult? folder = null; + + if (FileExtensionHelpers.IsShortcutOrUrlFile(path)) + { + // TODO: In the future, when IStorageItemWithPath will inherit from IStorageItem, + // we could implement this code here for getting .lnk files + // for now, we can't + return default; + } + + // Fast get attributes +#if WINDOWS + uint itemAttributes = PInvoke.GetFileAttributes(path); + bool exists = itemAttributes != PInvoke.INVALID_FILE_ATTRIBUTES; + bool isDirectory = exists && (itemAttributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; +#else + bool isDirectory = System.IO.Directory.Exists(path); + bool exists = isDirectory || System.IO.File.Exists(path); +#endif + if (exists) // Exists on local storage + { + // Directory + if (isDirectory) + { + if (typeof(IStorageFile).IsAssignableFrom(typeof(TRequested))) // Wanted file + { + // NotAFile + return default; + } + else // Just get the directory + { + await GetFolderAsync(); + } + } + else // File + { + if (typeof(IStorageFolder).IsAssignableFrom(typeof(TRequested))) // Wanted directory + { + // NotAFile + return default; + } + else // Just get the file + { + await GetFileAsync(); + } + } + } + else // Does not exist or is not present on local storage + { + Debug.WriteLine($"Path does not exist. Trying to find storage item manually (HRESULT: {Marshal.GetLastWin32Error()})"); + + if (typeof(IStorageFile).IsAssignableFrom(typeof(TRequested))) + { + await GetFileAsync(); + } + else if (typeof(IStorageFolder).IsAssignableFrom(typeof(TRequested))) + { + await GetFolderAsync(); + } + else if (typeof(IStorageItem).IsAssignableFrom(typeof(TRequested))) + { + if (System.IO.Path.HasExtension(path)) // Possibly a file + { + await GetFileAsync(); + } + + if (file?.Result is null) // Possibly a folder + { + await GetFolderAsync(); + + if (file is null && folder?.Result is null) + { + // Try file because it wasn't checked + await GetFileAsync(); + } + } + } + } + + if (file?.Result is TRequested requestedFile) + { + return requestedFile; + } + else if (folder?.Result is TRequested requestedFolder) + { + return requestedFolder; + } + + return default; + + // Extensions + + async Task GetFileAsync() + { + var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(path)); + file = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFileFromPathAsync(path, rootItem.Result)); + } + + async Task GetFolderAsync() + { + var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(path)); + folder = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path, rootItem.Result)); + } + } + + public static async Task GetFileSize(this IStorageFile file) + { + BasicProperties properties = await file.GetBasicPropertiesAsync(); + return (long)properties.Size; + } + + public static async Task> ToStorageItemResult(this IStorageItemWithPath item) + { + var returnedItem = new FilesystemResult(null, FileSystemStatusCode.Generic); + var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(item.Path)); + if (!string.IsNullOrEmpty(item.Path)) + { + returnedItem = (item.ItemType == FilesystemItemType.File) ? + ToType( + await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFileFromPathAsync(item.Path, rootItem))) : + ToType( + await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(item.Path, rootItem))); + } + if (returnedItem.Result is null && item.Item is not null) + returnedItem = new FilesystemResult(item.Item, FileSystemStatusCode.Success); + if (returnedItem.Result is IPasswordProtectedItem ppid && item.Item is IPasswordProtectedItem ppis) + ppid.Credentials = ppis.Credentials; + return returnedItem; + } + + public static IStorageItemWithPath FromPathAndType(string customPath, FilesystemItemType? itemType) + { + return (itemType == FilesystemItemType.File) ? + new StorageFileWithPath(null, customPath) : + new StorageFolderWithPath(null, customPath); + } + + public static async Task GetTypeFromPath(string path) + { + IStorageItem? item = await ToStorageItem(path); + + return item is null ? FilesystemItemType.File : (item.IsOfType(StorageItemTypes.Folder) ? FilesystemItemType.Directory : FilesystemItemType.File); + } + + public static bool Exists(string path) + { + if (OperatingSystem.IsLinux()) + return System.IO.Directory.Exists(path) || System.IO.File.Exists(path); + + return PInvoke.GetFileAttributes(path) != PInvoke.INVALID_FILE_ATTRIBUTES; + } + + public static IStorageItemWithPath? FromStorageItem(this IStorageItem? item, string? customPath = null, FilesystemItemType? itemType = null) + { + if (item is null) + { + return FromPathAndType(customPath + ?? throw new InvalidOperationException("A path is required when converting a missing storage item."), itemType); + } + else if (item.IsOfType(StorageItemTypes.File)) + { + return new StorageFileWithPath(item.AsBaseStorageFile(), GetPath(item, customPath)); + } + else if (item.IsOfType(StorageItemTypes.Folder)) + { + return new StorageFolderWithPath(item.AsBaseStorageFolder(), GetPath(item, customPath)); + } + return null; + + static string GetPath(IStorageItem item, string? customPath) + => !string.IsNullOrEmpty(item.Path) + ? item.Path + : customPath ?? string.Empty; + } + + public static FilesystemResult ToType(FilesystemResult result) where T : class + { + return new FilesystemResult(result.Result as T, result.ErrorCode); + } + } +} diff --git a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs index 940b76b4af81..00205c1eb116 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageHelpers.cs @@ -1,206 +1,40 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Files.Shared.Helpers; -using System.Runtime.InteropServices; +using Files.Core.Storage.Contracts; using Windows.Storage; -using Windows.Storage.FileProperties; -using Windows.Win32; -using Windows.Win32.Storage.FileSystem; namespace Files.App.Helpers { - /// - /// related Helpers - /// - public static class StorageHelpers + public static partial class StorageHelpers { - public static async Task ToStorageItem(this IStorageItemWithPath item) - { - return (await item.ToStorageItemResult()).Result; - } - +#if !WINDOWS + // LINUX-TODO(storage): remove the remaining WinRT entry points (ToStorageItem, FromStorageItem) once their callers use storables. public static async Task ToStorageItem(string path) where TRequested : IStorageItem { - FilesystemResult? file = null; - FilesystemResult? folder = null; - - if (FileExtensionHelpers.IsShortcutOrUrlFile(path)) - { - // TODO: In the future, when IStorageItemWithPath will inherit from IStorageItem, - // we could implement this code here for getting .lnk files - // for now, we can't + var result = await GetStorableAsync(path); + if (!result) return default; - } - - // Fast get attributes -#if WINDOWS - uint itemAttributes = PInvoke.GetFileAttributes(path); - bool exists = itemAttributes != PInvoke.INVALID_FILE_ATTRIBUTES; - bool isDirectory = exists && (itemAttributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; -#else - bool isDirectory = System.IO.Directory.Exists(path); - bool exists = isDirectory || System.IO.File.Exists(path); -#endif - if (exists) // Exists on local storage - { - // Directory - if (isDirectory) - { - if (typeof(IStorageFile).IsAssignableFrom(typeof(TRequested))) // Wanted file - { - // NotAFile - return default; - } - else // Just get the directory - { - await GetFolderAsync(); - } - } - else // File - { - if (typeof(IStorageFolder).IsAssignableFrom(typeof(TRequested))) // Wanted directory - { - // NotAFile - return default; - } - else // Just get the file - { - await GetFileAsync(); - } - } - } - else // Does not exist or is not present on local storage - { - Debug.WriteLine($"Path does not exist. Trying to find storage item manually (HRESULT: {Marshal.GetLastWin32Error()})"); - - if (typeof(IStorageFile).IsAssignableFrom(typeof(TRequested))) - { - await GetFileAsync(); - } - else if (typeof(IStorageFolder).IsAssignableFrom(typeof(TRequested))) - { - await GetFolderAsync(); - } - else if (typeof(IStorageItem).IsAssignableFrom(typeof(TRequested))) - { - if (System.IO.Path.HasExtension(path)) // Possibly a file - { - await GetFileAsync(); - } - - if (file?.Result is null) // Possibly a folder - { - await GetFolderAsync(); - - if (file is null && folder?.Result is null) - { - // Try file because it wasn't checked - await GetFileAsync(); - } - } - } - } - - if (file?.Result is TRequested requestedFile) - { - return requestedFile; - } - else if (folder?.Result is TRequested requestedFolder) - { - return requestedFolder; - } - - return default; - - // Extensions - - async Task GetFileAsync() - { - var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(path)); - file = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFileFromPathAsync(path, rootItem.Result)); - } - - async Task GetFolderAsync() - { - var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(path)); - folder = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path, rootItem.Result)); - } - } - - public static async Task GetFileSize(this IStorageFile file) - { - BasicProperties properties = await file.GetBasicPropertiesAsync(); - return (long)properties.Size; - } - public static async Task> ToStorageItemResult(this IStorageItemWithPath item) - { - var returnedItem = new FilesystemResult(null, FileSystemStatusCode.Generic); - var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(item.Path)); - if (!string.IsNullOrEmpty(item.Path)) - { - returnedItem = (item.ItemType == FilesystemItemType.File) ? - ToType( - await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFileFromPathAsync(item.Path, rootItem))) : - ToType( - await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(item.Path, rootItem))); - } - if (returnedItem.Result is null && item.Item is not null) - returnedItem = new FilesystemResult(item.Item, FileSystemStatusCode.Success); - if (returnedItem.Result is IPasswordProtectedItem ppid && item.Item is IPasswordProtectedItem ppis) - ppid.Credentials = ppis.Credentials; - return returnedItem; + IStorageItem? item = result.Result is IFolder + ? await StorageFileExtensions.OpenLegacyFolderAsync(path) + : await StorageFileExtensions.OpenLegacyFileAsync(path); + return item is TRequested requested ? requested : default; } public static IStorageItemWithPath FromPathAndType(string customPath, FilesystemItemType? itemType) - { - return (itemType == FilesystemItemType.File) ? - new StorageFileWithPath(null, customPath) : - new StorageFolderWithPath(null, customPath); - } + => new StorableWithPath(customPath, itemType ?? FilesystemItemType.Directory); public static async Task GetTypeFromPath(string path) - { - IStorageItem? item = await ToStorageItem(path); - - return item is null ? FilesystemItemType.File : (item.IsOfType(StorageItemTypes.Folder) ? FilesystemItemType.Directory : FilesystemItemType.File); - } + => (await GetStorableAsync(path)).Result is IFolder ? FilesystemItemType.Directory : FilesystemItemType.File; public static bool Exists(string path) - { - if (OperatingSystem.IsLinux()) - return System.IO.Directory.Exists(path) || System.IO.File.Exists(path); - - return PInvoke.GetFileAttributes(path) != PInvoke.INVALID_FILE_ATTRIBUTES; - } + => System.IO.Directory.Exists(path) || System.IO.File.Exists(path); public static IStorageItemWithPath? FromStorageItem(this IStorageItem? item, string? customPath = null, FilesystemItemType? itemType = null) - { - if (item is null) - { - return FromPathAndType(customPath - ?? throw new InvalidOperationException("A path is required when converting a missing storage item."), itemType); - } - else if (item.IsOfType(StorageItemTypes.File)) - { - return new StorageFileWithPath(item.AsBaseStorageFile(), GetPath(item, customPath)); - } - else if (item.IsOfType(StorageItemTypes.Folder)) - { - return new StorageFolderWithPath(item.AsBaseStorageFolder(), GetPath(item, customPath)); - } - return null; - - static string GetPath(IStorageItem item, string? customPath) - => !string.IsNullOrEmpty(item.Path) - ? item.Path - : customPath ?? string.Empty; - } - - public static FilesystemResult ToType(FilesystemResult result) where T : class - { - return new FilesystemResult(result.Result as T, result.ErrorCode); - } + => FromPathAndType(!string.IsNullOrEmpty(item?.Path) ? item.Path : customPath + ?? throw new InvalidOperationException("A path is required when converting a missing storage item."), + item is null ? itemType : item.IsOfType(StorageItemTypes.Folder) ? FilesystemItemType.Directory : FilesystemItemType.File); +#endif } } diff --git a/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs b/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs index b66db47004a5..4cfb2f63dc16 100644 --- a/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs +++ b/src/Files.App/Utils/Storage/Helpers/StorageSenseHelper.cs @@ -10,6 +10,10 @@ internal sealed class StorageSenseHelper { public static async Task OpenStorageSenseAsync(string path) { +#if !WINDOWS + // LINUX-TODO(storage-sense): no system storage cleanup page to open + await Task.CompletedTask; +#else if (!path.StartsWith(Constants.UserEnvironmentPaths.SystemDrivePath, StringComparison.OrdinalIgnoreCase) && ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) { @@ -19,6 +23,7 @@ public static async Task OpenStorageSenseAsync(string path) { await Launcher.LaunchUriAsync(new Uri("ms-settings:storagesense")); } +#endif } } } diff --git a/src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.Linux.cs b/src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.Linux.cs new file mode 100644 index 000000000000..c1278b42223e --- /dev/null +++ b/src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.Linux.cs @@ -0,0 +1,12 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +namespace Files.App.Utils.Storage +{ + internal static class SyncRootHelpers + { + // LINUX-TODO(cloud): Windows cloud sync roots have no Linux equivalent; report no quota. + public static Task<(bool Success, ulong Capacity, ulong Used)> GetSyncRootQuotaAsync(string path) + => Task.FromResult((false, 0ul, 0ul)); + } +} diff --git a/src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.cs b/src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.cs rename to src/Files.App/Utils/Storage/Helpers/SyncRootHelpers.Windows.cs diff --git a/src/Files.App/Utils/Storage/History/StorageHistoryOperations.cs b/src/Files.App/Utils/Storage/History/StorageHistoryOperations.cs index bf7ab30ae2e1..998837b8efd7 100644 --- a/src/Files.App/Utils/Storage/History/StorageHistoryOperations.cs +++ b/src/Files.App/Utils/Storage/History/StorageHistoryOperations.cs @@ -10,15 +10,20 @@ namespace Files.App.Utils.Storage public sealed partial class StorageHistoryOperations : IStorageHistoryOperations { private IFilesystemHelpers? helpers; - private ShellFilesystemOperations? operations; + private IFilesystemOperations? operations; private readonly CancellationToken cancellationToken; public StorageHistoryOperations(IShellPage associatedInstance, CancellationToken cancellationToken) + : this(associatedInstance.FilesystemHelpers, FilesystemOperationsFactory.Create(associatedInstance), cancellationToken) + { + } + + internal StorageHistoryOperations(IFilesystemHelpers helpers, IFilesystemOperations operations, CancellationToken cancellationToken) { this.cancellationToken = cancellationToken; - helpers = associatedInstance.FilesystemHelpers; - operations = new ShellFilesystemOperations(associatedInstance); + this.helpers = helpers; + this.operations = operations; } public async Task Undo(IStorageHistory history) @@ -26,9 +31,13 @@ public async Task Undo(IStorageHistory history) var helpers = this.helpers ?? throw new ObjectDisposedException(nameof(StorageHistoryOperations)); var operations = this.operations ?? throw new ObjectDisposedException(nameof(StorageHistoryOperations)); ReturnResult returnStatus = ReturnResult.InProgress; +#if WINDOWS Progress progress = new(); progress.ProgressChanged += (s, e) => returnStatus = e.Status!.Value.ToStatus(); +#else + HistoryProgress progress = new(); +#endif switch (history.OperationType) { @@ -55,7 +64,13 @@ public async Task Undo(IStorageHistory history) for (int i = 0; i < renamedItems.Count; i++) { string name = Path.GetFileName(history.Source[i].Path); +#if WINDOWS await operations.RenameAsync(renamedItems[i], name, collision, progress, cancellationToken); +#else + var replayed = await operations.RenameAsync(renamedItems[i], name, collision, progress, cancellationToken); + if (replayed?.Destination is { Count: > 0 }) + history.Source[i] = replayed.Destination[0]; +#endif } } break; @@ -71,7 +86,11 @@ public async Task Undo(IStorageHistory history) var movedItems = history.Destination; if (!IsHistoryNull(history.Source) && !IsHistoryNull(movedItems)) { +#if WINDOWS return await helpers.MoveItemsAsync(movedItems, history.Source.Select(item => item.Path), false, false); +#else + return await helpers.MoveItemsAsync(movedItems, history.Source.Select(item => item.Path), false, false, replayed => UpdateHistory(history, replayed, true)); +#endif } break; case FileOperationType.Extract: // Opposite: No opposite for archive extraction @@ -82,7 +101,11 @@ public async Task Undo(IStorageHistory history) var recycledItems = history.Destination; if (!IsHistoryNull(history.Source) && !IsHistoryNull(recycledItems)) { +#if WINDOWS returnStatus = await helpers.RestoreItemsFromTrashAsync(recycledItems, history.Source.Select(item => item.Path), false); +#else + returnStatus = await helpers.RestoreItemsFromTrashAsync(recycledItems, history.Source.Select(item => item.Path), false, replayed => UpdateHistory(history, replayed, true)); +#endif if (returnStatus is ReturnResult.IntegrityCheckFailed) // Not found, corrupted { App.HistoryWrapper.RemoveHistory(history, false); @@ -101,7 +124,11 @@ public async Task Undo(IStorageHistory history) else { // We need to change the recycled item paths (since IDs are different) - for Redo() to work +#if WINDOWS App.HistoryWrapper.ModifyCurrentHistory(newHistory); +#else + UpdateHistory(history, newHistory, true); +#endif } } break; @@ -110,7 +137,11 @@ public async Task Undo(IStorageHistory history) break; } +#if WINDOWS return returnStatus; +#else + return progress.Status ?? returnStatus; +#endif } public async Task Redo(IStorageHistory history) @@ -118,9 +149,13 @@ public async Task Redo(IStorageHistory history) var helpers = this.helpers ?? throw new ObjectDisposedException(nameof(StorageHistoryOperations)); var operations = this.operations ?? throw new ObjectDisposedException(nameof(StorageHistoryOperations)); ReturnResult returnStatus = ReturnResult.InProgress; +#if WINDOWS Progress progress = new(); progress.ProgressChanged += (s, e) => { returnStatus = e.Status!.Value.ToStatus(); }; +#else + HistoryProgress progress = new(); +#endif switch (history.OperationType) { @@ -142,7 +177,13 @@ await operations.CreateShortcutItemsAsync(history.Source, for (int i = 0; i < history.Source.Count; i++) { string name = Path.GetFileName(renameDestinations[i].Path); +#if WINDOWS await operations.RenameAsync(history.Source[i], name, collision, progress, cancellationToken); +#else + var replayed = await operations.RenameAsync(history.Source[i], name, collision, progress, cancellationToken); + if (replayed?.Destination is { Count: > 0 }) + renameDestinations[i] = replayed.Destination[0]; +#endif } } break; @@ -150,14 +191,22 @@ await operations.CreateShortcutItemsAsync(history.Source, var copyDestinations = history.Destination; if (!IsHistoryNull(history.Source) && !IsHistoryNull(copyDestinations)) { +#if WINDOWS return await helpers.CopyItemsAsync(history.Source, copyDestinations.Select(item => item.Path), false, false); +#else + return await helpers.CopyItemsAsync(history.Source, copyDestinations.Select(item => item.Path), false, false, replayed => UpdateHistory(history, replayed, false)); +#endif } break; case FileOperationType.Move: var moveDestinations = history.Destination; if (!IsHistoryNull(history.Source) && !IsHistoryNull(moveDestinations)) { +#if WINDOWS return await helpers.MoveItemsAsync(history.Source, moveDestinations.Select(item => item.Path), false, false); +#else + return await helpers.MoveItemsAsync(history.Source, moveDestinations.Select(item => item.Path), false, false, replayed => UpdateHistory(history, replayed, false)); +#endif } break; case FileOperationType.Extract: @@ -184,7 +233,11 @@ await operations.CreateShortcutItemsAsync(history.Source, var restoreDestinations = history.Destination; if (!IsHistoryNull(history.Source) && !IsHistoryNull(restoreDestinations)) { +#if WINDOWS await helpers.RestoreItemsFromTrashAsync(history.Source, restoreDestinations.Select(item => item.Path), false); +#else + return await helpers.RestoreItemsFromTrashAsync(history.Source, restoreDestinations.Select(item => item.Path), false, replayed => UpdateHistory(history, replayed, false)); +#endif } break; case FileOperationType.Delete: @@ -192,7 +245,11 @@ await operations.CreateShortcutItemsAsync(history.Source, break; } +#if WINDOWS return returnStatus; +#else + return progress.Status ?? returnStatus; +#endif } public void Dispose() @@ -204,6 +261,26 @@ public void Dispose() operations = null; } +#if !WINDOWS + private static void UpdateHistory(IStorageHistory history, IStorageHistory? replayed, bool reverse) + { + if (replayed?.Destination is not null) + history.Modify(new StorageHistory(history.OperationType, + reverse ? replayed.Destination : replayed.Source, + reverse ? replayed.Source : replayed.Destination)); + } + + private sealed class HistoryProgress : IProgress + { + private StatusCenterItemProgressModel? model; + + // The model can reach its terminal status while its last notification is throttled. + public ReturnResult? Status => model?.Status?.ToStatus(); + + public void Report(StatusCenterItemProgressModel value) => model = value; + } +#endif + private static bool IsHistoryNull([NotNullWhen(false)] IEnumerable? source) => source is null || !source.All(HasPath); diff --git a/src/Files.App/Utils/Storage/History/Tests/Directory.Build.props b/src/Files.App/Utils/Storage/History/Tests/Directory.Build.props new file mode 100644 index 000000000000..fcb8cd1bc5ab --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/Directory.Build.props @@ -0,0 +1,7 @@ + + + + /tmp/files-p4-b-history/obj/ + /tmp/files-p4-b-history/bin/ + + diff --git a/src/Files.App/Utils/Storage/History/Tests/Files.StorageHistory.Tests.csproj b/src/Files.App/Utils/Storage/History/Tests/Files.StorageHistory.Tests.csproj new file mode 100644 index 000000000000..f25c6fe7fb32 --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/Files.StorageHistory.Tests.csproj @@ -0,0 +1,29 @@ + + + $(BaseTargetFramework) + Exe + false + enable + enable + $(DefineConstants);STORAGE_HISTORY_TESTS + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/Files.App/Utils/Storage/History/Tests/HistoryTestEnvironment.cs b/src/Files.App/Utils/Storage/History/Tests/HistoryTestEnvironment.cs new file mode 100644 index 000000000000..2853de95beda --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/HistoryTestEnvironment.cs @@ -0,0 +1,74 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if STORAGE_HISTORY_TESTS +global using Files.App.Data.Enums; +global using Files.App.Helpers; +global using Files.App.Utils.Storage; +global using Files.App.Utils.StatusCenter; +global using CommunityToolkit.Mvvm.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection; +global using OwlCore.Storage; +global using System.ComponentModel; +global using System.Diagnostics; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Files.App.Utils.Storage +{ + // UI-only dependencies; the operations, history, progress and platform services are linked unchanged. + public interface IShellPage + { + IFilesystemHelpers FilesystemHelpers { get; } + ShellViewModel GetRequiredShellViewModel(); + } + + public sealed class ShellViewModel + { + public Task RemoveFileOrFolderAsync(string path) => Task.CompletedTask; + } + + internal static class App + { + public static ILogger Logger { get; } = NullLogger.Instance; + public static StorageHistoryWrapper HistoryWrapper { get; } = new(); + } + + internal static class DialogDisplayHelper + { + public static Task ShowDialogAsync(string title, string message) => Task.CompletedTask; + } + + internal static class LogPathHelper + { + public static string RedactPath(string path) => path; + } + + internal static class StatusCenterHelper + { + public static void AddCard_RestoreWarning(IEnumerable paths) { } + } + + internal static class TestExtensions + { + public static List CreateList(this T item) => [item]; + public static Task> ToListAsync(this IEnumerable items) => Task.FromResult(items.ToList()); + public static string GetLocalizedResource(this string key) => key; + } + + internal static class Strings + { + public const string AccessDenied = "AccessDenied"; + public const string AccessDeniedCreateDialogText = "AccessDeniedCreateDialogText"; + public const string ErrorDialogNameNotAllowed = "ErrorDialogNameNotAllowed"; + public const string ErrorDialogThisActionCannotBeDone = "ErrorDialogThisActionCannotBeDone"; + public const string FileNotFoundDialogText = "FileNotFoundDialogText"; + public const string FileNotFoundDialogTitle = "FileNotFoundDialogTitle"; + public const string ItemAlreadyExistsDialogContent = "ItemAlreadyExistsDialogContent"; + public const string ItemAlreadyExistsDialogTitle = "ItemAlreadyExistsDialogTitle"; + public const string RenameErrorItemDeletedText = "RenameErrorItemDeletedText"; + public const string RenameErrorItemDeletedTitle = "RenameErrorItemDeletedTitle"; + } +} +#endif diff --git a/src/Files.App/Utils/Storage/History/Tests/HistoryTestHelpers.cs b/src/Files.App/Utils/Storage/History/Tests/HistoryTestHelpers.cs new file mode 100644 index 000000000000..43ed8bc1258e --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/HistoryTestHelpers.cs @@ -0,0 +1,97 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if STORAGE_HISTORY_TESTS +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; + +namespace Files.App.Utils.Storage +{ + internal sealed class HistoryTestHelpers(LinuxFilesystemOperations operations) : IFilesystemHelpers + { + public DeleteConfirmationPolicies? LastDeleteConfirmation { get; private set; } + public bool RegisteredHistory { get; private set; } + + private sealed class Progress : IProgress + { + private StatusCenterItemProgressModel? model; + public ReturnResult Status => model?.Status?.ToStatus() ?? ReturnResult.InProgress; + public void Report(StatusCenterItemProgressModel value) => model = value; + } + + public async Task DeleteItemsAsync(IEnumerable source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory) + { + LastDeleteConfirmation = showDialog; + RegisteredHistory |= registerHistory; + var progress = new Progress(); + await operations.DeleteItemsAsync(source.ToList(), progress, permanently, CancellationToken.None); + return progress.Status; + } + + public async Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory, Action? historyCallback = null) + { + RegisteredHistory |= registerHistory; + var progress = new Progress(); + var history = await operations.CopyItemsAsync(source.ToList(), destination.ToList(), source.Select(_ => FileNameConflictResolveOptionType.GenerateNewName).ToList(), progress, CancellationToken.None); + historyCallback?.Invoke(history); + return progress.Status; + } + + public async Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory, Action? historyCallback = null) + { + RegisteredHistory |= registerHistory; + var progress = new Progress(); + var history = await operations.MoveItemsAsync(source.ToList(), destination.ToList(), source.Select(_ => FileNameConflictResolveOptionType.GenerateNewName).ToList(), progress, CancellationToken.None); + historyCallback?.Invoke(history); + return progress.Status; + } + + public async Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory, Action? historyCallback = null) + { + RegisteredHistory |= registerHistory; + var progress = new Progress(); + var history = await operations.RestoreItemsFromTrashAsync(source.ToList(), destination.ToList(), progress, CancellationToken.None); + historyCallback?.Invoke(history); + return progress.Status; + } + + public void Dispose() { } + + public Task<(ReturnResult, IStorageItem?)> CreateAsync(IStorageItemWithPath source, bool registerHistory) + => throw new NotSupportedException(); + + public Task DeleteItemAsync(IStorageItemWithPath source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory) + => throw new NotSupportedException(); + + public Task RestoreItemFromTrashAsync(IStorageItemWithPath source, string destination, bool registerHistory) + => throw new NotSupportedException(); + + public Task PerformOperationTypeAsync(DataPackageOperation operation, DataPackageView packageView, string destination, bool showDialog, bool registerHistory, bool isDestinationExecutable = false, bool isDestinationScript = false) + => throw new NotSupportedException(); + + public Task PerformOperationTypeAsync(IReadOnlyList sourcePaths, DataPackageOperation operation, string destination, bool showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task CopyItemAsync(IStorageItemWithPath source, string destination, bool showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task CopyItemsFromClipboard(DataPackageView packageView, string destination, bool showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task RecycleItemsFromClipboard(DataPackageView packageView, string destination, DeleteConfirmationPolicies showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task CreateShortcutFromClipboard(DataPackageView packageView, string destination, bool showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task MoveItemAsync(IStorageItemWithPath source, string destination, bool showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task MoveItemsFromClipboard(DataPackageView packageView, string destination, bool showDialog, bool registerHistory) + => throw new NotSupportedException(); + + public Task RenameAsync(IStorageItemWithPath source, string newName, NameCollisionOption collision, bool registerHistory, bool showExtensionDialog = true) + => throw new NotSupportedException(); + } +} +#endif diff --git a/src/Files.App/Utils/Storage/History/Tests/LinuxStorageHistoryTests.cs b/src/Files.App/Utils/Storage/History/Tests/LinuxStorageHistoryTests.cs new file mode 100644 index 000000000000..c8919351c1df --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/LinuxStorageHistoryTests.cs @@ -0,0 +1,269 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if STORAGE_HISTORY_TESTS +using Files.Platform.Linux.FileOperations; +using Files.Platform.Linux.Trash; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Storage; + +namespace Files.App.Utils.Storage +{ + [TestClass] + public sealed class LinuxStorageHistoryTests + { + private string root = null!; + private LinuxTrashService trash = null!; + private LinuxFilesystemOperations operations = null!; + private HistoryTestHelpers helpers = null!; + private StorageHistoryOperations historyOperations = null!; + + private sealed class Progress : IProgress + { + public void Report(StatusCenterItemProgressModel value) { } + } + + private readonly Progress progress = new(); + + [TestInitialize] + public void Setup() + { + root = Path.Combine(Path.GetTempPath(), "files-history-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "source")); + Directory.CreateDirectory(Path.Combine(root, "destination")); + trash = new LinuxTrashService(new LinuxTrashOptions { DataHome = Path.Combine(root, "data") }); + operations = new LinuxFilesystemOperations(new LinuxFileOperationsService(), trash); + helpers = new HistoryTestHelpers(operations); + historyOperations = new StorageHistoryOperations(helpers, operations, CancellationToken.None); + App.HistoryWrapper.Dispose(); + } + + [TestCleanup] + public void Cleanup() + { + historyOperations.Dispose(); + trash.Watcher.Dispose(); + App.HistoryWrapper.Dispose(); + Directory.Delete(root, true); + } + + private StorableWithPath CreateSource(string name = "file.txt") + { + var path = Path.Combine(root, "source", name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "original content"); + return new StorableWithPath(path, FilesystemItemType.File); + } + + private string Destination(string name = "file.txt") => Path.Combine(root, "destination", name); + + private static IStorageHistory RequireHistory(IStorageHistory? history, FileOperationType type, string source, string destination) + { + Assert.IsNotNull(history); + Assert.AreEqual(type, history.OperationType); + Assert.HasCount(1, history.Source); + Assert.AreEqual(source, history.Source[0].Path); + Assert.IsNotNull(history.Destination); + Assert.HasCount(1, history.Destination); + Assert.AreEqual(destination, history.Destination[0].Path); + Assert.IsInstanceOfType(history.Destination[0]); + Assert.IsNull(history.Destination[0].Item); + return history; + } + + [TestMethod] + public async Task UndoCopyDeletesOnlyTheCopyAndRedoCopiesAgain() + { + var source = CreateSource(); + var history = RequireHistory(await operations.CopyAsync(source, Destination(), NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None), FileOperationType.Copy, source.Path, Destination()); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + Assert.IsFalse(File.Exists(Destination())); + Assert.AreEqual(DeleteConfirmationPolicies.Always, helpers.LastDeleteConfirmation); + Assert.IsFalse(helpers.RegisteredHistory); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.AreEqual("original content", File.ReadAllText(Destination())); + } + + [TestMethod] + public async Task CopyHistoryUsesTheGeneratedDestinationWithoutDeletingTheExistingFile() + { + var source = CreateSource(); + File.WriteAllText(Destination(), "existing content"); + var history = await operations.CopyAsync(source, Destination(), NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None); + Assert.IsNotNull(history?.Destination); + var copiedPath = history.Destination[0].Path; + Assert.AreNotEqual(Destination(), copiedPath); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.IsFalse(File.Exists(copiedPath)); + Assert.AreEqual("existing content", File.ReadAllText(Destination())); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + } + + [TestMethod] + public async Task UndoMoveRestoresTheSourceAndRedoMovesAgain() + { + var source = CreateSource(); + var history = RequireHistory(await operations.MoveAsync(source, Destination(), NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None), FileOperationType.Move, source.Path, Destination()); + Assert.IsFalse(File.Exists(source.Path)); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + Assert.IsFalse(File.Exists(Destination())); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.IsFalse(File.Exists(source.Path)); + Assert.AreEqual("original content", File.ReadAllText(Destination())); + } + + [TestMethod] + public async Task UndoRenameRestoresTheNameAndReportsSuccessBeforeReturning() + { + var source = CreateSource(); + var renamed = Path.Combine(root, "source", "renamed.txt"); + var history = RequireHistory(await operations.RenameAsync(source, "renamed.txt", NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None), FileOperationType.Rename, source.Path, renamed); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.IsFalse(File.Exists(renamed)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.IsFalse(File.Exists(source.Path)); + Assert.AreEqual("original content", File.ReadAllText(renamed)); + } + + [TestMethod] + public async Task UndoTrashRestoresTheFileAndRedoRefreshesTheTrashIdentity() + { + var source = CreateSource(); + var history = await operations.DeleteAsync(source, progress, false, CancellationToken.None); + Assert.IsNotNull(history?.Destination); + var oldTrashPath = history.Destination[0].Path; + RequireHistory(history, FileOperationType.Recycle, source.Path, oldTrashPath); + Assert.IsTrue(trash.IsUnderTrash(oldTrashPath)); + App.HistoryWrapper.AddHistory(history); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + Assert.IsFalse(File.Exists(oldTrashPath)); + + var other = CreateSource(Path.Combine("other", "file.txt")); + await operations.DeleteAsync(other, progress, false, CancellationToken.None); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.AreNotEqual(oldTrashPath, history.Destination[0].Path); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + } + + [TestMethod] + public async Task RestoreHistoryCanBeUndoneAndRedoneWithANewTrashIdentity() + { + var source = CreateSource(); + var recycled = await operations.DeleteAsync(source, progress, false, CancellationToken.None); + Assert.IsNotNull(recycled?.Destination); + var history = RequireHistory(await operations.RestoreFromTrashAsync(recycled.Destination[0], source.Path, progress, CancellationToken.None), FileOperationType.Restore, recycled.Destination[0].Path, source.Path); + App.HistoryWrapper.AddHistory(history); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.AreEqual(FileOperationType.Restore, history.OperationType); + Assert.IsFalse(File.Exists(source.Path)); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + } + + [TestMethod] + public async Task CopyRedoUpdatesTheDestinationBeforeTheNextUndo() + { + var source = CreateSource(); + var history = await operations.CopyAsync(source, Destination(), NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None); + Assert.IsNotNull(history); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + File.WriteAllText(Destination(), "unrelated content"); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.IsNotNull(history.Destination); + var copiedPath = history.Destination[0].Path; + Assert.AreNotEqual(Destination(), copiedPath); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.IsFalse(File.Exists(copiedPath)); + Assert.AreEqual("unrelated content", File.ReadAllText(Destination())); + } + + [TestMethod] + public async Task RenameUndoWithAnOccupiedOriginalTracksTheActualPathForRedo() + { + var source = CreateSource(); + var renamed = Path.Combine(root, "source", "renamed.txt"); + var history = await operations.RenameAsync(source, "renamed.txt", NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None); + Assert.IsNotNull(history); + File.WriteAllText(source.Path, "unrelated content"); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.AreNotEqual(source.Path, history.Source[0].Path); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.AreEqual("original content", File.ReadAllText(renamed)); + Assert.AreEqual("unrelated content", File.ReadAllText(source.Path)); + } + + [TestMethod] + public async Task TrashUndoWithAnOccupiedOriginalTracksTheActualPathForRedo() + { + var source = CreateSource(); + var history = await operations.DeleteAsync(source, progress, false, CancellationToken.None); + Assert.IsNotNull(history); + App.HistoryWrapper.AddHistory(history); + File.WriteAllText(source.Path, "unrelated content"); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + var restoredPath = history.Source[0].Path; + Assert.AreNotEqual(source.Path, restoredPath); + Assert.AreEqual("original content", File.ReadAllText(restoredPath)); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.IsFalse(File.Exists(restoredPath)); + Assert.AreEqual("unrelated content", File.ReadAllText(source.Path)); + } + + [TestMethod] + public async Task MoveUndoWithAnOccupiedOriginalTracksTheActualPathForRedo() + { + var source = CreateSource(); + var history = await operations.MoveAsync(source, Destination(), NameCollisionOption.GenerateUniqueName, progress, CancellationToken.None); + Assert.IsNotNull(history); + File.WriteAllText(source.Path, "unrelated content"); + + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + var restoredPath = history.Source[0].Path; + Assert.AreNotEqual(source.Path, restoredPath); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Redo(history)); + Assert.IsFalse(File.Exists(restoredPath)); + Assert.AreEqual("original content", File.ReadAllText(Destination())); + Assert.AreEqual("unrelated content", File.ReadAllText(source.Path)); + } + + [TestMethod] + public async Task PermanentDeleteRecordsOnlySuccessfulSourcesAndNoDestination() + { + var source = CreateSource(); + var history = await operations.DeleteAsync(source, progress, true, CancellationToken.None); + Assert.IsNotNull(history); + Assert.AreEqual(FileOperationType.Delete, history.OperationType); + Assert.AreEqual(source.Path, history.Source[0].Path); + Assert.IsNull(history.Destination); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history)); + Assert.IsFalse(File.Exists(source.Path)); + } + + [TestMethod] + public async Task PartialCopyFailureRecordsOnlyTheSuccessfulPair() + { + var source = CreateSource(); + var missing = new StorableWithPath(Path.Combine(root, "source", "missing.txt"), FilesystemItemType.File); + var history = await operations.CopyItemsAsync([source, missing], [Destination(), Destination("missing.txt")], [FileNameConflictResolveOptionType.GenerateNewName, FileNameConflictResolveOptionType.GenerateNewName], progress, CancellationToken.None); + RequireHistory(history, FileOperationType.Copy, source.Path, Destination()); + Assert.AreEqual(ReturnResult.Success, await historyOperations.Undo(history!)); + Assert.AreEqual("original content", File.ReadAllText(source.Path)); + } + } +} +#endif diff --git a/src/Files.App/Utils/Storage/History/Tests/README.md b/src/Files.App/Utils/Storage/History/Tests/README.md new file mode 100644 index 000000000000..036160f50e0b --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/README.md @@ -0,0 +1,16 @@ +# Linux storage history tests + +Source-links the app's Linux operations, history replay, progress model and path contracts. +Only UI dependencies are test facades; file operations and trash use the real Linux services +with temporary files and an isolated trash data directory. No display or real user settings are used. + +Run from the repository root, sequentially: + +```bash +MSBUILDDISABLENODEREUSE=1 nice -n 19 dotnet build src/Files.App/Utils/Storage/History/Tests/Files.StorageHistory.Tests.csproj -nodeReuse:false -m:2 -warnaserror +nice -n 19 dotnet test --project src/Files.App/Utils/Storage/History/Tests/Files.StorageHistory.Tests.csproj --no-build +``` + +The test sources compile only with `STORAGE_HISTORY_TESTS`. Generated files are kept under +`/tmp/files-p4-b-history` so the app's source glob cannot include them. This project stays +inside P4-B ownership and runs alongside `tests/Files.Platform.Tests`. diff --git a/src/Files.App/Utils/Storage/History/Tests/VERIFICATION.md b/src/Files.App/Utils/Storage/History/Tests/VERIFICATION.md new file mode 100644 index 000000000000..a03794bc25d8 --- /dev/null +++ b/src/Files.App/Utils/Storage/History/Tests/VERIFICATION.md @@ -0,0 +1,39 @@ +# P4-B verification + +- Normal desktop build: passed (`net10.0-desktop`, low priority, two MSBuild workers, node reuse disabled). +- History project: build passed with warnings as errors; 12 tests passed. Covers copy/move/rename/trash undo, + redo, restore identity, permanent deletion, partial failure, and conflicts preserving unrelated files. +- Platform project: build passed with warnings as errors. Suite: 720 passed, 4 failed, 7 skipped (731 total). + The four failures are the installed-font render test and three `TrustedNativeDirectory` tests. + In this sandbox `/`, `/usr` and `/home` have UID 65534; those unmodified security checks require root/current-user + ownership. Xvfb is unavailable, so seven clipboard integration tests skip. The platform suite does not compile + the app files changed by this work package. The full platform-test gate needs a run outside this sandbox. +- Compat-off ratchet: passed, no new keys; 46 remaining, 11 fixed. Baseline left unchanged. +- Owned-source coupling scan: zero matches for `BaseStorage|StorageItems\.|Windows\.Win32|PInvoke\.|Win32Helper\.` + outside `*.Windows.cs`, excluding P4-K's `FileSizeCalculator.cs`. +- The three Windows implementation moves are byte-identical to the parent commit, retaining their original + LF endings. Edited and new sources use CRLF/tabs. Windows execution/build and UI smoke were not verified here. + +## Fixed compat-off keys + +```text +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0103: The name 'SHOW_WINDOW_CMD' does not exist in the current context +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HRESULT' is inaccessible due to its protection level +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0122: 'HWND' is inaccessible due to its protection level +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'FILEOPERATION_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?) +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'PROPERTYKEY' does not exist in the namespace 'Windows.Win32.Foundation' (are you missing an assembly reference?) +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0234: The type or namespace name 'SLR_FLAGS' does not exist in the namespace 'Windows.Win32.UI.Shell' (are you missing an assembly reference?) +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'Disposable' could not be found (are you missing a using directive or an assembly reference?) +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'SHOW_WINDOW_CMD' could not be found (are you missing a using directive or an assembly reference?) +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellFileOperations2' could not be found (are you missing a using directive or an assembly reference?) +src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs: error CS0246: The type or namespace name 'ShellItem' could not be found (are you missing a using directive or an assembly reference?) +src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs: error CS0234: The type or namespace name 'FileSystem' does not exist in the namespace 'Windows.Win32.Storage' (are you missing an assembly reference?) +``` + +## Remaining seams + +The desktop compatibility facade marks Windows shortcut parsing/editing/icons, principal picking and shutdown +operation tracking with `LINUX-TODO`. The creation result still uses the existing `IStorageItem` return contract +until P4-H migrates `UIFilesystemHelpers`; operation inputs and history entries stay on `IStorageItemWithPath`. +Existing limitations remain: transfer staging can clash when a requested name differs from the source name, +and trash restore honours the recorded original location rather than an arbitrary destination. diff --git a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.Linux.cs b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.Linux.cs new file mode 100644 index 000000000000..634f7b0428b1 --- /dev/null +++ b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.Linux.cs @@ -0,0 +1,57 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if !WINDOWS +using Files.Platform.Abstractions.Trash; +using Windows.ApplicationModel.DataTransfer; + +namespace Files.App.Utils.Storage +{ + public sealed partial class FileOperationsHelpers + { + public static Task SetClipboard(string[] filesToCopy, DataPackageOperation operation) + { + FileClipboard.Set(filesToCopy, operation); + return Services.Desktop.DesktopFileDragHelper.PublishFilesAsync(filesToCopy, operation); + } + + public static Task<(bool, ShellOperationResult)> TestRecycleAsync(string[] fileToDeletePath) + { + var trash = Ioc.Default.GetRequiredService(); + var result = new ShellOperationResult(); + foreach (var path in fileToDeletePath) + result.Items.Add(new ShellOperationItemResult { Source = path, Succeeded = trash.IsSupported(path) }); + return Task.FromResult((result.Items.All(item => item.Succeeded), result)); + } + + public static Task ParseLinkAsync(string linkPath, bool resolveTarget = true) + { + // LINUX-TODO(shortcuts): Windows shortcut parsing is unavailable on desktop. + return Task.FromResult(null); + } + + public static Task CreateOrUpdateLinkAsync(string linkSavePath, string? targetPath, string? arguments = "", string? workingDirectory = "", bool runAsAdmin = false, object? showWindowCommand = null) + { + // LINUX-TODO(shortcuts): legacy .lnk/.url editing needs a desktop shortcut capability. + return Task.FromResult(false); + } + + public static bool SetLinkIcon(string filePath, string? iconFile, int iconIndex) + { + // LINUX-TODO(shortcuts): Windows shortcut icons are unavailable on desktop. + return false; + } + + public static Task OpenObjectPickerAsync(long hWnd) + { + // LINUX-TODO(security): the Windows principal picker is unavailable on desktop. + return Task.FromResult(null); + } + + public static void WaitForCompletion() + { + // LINUX-TODO(fileops): desktop shutdown needs platform operation lifetime tracking. + } + } +} +#endif diff --git a/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs b/src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.cs rename to src/Files.App/Utils/Storage/Operations/FileOperationsHelpers.Windows.cs diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.Linux.cs b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.Linux.cs new file mode 100644 index 000000000000..df19516d0ab3 --- /dev/null +++ b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.Linux.cs @@ -0,0 +1,49 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if !WINDOWS +using Files.Platform.Linux.Clipboard; +using Microsoft.Extensions.Logging; +using System.IO; +using Windows.ApplicationModel.DataTransfer; + +namespace Files.App.Utils.Storage +{ + public sealed partial class FilesystemHelpers + { + public static bool HasDraggedStorageItems(DataPackageView packageView) + => packageView is not null && (packageView.Contains(StandardDataFormats.StorageItems) || packageView.Contains(ClipboardFormats.UriList)); + + public static async Task> GetDraggedStorageItems(DataPackageView packageView) + { + try + { + IEnumerable paths; + if (packageView.Contains(ClipboardFormats.UriList)) + { + var data = await packageView.GetDataAsync(ClipboardFormats.UriList); + paths = data is string text ? ClipboardFormats.ParseUriList(text) : []; + } + else if (packageView.Contains(StandardDataFormats.StorageItems)) + { + // Uno exposes inbound XDND paths through this transport; keep only the paths. + paths = (await packageView.GetStorageItemsAsync()).Select(item => item.Path); + } + else + { + return []; + } + + return paths.Where(Path.IsPathFullyQualified).Distinct(StringComparer.Ordinal) + .Select(path => new StorableWithPath(path, Directory.Exists(path) ? FilesystemItemType.Directory : FilesystemItemType.File)) + .ToList(); + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Could not read dragged file paths."); + return []; + } + } + } +} +#endif diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.Windows.cs b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.Windows.cs new file mode 100644 index 000000000000..590e6293d515 --- /dev/null +++ b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.Windows.cs @@ -0,0 +1,140 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.IO; +using System.Runtime.InteropServices; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage.Streams; +using Windows.Win32; +using Windows.Win32.Storage.FileSystem; +using Windows.Win32.UI.Shell; +using FileAttributes = System.IO.FileAttributes; + +namespace Files.App.Utils.Storage +{ + public sealed partial class FilesystemHelpers + { + public static bool HasDraggedStorageItems(DataPackageView packageView) + { + return packageView is not null && (packageView.Contains(StandardDataFormats.StorageItems) || packageView.Contains("FileDrop")); + } + + public static async Task> GetDraggedStorageItems(DataPackageView packageView) + { + var itemsList = new List(); + var hasVirtualItems = false; + + if (packageView.Contains(StandardDataFormats.StorageItems)) + { + try + { + var source = await packageView.GetStorageItemsAsync(); + itemsList.AddRange(source.Select(item => item.FromStorageItem() + ?? throw new InvalidOperationException("A dragged storage item could not be converted."))); + } + catch (Exception ex) when ((uint)ex.HResult == 0x80040064 || (uint)ex.HResult == 0x8004006A) + { + hasVirtualItems = true; + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, ex.Message); + return itemsList; + } + } + + // workaround for pasting folders from remote desktop (#12318) + try + { + if (hasVirtualItems && packageView.Contains("FileContents")) + { + var dataObject = ShellDataObject.GetClipboard(); + if (dataObject is null) + return itemsList; + + var descriptors = ShellDataObject.GetFileDescriptors(dataObject); + for (var ii = 0; ii < descriptors.Count; ii++) + { + var descriptor = descriptors[ii]; + if (descriptor.Attributes.HasFlag(FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY)) + itemsList.Add(new VirtualStorageFolder(descriptor.Name).FromStorageItem()!); + else if (ShellDataObject.TryGetFileContents(dataObject, ii, out var stream, out var medium)) + { + var streamContent = new ComStreamWrapper(stream!, medium); + itemsList.Add(new VirtualStorageFile(streamContent, descriptor.Name).FromStorageItem()!); + } + } + } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, ex.Message); + } + + // workaround for GetStorageItemsAsync() bug that only yields 16 items at most + // https://learn.microsoft.com/windows/win32/shell/clipboard#cf_hdrop + if (packageView.Contains("FileDrop")) + { + var fileDropData = await SafetyExtensions.IgnoreExceptions( + () => packageView.GetDataAsync("FileDrop").AsTask()); + if (fileDropData is IRandomAccessStream stream) + { + stream.Seek(0); + + byte[]? dropBytes = null; + int bytesRead = 0; + try + { + dropBytes = new byte[stream.Size]; + bytesRead = await stream.AsStreamForRead().ReadAsync(dropBytes); + } + catch (COMException) + { + } + + if (bytesRead > 0) + { + IntPtr dropStructPointer = Marshal.AllocHGlobal(dropBytes!.Length); + + try + { + Marshal.Copy(dropBytes, 0, dropStructPointer, dropBytes.Length); + HDROP dropStructHandle = new(dropStructPointer); + + var itemPaths = new List(); + uint filesCount = PInvoke.DragQueryFile(dropStructHandle, uint.MaxValue, Span.Empty); + for (uint i = 0; i < filesCount; i++) + { + uint charsNeeded = PInvoke.DragQueryFile(dropStructHandle, i, Span.Empty); + uint bufferSpaceRequired = charsNeeded + 1; // include space for terminating null character + char[] buffer = new char[bufferSpaceRequired]; + uint charsCopied = PInvoke.DragQueryFile(dropStructHandle, i, buffer); + + if (charsCopied > 0) + { + string path = new(buffer, 0, (int)charsCopied); + itemPaths.Add(Path.GetFullPath(path)); + } + } + + foreach (var path in itemPaths) + { + var isDirectory = Win32Helper.HasFileAttribute(path, FileAttributes.Directory); + itemsList.Add(StorageHelpers.FromPathAndType(path, isDirectory ? FilesystemItemType.Directory : FilesystemItemType.File)); + } + } + finally + { + Marshal.FreeHGlobal(dropStructPointer); + } + } + } + } + + itemsList = itemsList.DistinctBy(x => string.IsNullOrEmpty(x.Path) ? x.Name : x.Path).ToList(); + return itemsList; + } + + } +} diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs index f3ad8aa9ebae..feb5a8ae1a0d 100644 --- a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs @@ -8,11 +8,6 @@ using Windows.ApplicationModel.DataTransfer; using Windows.Graphics.Imaging; using Windows.Storage; -using Windows.Storage.Streams; -using Windows.Win32; -using Windows.Win32.Storage.FileSystem; -using Windows.Win32.UI.Shell; -using FileAttributes = System.IO.FileAttributes; namespace Files.App.Utils.Storage { @@ -60,9 +55,7 @@ public FilesystemHelpers(IShellPage associatedInstance, CancellationToken cancel this.associatedInstance = associatedInstance; this.cancellationToken = cancellationToken; jumpListService = Ioc.Default.GetRequiredService(); - filesystemOperations = OperatingSystem.IsLinux() - ? new LinuxFilesystemOperations(this.associatedInstance) - : new ShellFilesystemOperations(this.associatedInstance); + filesystemOperations = FilesystemOperationsFactory.Create(this.associatedInstance); } public async Task<(ReturnResult, IStorageItem?)> CreateAsync(IStorageItemWithPath source, bool registerHistory) { @@ -189,26 +182,38 @@ showDialog is DeleteConfirmationPolicies.PermanentOnly && public Task DeleteItemAsync(IStorageItemWithPath source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory) => DeleteItemsAsync(source.CreateEnumerable(), showDialog, permanently, registerHistory); +#if WINDOWS public Task DeleteItemsAsync(IEnumerable source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory) => DeleteItemsAsync(source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for deletion.")), showDialog, permanently, registerHistory); +#endif +#if WINDOWS public Task DeleteItemAsync(IStorageItem source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory) => DeleteItemAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for deletion."), showDialog, permanently, registerHistory); +#endif +#if WINDOWS public Task RestoreItemFromTrashAsync(IStorageItem source, string destination, bool registerHistory) => RestoreItemFromTrashAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for restoration."), destination, registerHistory); +#endif +#if WINDOWS public Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory) => RestoreItemsFromTrashAsync(source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for restoration.")), destination, registerHistory); +#endif public Task RestoreItemFromTrashAsync(IStorageItemWithPath source, string destination, bool registerHistory) => RestoreItemsFromTrashAsync(source.CreateEnumerable(), destination.CreateEnumerable(), registerHistory); - public async Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory) + public async Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory +#if !WINDOWS + , Action? historyCallback = null +#endif + ) { source = await source.ToListAsync(); destination = await destination.ToListAsync(); @@ -231,6 +236,10 @@ public async Task RestoreItemsFromTrashAsync(IEnumerable PerformOperationTypeAsync( : await CopyItemsAsync(items, destinations, showDialog, registerHistory); } +#if WINDOWS public Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory) => CopyItemsAsync(source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for copying.")), destination, showDialog, registerHistory); +#endif +#if WINDOWS public Task CopyItemAsync(IStorageItem source, string destination, bool showDialog, bool registerHistory) => CopyItemAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for copying."), destination, showDialog, registerHistory); +#endif - public async Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory) + public async Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory +#if !WINDOWS + , Action? historyCallback = null +#endif + ) { source = await source.ToListAsync(); destination = await destination.ToListAsync(); @@ -403,6 +420,10 @@ public async Task CopyItemsAsync(IEnumerable destination, itemsCount); +#if !WINDOWS + historyCallback?.Invoke(history); +#endif + return returnStatus; } @@ -475,15 +496,23 @@ public async Task CopyItemsFromClipboard(DataPackageView packageVi return ReturnResult.BadArgumentException; } +#if WINDOWS public Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory) => MoveItemsAsync(source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for moving.")), destination, showDialog, registerHistory); +#endif +#if WINDOWS public Task MoveItemAsync(IStorageItem source, string destination, bool showDialog, bool registerHistory) => MoveItemAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for moving."), destination, showDialog, registerHistory); +#endif - public async Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory) + public async Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory +#if !WINDOWS + , Action? historyCallback = null +#endif + ) { source = await source.ToListAsync(); destination = await destination.ToListAsync(); @@ -555,6 +584,10 @@ public async Task MoveItemsAsync(IEnumerable destination, itemsCount); +#if !WINDOWS + historyCallback?.Invoke(history); +#endif + return returnStatus; } @@ -597,9 +630,11 @@ public async Task MoveItemsFromClipboard(DataPackageView packageVi return returnStatus; } +#if WINDOWS public Task RenameAsync(IStorageItem source, string newName, NameCollisionOption collision, bool registerHistory, bool showExtensionDialog = true) => RenameAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for renaming."), newName, collision, registerHistory, showExtensionDialog); +#endif public async Task RenameAsync(IStorageItemWithPath source, string newName, NameCollisionOption collision, bool registerHistory, bool showExtensionDialog = true) { @@ -798,127 +833,6 @@ await Ioc.Default.GetRequiredService().TryGetFileAsync(dest) return (newCollisions, false, itemsResult ?? new List()); } - public static bool HasDraggedStorageItems(DataPackageView packageView) - { - return packageView is not null && (packageView.Contains(StandardDataFormats.StorageItems) || packageView.Contains("FileDrop")); - } - - public static async Task> GetDraggedStorageItems(DataPackageView packageView) - { - var itemsList = new List(); - var hasVirtualItems = false; - - if (packageView.Contains(StandardDataFormats.StorageItems)) - { - try - { - var source = await packageView.GetStorageItemsAsync(); - itemsList.AddRange(source.Select(item => item.FromStorageItem() - ?? throw new InvalidOperationException("A dragged storage item could not be converted."))); - } - catch (Exception ex) when ((uint)ex.HResult == 0x80040064 || (uint)ex.HResult == 0x8004006A) - { - hasVirtualItems = true; - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, ex.Message); - return itemsList; - } - } - - // workaround for pasting folders from remote desktop (#12318) - try - { - if (hasVirtualItems && packageView.Contains("FileContents")) - { - var dataObject = ShellDataObject.GetClipboard(); - if (dataObject is null) - return itemsList; - - var descriptors = ShellDataObject.GetFileDescriptors(dataObject); - for (var ii = 0; ii < descriptors.Count; ii++) - { - var descriptor = descriptors[ii]; - if (descriptor.Attributes.HasFlag(FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY)) - itemsList.Add(new VirtualStorageFolder(descriptor.Name).FromStorageItem()!); - else if (ShellDataObject.TryGetFileContents(dataObject, ii, out var stream, out var medium)) - { - var streamContent = new ComStreamWrapper(stream!, medium); - itemsList.Add(new VirtualStorageFile(streamContent, descriptor.Name).FromStorageItem()!); - } - } - } - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, ex.Message); - } - - // workaround for GetStorageItemsAsync() bug that only yields 16 items at most - // https://learn.microsoft.com/windows/win32/shell/clipboard#cf_hdrop - if (packageView.Contains("FileDrop")) - { - var fileDropData = await SafetyExtensions.IgnoreExceptions( - () => packageView.GetDataAsync("FileDrop").AsTask()); - if (fileDropData is IRandomAccessStream stream) - { - stream.Seek(0); - - byte[]? dropBytes = null; - int bytesRead = 0; - try - { - dropBytes = new byte[stream.Size]; - bytesRead = await stream.AsStreamForRead().ReadAsync(dropBytes); - } - catch (COMException) - { - } - - if (bytesRead > 0) - { - IntPtr dropStructPointer = Marshal.AllocHGlobal(dropBytes!.Length); - - try - { - Marshal.Copy(dropBytes, 0, dropStructPointer, dropBytes.Length); - HDROP dropStructHandle = new(dropStructPointer); - - var itemPaths = new List(); - uint filesCount = PInvoke.DragQueryFile(dropStructHandle, uint.MaxValue, Span.Empty); - for (uint i = 0; i < filesCount; i++) - { - uint charsNeeded = PInvoke.DragQueryFile(dropStructHandle, i, Span.Empty); - uint bufferSpaceRequired = charsNeeded + 1; // include space for terminating null character - char[] buffer = new char[bufferSpaceRequired]; - uint charsCopied = PInvoke.DragQueryFile(dropStructHandle, i, buffer); - - if (charsCopied > 0) - { - string path = new(buffer, 0, (int)charsCopied); - itemPaths.Add(Path.GetFullPath(path)); - } - } - - foreach (var path in itemPaths) - { - var isDirectory = Win32Helper.HasFileAttribute(path, FileAttributes.Directory); - itemsList.Add(StorageHelpers.FromPathAndType(path, isDirectory ? FilesystemItemType.Directory : FilesystemItemType.File)); - } - } - finally - { - Marshal.FreeHGlobal(dropStructPointer); - } - } - } - } - - itemsList = itemsList.DistinctBy(x => string.IsNullOrEmpty(x.Path) ? x.Name : x.Path).ToList(); - return itemsList; - } - public static string FilterRestrictedCharacters(string input) { int invalidCharIndex; diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemOperations.cs b/src/Files.App/Utils/Storage/Operations/FilesystemOperations.Windows.cs similarity index 98% rename from src/Files.App/Utils/Storage/Operations/FilesystemOperations.cs rename to src/Files.App/Utils/Storage/Operations/FilesystemOperations.Windows.cs index 8c6cce68274a..84349aa0f8fa 100644 --- a/src/Files.App/Utils/Storage/Operations/FilesystemOperations.cs +++ b/src/Files.App/Utils/Storage/Operations/FilesystemOperations.Windows.cs @@ -238,7 +238,7 @@ fsSourceFolder.Result is BaseStorageFolder sourceFolder && } else if (source.ItemType == FilesystemItemType.File) { - var fsResult = (FilesystemResult)await Task.Run(() => PInvoke.CopyFileFromApp(source.Path, destination, true)); + var fsResult = (FilesystemResult)(bool)await Task.Run(() => PInvoke.CopyFileFromApp(source.Path, destination, true)); if (!fsResult) { @@ -400,7 +400,7 @@ await DialogDisplayHelper.ShowDialogAsync( } else { - var fsResult = (FilesystemResult)await Task.Run(() => PInvoke.MoveFileFromApp(source.Path, destination)); + var fsResult = (FilesystemResult)(bool)await Task.Run(() => PInvoke.MoveFileFromApp(source.Path, destination)); if (!fsResult) { @@ -468,7 +468,7 @@ fsSourceFolder.Result is BaseStorageFolder sourceFolder && } else if (source.ItemType == FilesystemItemType.File) { - var fsResult = (FilesystemResult)await Task.Run(() => PInvoke.MoveFileFromApp(source.Path, destination)); + var fsResult = (FilesystemResult)(bool)await Task.Run(() => PInvoke.MoveFileFromApp(source.Path, destination)); if (!fsResult) { @@ -553,7 +553,7 @@ sourceResult.Result is BaseStorageFile file && if (permanently) { - fsResult = (FilesystemResult)PInvoke.DeleteFileFromApp(source.Path); + fsResult = (FilesystemResult)(bool)PInvoke.DeleteFileFromApp(source.Path); } if (!fsResult) { @@ -801,7 +801,7 @@ await storageHistory.SelectMany(item => item.Source).ToListAsync(), FilesystemResult fsResult = FileSystemStatusCode.InProgress; - fsResult = (FilesystemResult)await Task.Run(() => PInvoke.MoveFileFromApp(source.Path, destination)); + fsResult = (FilesystemResult)(bool)await Task.Run(() => PInvoke.MoveFileFromApp(source.Path, destination)); if (!fsResult) { diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemOperationsFactory.cs b/src/Files.App/Utils/Storage/Operations/FilesystemOperationsFactory.cs new file mode 100644 index 000000000000..8ef287cab0c2 --- /dev/null +++ b/src/Files.App/Utils/Storage/Operations/FilesystemOperationsFactory.cs @@ -0,0 +1,17 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +namespace Files.App.Utils.Storage +{ + internal static class FilesystemOperationsFactory + { + public static IFilesystemOperations Create(IShellPage? associatedInstance) + { +#if WINDOWS + return new ShellFilesystemOperations(associatedInstance!); +#else + return new LinuxFilesystemOperations(associatedInstance); +#endif + } + } +} diff --git a/src/Files.App/Utils/Storage/Operations/IFilesystemHelpers.cs b/src/Files.App/Utils/Storage/Operations/IFilesystemHelpers.cs index 9dfacf7c55f0..23d960483131 100644 --- a/src/Files.App/Utils/Storage/Operations/IFilesystemHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/IFilesystemHelpers.cs @@ -18,6 +18,7 @@ public interface IFilesystemHelpers : IDisposable #region Delete +#if WINDOWS /// /// Deletes provided /// @@ -27,7 +28,9 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task DeleteItemsAsync(IEnumerable source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory); +#endif +#if WINDOWS /// /// Deletes provided /// @@ -37,6 +40,7 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task DeleteItemAsync(IStorageItem source, DeleteConfirmationPolicies showDialog, bool permanently, bool registerHistory); +#endif /// /// Deletes provided @@ -62,6 +66,7 @@ public interface IFilesystemHelpers : IDisposable #region Restore +#if WINDOWS /// /// Restores from the RecycleBin to fullPath /// @@ -70,7 +75,9 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task RestoreItemFromTrashAsync(IStorageItem source, string destination, bool registerHistory); +#endif +#if WINDOWS /// /// Restores from the RecycleBin to fullPath /// @@ -79,6 +86,7 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory); +#endif /// /// Restores from the RecycleBin to fullPath @@ -96,7 +104,11 @@ public interface IFilesystemHelpers : IDisposable /// The destination fullPath to restore to /// Determines whether is saved /// of performed operation - Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory); + Task RestoreItemsFromTrashAsync(IEnumerable source, IEnumerable destination, bool registerHistory +#if !WINDOWS + , Action? historyCallback = null +#endif + ); #endregion Restore @@ -123,6 +135,7 @@ public interface IFilesystemHelpers : IDisposable #region Copy +#if WINDOWS /// /// Copies to fullPath /// @@ -132,7 +145,9 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory); +#endif +#if WINDOWS /// /// Copies to fullPath /// @@ -142,6 +157,7 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task CopyItemAsync(IStorageItem source, string destination, bool showDialog, bool registerHistory); +#endif /// /// Copies to fullPath @@ -151,7 +167,11 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether to show copy dialog /// Determines whether is saved /// of performed operation - Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory); + Task CopyItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory +#if !WINDOWS + , Action? historyCallback = null +#endif + ); /// /// Copies to fullPath @@ -186,6 +206,7 @@ public interface IFilesystemHelpers : IDisposable #region Move +#if WINDOWS /// /// Moves to fullPath /// @@ -195,7 +216,9 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory); +#endif +#if WINDOWS /// /// Moves to fullPath /// @@ -205,6 +228,7 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether is saved /// of performed operation Task MoveItemAsync(IStorageItem source, string destination, bool showDialog, bool registerHistory); +#endif /// /// Moves to fullPath @@ -214,7 +238,11 @@ public interface IFilesystemHelpers : IDisposable /// Determines whether to show move dialog /// Determines whether is saved /// of performed operation - Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory); + Task MoveItemsAsync(IEnumerable source, IEnumerable destination, bool showDialog, bool registerHistory +#if !WINDOWS + , Action? historyCallback = null +#endif + ); /// /// Moves to fullPath @@ -243,6 +271,7 @@ public interface IFilesystemHelpers : IDisposable #endregion Move +#if WINDOWS /// /// Renames with /// @@ -253,6 +282,7 @@ public interface IFilesystemHelpers : IDisposable /// Determines wheteher the Extension Modified Dialog is shown /// of performed operation Task RenameAsync(IStorageItem source, string newName, NameCollisionOption collision, bool registerHistory, bool showExtensionDialog = true); +#endif /// /// Renames fullPath with diff --git a/src/Files.App/Utils/Storage/Operations/IFilesystemOperations.cs b/src/Files.App/Utils/Storage/Operations/IFilesystemOperations.cs index d5b5bc3b2822..412c39e3becc 100644 --- a/src/Files.App/Utils/Storage/Operations/IFilesystemOperations.cs +++ b/src/Files.App/Utils/Storage/Operations/IFilesystemOperations.cs @@ -37,6 +37,7 @@ Task CreateShortcutItemsAsync( IProgress progress, CancellationToken cancellationToken); +#if WINDOWS /// /// Copies to fullPath /// @@ -57,6 +58,7 @@ Task CreateShortcutItemsAsync( NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken); +#endif /// /// Copies to fullPath @@ -79,6 +81,7 @@ Task CreateShortcutItemsAsync( IProgress progress, CancellationToken cancellationToken); +#if WINDOWS /// /// Copies to fullPath /// @@ -88,6 +91,7 @@ Task CreateShortcutItemsAsync( IList collisions, IProgress progress, CancellationToken cancellationToken); +#endif /// /// Copies to fullPath @@ -100,6 +104,7 @@ Task CreateShortcutItemsAsync( CancellationToken cancellationToken, bool asAdmin = false); +#if WINDOWS /// /// Moves to fullPath /// @@ -120,6 +125,7 @@ Task CreateShortcutItemsAsync( NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken); +#endif /// /// Moves to fullPath @@ -142,6 +148,7 @@ Task CreateShortcutItemsAsync( IProgress progress, CancellationToken cancellationToken); +#if WINDOWS /// /// Moves to fullPath /// @@ -151,6 +158,7 @@ Task CreateShortcutItemsAsync( IList collisions, IProgress progress, CancellationToken cancellationToken); +#endif /// /// Moves to fullPath @@ -163,6 +171,7 @@ Task CreateShortcutItemsAsync( CancellationToken cancellationToken, bool asAdmin = false); +#if WINDOWS /// /// Deletes /// @@ -185,6 +194,7 @@ Task CreateShortcutItemsAsync( IProgress progress, bool permanently, CancellationToken cancellationToken); +#endif /// /// Deletes @@ -209,6 +219,7 @@ Task CreateShortcutItemsAsync( bool permanently, CancellationToken cancellationToken); +#if WINDOWS /// /// Deletes provided /// @@ -217,6 +228,7 @@ Task CreateShortcutItemsAsync( IProgress progress, bool permanently, CancellationToken cancellationToken); +#endif /// /// Deletes provided @@ -228,6 +240,7 @@ Task CreateShortcutItemsAsync( CancellationToken cancellationToken, bool asAdmin = false); +#if WINDOWS /// /// Renames with /// @@ -247,6 +260,7 @@ Task CreateShortcutItemsAsync( NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken); +#endif /// /// Renames fullPath with @@ -269,6 +283,7 @@ Task CreateShortcutItemsAsync( CancellationToken cancellationToken, bool asAdmin = false); +#if WINDOWS /// /// Restores from the RecycleBin to fullPath /// @@ -287,7 +302,9 @@ Task CreateShortcutItemsAsync( IList destination, IProgress progress, CancellationToken cancellationToken); +#endif +#if WINDOWS /// /// Restores from the RecycleBin to fullPath /// @@ -306,6 +323,7 @@ Task CreateShortcutItemsAsync( string destination, IProgress progress, CancellationToken cancellationToken); +#endif /// /// Restores from the RecycleBin to fullPath diff --git a/src/Files.App/Utils/Storage/Operations/LinuxFilesystemOperations.cs b/src/Files.App/Utils/Storage/Operations/LinuxFilesystemOperations.cs index b76c0346e102..ee9fea0cbbf5 100644 --- a/src/Files.App/Utils/Storage/Operations/LinuxFilesystemOperations.cs +++ b/src/Files.App/Utils/Storage/Operations/LinuxFilesystemOperations.cs @@ -15,14 +15,21 @@ namespace Files.App.Utils.Storage /// public sealed class LinuxFilesystemOperations : IFilesystemOperations { - private readonly IFileOperationsService _fileOperations = Ioc.Default.GetRequiredService(); + private readonly IFileOperationsService _fileOperations; - private readonly ITrashService _trash = Ioc.Default.GetRequiredService(); + private readonly ITrashService _trash; private readonly IShellPage? _associatedInstance; public LinuxFilesystemOperations(IShellPage? associatedInstance) + : this(Ioc.Default.GetRequiredService(), Ioc.Default.GetRequiredService(), associatedInstance) { + } + + internal LinuxFilesystemOperations(IFileOperationsService fileOperations, ITrashService trash, IShellPage? associatedInstance = null) + { + _fileOperations = fileOperations; + _trash = trash; _associatedInstance = associatedInstance; } @@ -107,7 +114,7 @@ private static FileOperationOptions OptionsFor(FileNameConflictResolveOptionType } private static IStorageItemWithPath FromResult(string path, FilesystemItemType type) - => StorageHelpers.FromPathAndType(path, type); + => new StorableWithPath(path, type); private async Task RemoveFromViewAsync(string path) { @@ -124,6 +131,7 @@ private async Task RemoveFromViewAsync(string path) } } + // LINUX-TODO(storage): migrate the creation result with UIFilesystemHelpers in P4-H. public async Task<(IStorageHistory?, IStorageItem?)> CreateAsync(IStorageItemWithPath source, IProgress process, CancellationToken cancellationToken, bool asAdmin = false) { StatusCenterItemProgressModel fsProgress = new(process, true, FileSystemStatusCode.InProgress, 1); @@ -202,28 +210,36 @@ public async Task CreateShortcutItemsAsync(IList CopyAsync(IStorageItem source, string destination, NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken) => CopyAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for copying."), destination, collision, progress, cancellationToken); +#endif public Task CopyAsync(IStorageItemWithPath source, string destination, NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken) => CopyItemsAsync(source.CreateList(), destination.CreateList(), collision.ConvertBack().CreateList(), progress, cancellationToken); +#if WINDOWS public async Task CopyItemsAsync(IList source, IList destination, IList collisions, IProgress progress, CancellationToken cancellationToken) => await CopyItemsAsync(await source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for copying.")).ToListAsync(), destination, collisions, progress, cancellationToken); +#endif public Task CopyItemsAsync(IList source, IList destination, IList collisions, IProgress progress, CancellationToken cancellationToken, bool asAdmin = false) => TransferAsync(false, source, destination, collisions, progress, cancellationToken); +#if WINDOWS public Task MoveAsync(IStorageItem source, string destination, NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken) => MoveAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for moving."), destination, collision, progress, cancellationToken); +#endif public Task MoveAsync(IStorageItemWithPath source, string destination, NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken) => MoveItemsAsync(source.CreateList(), destination.CreateList(), collision.ConvertBack().CreateList(), progress, cancellationToken); +#if WINDOWS public async Task MoveItemsAsync(IList source, IList destination, IList collisions, IProgress progress, CancellationToken cancellationToken) => await MoveItemsAsync(await source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for moving.")).ToListAsync(), destination, collisions, progress, cancellationToken); +#endif public Task MoveItemsAsync(IList source, IList destination, IList collisions, IProgress progress, CancellationToken cancellationToken, bool asAdmin = false) => TransferAsync(true, source, destination, collisions, progress, cancellationToken); @@ -347,15 +363,19 @@ public async Task CreateShortcutItemsAsync(IList DeleteAsync(IStorageItem source, IProgress progress, bool permanently, CancellationToken cancellationToken) => DeleteAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for deletion."), progress, permanently, cancellationToken); +#endif public Task DeleteAsync(IStorageItemWithPath source, IProgress progress, bool permanently, CancellationToken cancellationToken) => DeleteItemsAsync(source.CreateList(), progress, permanently, cancellationToken); +#if WINDOWS public async Task DeleteItemsAsync(IList source, IProgress progress, bool permanently, CancellationToken cancellationToken) => await DeleteItemsAsync(await source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for deletion.")).ToListAsync(), progress, permanently, cancellationToken); +#endif public async Task DeleteItemsAsync(IList source, IProgress progress, bool permanently, CancellationToken cancellationToken, bool asAdmin = false) { @@ -446,17 +466,22 @@ public async Task CreateShortcutItemsAsync(IList s.Src).ToList(), null); } +#if WINDOWS public Task RenameAsync(IStorageItem source, string newName, NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken) => RenameAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for renaming."), newName, collision, progress, cancellationToken); +#endif public async Task RenameAsync(IStorageItemWithPath source, string newName, NameCollisionOption collision, IProgress progress, CancellationToken cancellationToken, bool asAdmin = false) { StatusCenterItemProgressModel fsProgress = new(progress, true, FileSystemStatusCode.InProgress); fsProgress.Report(); - var options = collision == NameCollisionOption.ReplaceExisting - ? OptionsFor(FileNameConflictResolveOptionType.ReplaceExisting, null) - : null; + var options = collision switch + { + NameCollisionOption.ReplaceExisting => OptionsFor(FileNameConflictResolveOptionType.ReplaceExisting, null), + NameCollisionOption.GenerateUniqueName => OptionsFor(FileNameConflictResolveOptionType.GenerateNewName, null), + _ => null, + }; var result = await _fileOperations.RenameAsync(source.Path, newName, options, cancellationToken); if (!result.Succeeded || result.ResultPath is null) @@ -477,12 +502,16 @@ public async Task CreateShortcutItemsAsync(IList RestoreItemsFromTrashAsync(IList source, IList destination, IProgress progress, CancellationToken cancellationToken) => RestoreItemsFromTrashAsync(source.Select(item => item.FromStorageItem() ?? throw new InvalidOperationException("A storage item could not be converted for restoration.")).ToList(), destination, progress, cancellationToken); +#endif +#if WINDOWS public Task RestoreFromTrashAsync(IStorageItem source, string destination, IProgress progress, CancellationToken cancellationToken) => RestoreFromTrashAsync(source.FromStorageItem() ?? throw new InvalidOperationException("The storage item could not be converted for restoration."), destination, progress, cancellationToken); +#endif public Task RestoreFromTrashAsync(IStorageItemWithPath source, string destination, IProgress progress, CancellationToken cancellationToken) => RestoreItemsFromTrashAsync(source.CreateList(), destination.CreateList(), progress, cancellationToken); diff --git a/src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.cs b/src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.cs rename to src/Files.App/Utils/Storage/Operations/ShellFilesystemOperations.Windows.cs diff --git a/src/Files.App/Utils/Storage/Search/FolderSearch.Linux.cs b/src/Files.App/Utils/Storage/Search/FolderSearch.Linux.cs index 965f72f50afb..543bf6b69b4d 100644 --- a/src/Files.App/Utils/Storage/Search/FolderSearch.Linux.cs +++ b/src/Files.App/Utils/Storage/Search/FolderSearch.Linux.cs @@ -1,6 +1,7 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.Platform.Abstractions.Search; using Microsoft.Extensions.Logging; using System.IO; @@ -35,6 +36,9 @@ public static (string Pattern, bool Content) ParseLinuxQuery(string? query) return (text, false); } + private Task AddItemsAsync(string folder, IList results, CancellationToken token) + => SearchLinuxAsync(folder, results, token); + private async Task SearchLinuxAsync(string folder, IList results, CancellationToken token) { if (IsTagQuery(Query ?? string.Empty)) @@ -178,3 +182,4 @@ private static ListedItem CreateLinuxItem(string path, bool isDirectory, long le } } } +#endif diff --git a/src/Files.App/Utils/Storage/Search/FolderSearch.Windows.cs b/src/Files.App/Utils/Storage/Search/FolderSearch.Windows.cs new file mode 100644 index 000000000000..fbec1b502eb3 --- /dev/null +++ b/src/Files.App/Utils/Storage/Search/FolderSearch.Windows.cs @@ -0,0 +1,563 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using Microsoft.Extensions.Logging; +using System.IO; +using System.IO.Enumeration; +using System.Text.RegularExpressions; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Search; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Storage.FileSystem; +using FileAttributes = System.IO.FileAttributes; + +namespace Files.App.Utils.Storage +{ + public sealed partial class FolderSearch + { + private async Task SearchAsync(BaseStorageFolder folder, IList results, CancellationToken token) + { + uint index = 0; + var stepSize = Math.Min(defaultStepSize, GetRemainingItemCount(results)); + if (stepSize == 0) + return; + + var options = ToQueryOptions(); + + var queryResult = folder.CreateItemQueryWithOptions(options); + var items = await queryResult.GetItemsAsync(0, stepSize).AsTask(token); + + while (items.Count > 0) + { + foreach (IStorageItem item in items) + { + if (token.IsCancellationRequested) + { + return; + } + + try + { + if (!item.Name.StartsWith('.') || UserSettingsService.FoldersSettingsService.ShowDotFiles) + { + var listedItem = await GetListedItemAsync(item); + AddResult(results, listedItem, token); + if (listedItem.ItemPath is not null) + indexedResultPaths.Add(listedItem.ItemPath); + } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Error creating ListedItem from StorageItem"); + } + } + + index += (uint)items.Count; + stepSize = Math.Min(defaultStepSize, GetRemainingItemCount(results)); + if (stepSize == 0) + return; + + items = await queryResult.GetItemsAsync(index, stepSize).AsTask(token); + } + } + + private async Task SearchTagsAsync(string folder, IList results, CancellationToken token) + { + var expression = ParseTagQuery(AQSQuery); + + if (expression.OrGroups.Count == 0) + { + return; + } + + var dbInstance = FileTagsHelper.GetDbInstance(); + var matches = dbInstance.GetAllUnderPath(folder) + .Where(x => MatchesTagExpression(x.Tags, expression)); + if (string.IsNullOrEmpty(folder)) + matches = matches.Where(x => !StorageTrashBinService.IsUnderTrashBin(x.FilePath)); + + foreach (var match in matches) + { + if (token.IsCancellationRequested) + return; + + (FindCloseSafeHandle? hFile, WIN32_FIND_DATAW findData) = await Task.Run(() => + { + WIN32_FIND_DATAW findDataTsk = default; + FindCloseSafeHandle hFileTsk; + unsafe + { + hFileTsk = PInvoke.FindFirstFileEx(match.FilePath, FINDEX_INFO_LEVELS.FindExInfoBasic, + &findDataTsk, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); + } + return (hFileTsk, findDataTsk); + }).WithTimeoutAsync(TimeSpan.FromSeconds(5)); + if (token.IsCancellationRequested) + { + hFile?.Dispose(); + return; + } + + if (hFile is { IsInvalid: false } tagSearchHandle) + { + using (tagSearchHandle) + { + string fileName = findData.cFileName.ToString(); + var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; + var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; + var startWithDot = fileName.StartsWith('.'); + + bool shouldBeListed = (!isHidden || + (UserSettingsService.FoldersSettingsService.ShowHiddenItems && + (!isSystem || UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) && + (!startWithDot || UserSettingsService.FoldersSettingsService.ShowDotFiles); + + if (shouldBeListed) + { + var item = GetListedItemAsync(match.FilePath, findData); + if (item is not null) + AddResult(results, item, token); + } + } + } + else + { + hFile?.Dispose(); + try + { + IStorageItem? item = (await GetStorageFileAsync(match.FilePath)).Result; + item ??= (await GetStorageFolderAsync(match.FilePath)).Result; + item = item + ?? throw new InvalidOperationException($"The search item '{match.FilePath}' could not be opened."); + if (!item.Name.StartsWith('.') || UserSettingsService.FoldersSettingsService.ShowDotFiles) + { + AddResult(results, await GetListedItemAsync(item), token); + } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Error creating ListedItem from StorageItem"); + } + } + } + } + + private async Task AddItemsAsync(string folder, IList results, CancellationToken token) + { + if (IsTagQuery(AQSQuery)) + { + await SearchTagsAsync(folder, results, token); + } + else + { + var workingFolder = await GetStorageFolderAsync(folder); + var storageFolder = workingFolder ? workingFolder.Result : null; + + if (IsAQSQuery) + { + if (storageFolder is not null) + await SearchAsync(storageFolder, results, token); + + return; + } + + if (storageFolder is SystemStorageFolder && !DriveHelpers.IsMtpPath(folder) && !await IsFullyIndexedAsync(storageFolder)) + { + await SearchWithWin32Async(folder, false, results, token); + return; + } + + var hiddenOnlyFromWin32 = false; + if (storageFolder is not null) + { + var countBefore = results.Count; + await SearchAsync(storageFolder, results, token); + hiddenOnlyFromWin32 = results.Count != countBefore; + } + + await SearchWithWin32Async(folder, hiddenOnlyFromWin32, results, token); + } + } + + private static async Task IsFullyIndexedAsync(BaseStorageFolder folder) + { + try + { + return await folder.GetIndexedStateAsync() == IndexedState.FullyIndexed; + } + catch (Exception) + { + return false; + } + } + + private Task SearchWithWin32Async(string folder, bool hiddenOnly, IList results, CancellationToken token) + { + return Task.Factory.StartNew( + () => SearchWithWin32(folder, hiddenOnly, results, token), + token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default) + .WaitAsync(token); + } + + private void SearchWithWin32(string root, bool hiddenOnly, IList results, CancellationToken token) + { + var expression = FileSystemName.TranslateWin32Expression($"*{QueryWithWildcard}"); + var showHiddenItems = UserSettingsService.FoldersSettingsService.ShowHiddenItems; + var showProtectedSystemFiles = UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles; + var showDotFiles = UserSettingsService.FoldersSettingsService.ShowDotFiles; + + var pendingFolders = new Queue<(string Path, bool UseFindTimeout)>(); + pendingFolders.Enqueue((root, RequiresFindTimeout(root))); + + while (pendingFolders.TryDequeue(out var pendingFolder)) + { + var (folder, useFindTimeout) = pendingFolder; + if (token.IsCancellationRequested || results.Count >= UsedMaxItemCount) + return; + + using (var findHandle = FindFirstFile(folder, useFindTimeout, out var findData, token)) + { + if (findHandle is null || findHandle.IsInvalid) + continue; + + do + { + var fileName = findData.cFileName.ToString(); + if (fileName is "." or "..") + continue; + + var attributes = (FileAttributes)findData.dwFileAttributes; + var isDirectory = attributes.HasFlag(FileAttributes.Directory); + var reparseTag = attributes.HasFlag(FileAttributes.ReparsePoint) ? findData.dwReserved0 : 0; + + if (isDirectory && !IsNameSurrogateReparseTag(reparseTag)) + pendingFolders.Enqueue((Path.Combine(folder, fileName), useFindTimeout || IsCloudFilesReparseTag(reparseTag))); + + if (!FileSystemName.MatchesWin32Expression(expression, fileName)) + continue; + + var isSystem = attributes.HasFlag(FileAttributes.System); + var isHidden = attributes.HasFlag(FileAttributes.Hidden); + var startWithDot = fileName.StartsWith('.'); + var isShortcut = !isDirectory && FileExtensionHelpers.IsShortcutOrUrlFile(fileName); + + bool shouldBeListed = (hiddenOnly ? + (!isHidden && isShortcut) || (isHidden && showHiddenItems && (!isSystem || showProtectedSystemFiles)) : + !isHidden || (showHiddenItems && (!isSystem || showProtectedSystemFiles))) && + (!startWithDot || showDotFiles); + + var itemPath = Path.Combine(folder, fileName); + if (!shouldBeListed || (hiddenOnly && indexedResultPaths.Contains(itemPath))) + continue; + + try + { + if ((isShortcut ? GetShortcutItem(itemPath, findData) : GetListedItemAsync(itemPath, findData)) is { } item) + AddResult(results, item, token); + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, "Error creating ListedItem from Win32 find data"); + } + } + while (!token.IsCancellationRequested && results.Count < UsedMaxItemCount && PInvoke.FindNextFile(findHandle, out findData)); + } + + RaiseSearchTickIfDue(token); + } + } + + private static FindCloseSafeHandle? FindFirstFile(string folder, bool useTimeout, out WIN32_FIND_DATAW findData, CancellationToken token) + { + if (!useTimeout) + return FindFirstFile(folder, out findData); + + var findTask = Task.Run(() => (Handle: FindFirstFile(folder, out var data), Data: data)); + try + { + if (findTask.Wait(TimeSpan.FromSeconds(5), token)) + { + (var handle, findData) = findTask.Result; + return handle; + } + } + catch (OperationCanceledException) + { + } + + _ = findTask.ContinueWith(t => t.Result.Handle.Dispose(), TaskContinuationOptions.OnlyOnRanToCompletion); + findData = default; + return null; + } + + private static unsafe FindCloseSafeHandle FindFirstFile(string folder, out WIN32_FIND_DATAW findData) + { + WIN32_FIND_DATAW data = default; + var handle = PInvoke.FindFirstFileEx(Path.Join(folder, "*"), FINDEX_INFO_LEVELS.FindExInfoBasic, + &data, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); + findData = data; + return handle; + } + + private static bool RequiresFindTimeout(string root) + { + if (DriveHelpers.IsNetworkPath(root)) + return true; + + try + { + // Virtual cloud drives such as Google Drive report Fixed but aren't NTFS + var drive = new DriveInfo(root); + return drive.DriveType is not System.IO.DriveType.Fixed + || drive.DriveFormat is not ("NTFS" or "ReFS") + || File.GetAttributes(root).HasFlag(FileAttributes.ReparsePoint); + } + catch (Exception) + { + return true; + } + } + + // Junctions, symlinks and mount points + private static bool IsNameSurrogateReparseTag(uint reparseTag) + => (reparseTag & 0x20000000) != 0; + + // IO_REPARSE_TAG_CLOUD and IO_REPARSE_TAG_CLOUD_1 to _F + private static bool IsCloudFilesReparseTag(uint reparseTag) + => (reparseTag & 0xFFFF0FFF) == 0x9000001A; + + private static ShortcutItem GetShortcutItem(string itemPath, WIN32_FIND_DATAW findData) + { + string fileName = findData.cFileName.ToString(); + var isUrl = FileExtensionHelpers.IsWebLinkFile(fileName); + var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; + PInvoke.FileTimeToSystemTime(findData.ftLastWriteTime, out SYSTEMTIME modifiedTime); + PInvoke.FileTimeToSystemTime(findData.ftCreationTime, out SYSTEMTIME createdTime); + var fileSize = Win32FindDataExtensions.GetSize(findData); + + return new ShortcutItem(null) + { + PrimaryItemAttribute = StorageItemTypes.File, + FileExtension = fileName.Contains('.', StringComparison.Ordinal) ? Path.GetExtension(itemPath)! : string.Empty, + IsHiddenItem = isHidden, + Opacity = isHidden ? Constants.UI.DimItemOpacity : 1, + FileImage = null, + LoadFileIcon = false, + ItemNameRaw = fileName, + ItemDateModifiedReal = modifiedTime.ToDateTime(), + ItemDateCreatedReal = createdTime.ToDateTime(), + ItemType = isUrl ? Strings.ShortcutWebLinkFileType.GetLocalizedResource() : Strings.Shortcut.GetLocalizedResource(), + ItemPath = itemPath, + FileSize = fileSize.ToSizeString(), + FileSizeBytes = fileSize, + IsUrl = isUrl, + }; + } + + private ListedItem? GetListedItemAsync(string itemPath, WIN32_FIND_DATAW findData) + { + string fileName = findData.cFileName.ToString(); + ListedItem? listedItem = null; + var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; + var isFolder = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Directory) == FileAttributes.Directory; + PInvoke.FileTimeToSystemTime(findData.ftLastWriteTime, out SYSTEMTIME systemModifiedTimeOutput); + PInvoke.FileTimeToSystemTime(findData.ftCreationTime, out SYSTEMTIME systemCreatedTimeOutput); + + if (!isFolder) + { + string? itemFileExtension = null; + string? itemType = null; + long fileSize = Win32FindDataExtensions.GetSize(findData); + if (fileName.Contains('.', StringComparison.Ordinal)) + { + itemFileExtension = Path.GetExtension(itemPath); + itemType = itemFileExtension!.Trim('.') + " " + itemType; + } + + listedItem = new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.File, + ItemNameRaw = fileName, + ItemPath = itemPath, + ItemDateModifiedReal = systemModifiedTimeOutput.ToDateTime(), + ItemDateCreatedReal = systemCreatedTimeOutput.ToDateTime(), + IsHiddenItem = isHidden, + LoadFileIcon = false, + FileExtension = itemFileExtension, + ItemType = itemType, + Opacity = isHidden ? Constants.UI.DimItemOpacity : 1, + FileSize = fileSize.ToSizeString(), + FileSizeBytes = fileSize, + }; + } + else + { + if (fileName != "." && fileName != "..") + { + listedItem = new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemNameRaw = fileName, + ItemPath = itemPath, + ItemDateModifiedReal = systemModifiedTimeOutput.ToDateTime(), + ItemDateCreatedReal = systemCreatedTimeOutput.ToDateTime(), + IsHiddenItem = isHidden, + LoadFileIcon = false, + ItemType = folderTypeTextLocalized, + Opacity = isHidden ? Constants.UI.DimItemOpacity : 1 + }; + } + } + + return listedItem; + } + + private async Task GetListedItemAsync(IStorageItem item) + { + ListedItem? listedItem = null; + if (item.IsOfType(StorageItemTypes.Folder)) + { + var folder = item.AsBaseStorageFolder() + ?? throw new InvalidOperationException($"The search result '{item.Path}' could not be opened as a folder."); + + var props = await folder.GetBasicPropertiesAsync(); + if (folder is BinStorageFolder binFolder) + { + listedItem = new RecycleBinItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemNameRaw = folder.DisplayName, + ItemPath = folder.Path, + ItemDateModifiedReal = props.DateModified, + ItemDateCreatedReal = folder.DateCreated, + ItemType = folderTypeTextLocalized, + Opacity = 1, + FileSize = props.Size.ToSizeString(), + FileSizeBytes = (long)props.Size, + ItemDateDeletedReal = binFolder.DateDeleted, + ItemOriginalPath = binFolder.OriginalPath + }; + } + else + { + listedItem = new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemNameRaw = folder.DisplayName, + ItemPath = folder.Path, + ItemDateModifiedReal = props.DateModified, + ItemDateCreatedReal = folder.DateCreated, + ItemType = folderTypeTextLocalized, + Opacity = 1 + }; + } + } + else if (item.IsOfType(StorageItemTypes.File)) + { + var file = item.AsBaseStorageFile() + ?? throw new InvalidOperationException($"The search result '{item.Path}' could not be opened as a file."); + + var props = await file.GetBasicPropertiesAsync(); + string? itemFileExtension = null; + string? itemType = null; + if (file.Name.Contains('.', StringComparison.Ordinal)) + { + itemFileExtension = Path.GetExtension(file.Path); + itemType = itemFileExtension!.Trim('.') + " " + itemType; + } + + var itemSize = props.Size.ToSizeString(); + + if (file is BinStorageFile binFile) + { + listedItem = new RecycleBinItem(null) + { + PrimaryItemAttribute = StorageItemTypes.File, + ItemNameRaw = file.Name, + ItemPath = file.Path, + LoadFileIcon = false, + FileExtension = itemFileExtension, + FileSizeBytes = (long)props.Size, + FileSize = itemSize, + ItemDateModifiedReal = props.DateModified, + ItemDateCreatedReal = file.DateCreated, + ItemType = itemType, + Opacity = 1, + ItemDateDeletedReal = binFile.DateDeleted, + ItemOriginalPath = binFile.OriginalPath + }; + } + else if (FileExtensionHelpers.IsShortcutOrUrlFile(file.Path)) + { + var isUrl = FileExtensionHelpers.IsWebLinkFile(file.Path); + var shortcutItem = new ShortcutItem(null) + { + PrimaryItemAttribute = StorageItemTypes.File, + FileExtension = itemFileExtension, + IsHiddenItem = false, + Opacity = 1, + FileImage = null, + LoadFileIcon = false, + ItemNameRaw = file.Name, + ItemDateModifiedReal = props.DateModified, + ItemDateCreatedReal = file.DateCreated, + ItemType = isUrl ? Strings.ShortcutWebLinkFileType.GetLocalizedResource() : Strings.Shortcut.GetLocalizedResource(), + ItemPath = file.Path, + FileSize = itemSize, + FileSizeBytes = (long)props.Size, + IsUrl = isUrl, + }; + listedItem = shortcutItem; + } + else + { + listedItem = new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.File, + ItemNameRaw = file.Name, + ItemPath = file.Path, + LoadFileIcon = false, + FileExtension = itemFileExtension, + FileSizeBytes = (long)props.Size, + FileSize = itemSize, + ItemDateModifiedReal = props.DateModified, + ItemDateCreatedReal = file.DateCreated, + ItemType = itemType, + Opacity = 1 + }; + } + } + return listedItem + ?? throw new InvalidOperationException($"The search result '{item.Path}' is neither a file nor a folder."); + } + + private QueryOptions ToQueryOptions() + { + var query = new QueryOptions + { + FolderDepth = FolderDepth.Deep, + UserSearchFilter = AQSQuery ?? string.Empty, + }; + + query.IndexerOption = IndexerOption.UseIndexerWhenAvailable; + + query.SortOrder.Clear(); + query.SortOrder.Add(new SortEntry { PropertyName = "System.Search.Rank", AscendingOrder = false }); + + query.SetPropertyPrefetch(PropertyPrefetchOptions.BasicProperties, null); + + return query; + } + + private static Task> GetStorageFolderAsync(string path) + => FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path)); + + private static Task> GetStorageFileAsync(string path) + => FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFileFromPathAsync(path)); + } +} diff --git a/src/Files.App/Utils/Storage/Search/FolderSearch.cs b/src/Files.App/Utils/Storage/Search/FolderSearch.cs index a16769298368..c91b3b69837b 100644 --- a/src/Files.App/Utils/Storage/Search/FolderSearch.cs +++ b/src/Files.App/Utils/Storage/Search/FolderSearch.cs @@ -1,33 +1,29 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Files.Shared.Helpers; using Microsoft.Extensions.Logging; using Microsoft.UI.Dispatching; using System.IO; -using System.IO.Enumeration; using System.Text.RegularExpressions; using Windows.Storage; -using Windows.Storage.FileProperties; -using Windows.Storage.Search; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.Storage.FileSystem; -using FileAttributes = System.IO.FileAttributes; namespace Files.App.Utils.Storage { public sealed partial class FolderSearch { private IUserSettingsService UserSettingsService { get; } = Ioc.Default.GetRequiredService(); +#if WINDOWS private DrivesViewModel drivesViewModel = Ioc.Default.GetRequiredService(); private readonly IStorageTrashBinService StorageTrashBinService = Ioc.Default.GetRequiredService(); +#endif private readonly IFileTagsSettingsService fileTagsSettingsService = Ioc.Default.GetRequiredService(); private readonly ILogger logger = Ioc.Default.GetRequiredService>(); private static readonly string folderTypeTextLocalized = Strings.Folder.GetLocalizedResource(); +#if WINDOWS private const uint defaultStepSize = 500; +#endif public string? Query { get; set; } @@ -132,12 +128,9 @@ public async Task SearchAsync(IList results, CancellationToken token private async Task AddItemsForHomeAsync(IList results, CancellationToken token) { - if (OperatingSystem.IsLinux()) - { - await SearchLinuxAsync("Home", results, token); - return; - } - +#if !WINDOWS + await SearchLinuxAsync("Home", results, token); +#else if (IsTagQuery(AQSQuery)) { await SearchTagsAsync("", results, token); // Search tags everywhere, not only local drives @@ -149,6 +142,7 @@ private async Task AddItemsForHomeAsync(IList results, CancellationT await AddItemsAsync(drive.Path!, results, token); } } +#endif } public async Task> SearchAsync() @@ -206,52 +200,6 @@ private void RaiseSearchTickIfDue(CancellationToken token) private uint GetRemainingItemCount(IList results) => results.Count >= UsedMaxItemCount ? 0 : UsedMaxItemCount - (uint)results.Count; - private async Task SearchAsync(BaseStorageFolder folder, IList results, CancellationToken token) - { - uint index = 0; - var stepSize = Math.Min(defaultStepSize, GetRemainingItemCount(results)); - if (stepSize == 0) - return; - - var options = ToQueryOptions(); - - var queryResult = folder.CreateItemQueryWithOptions(options); - var items = await queryResult.GetItemsAsync(0, stepSize).AsTask(token); - - while (items.Count > 0) - { - foreach (IStorageItem item in items) - { - if (token.IsCancellationRequested) - { - return; - } - - try - { - if (!item.Name.StartsWith('.') || UserSettingsService.FoldersSettingsService.ShowDotFiles) - { - var listedItem = await GetListedItemAsync(item); - AddResult(results, listedItem, token); - if (listedItem.ItemPath is not null) - indexedResultPaths.Add(listedItem.ItemPath); - } - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, "Error creating ListedItem from StorageItem"); - } - } - - index += (uint)items.Count; - stepSize = Math.Min(defaultStepSize, GetRemainingItemCount(results)); - if (stepSize == 0) - return; - - items = await queryResult.GetItemsAsync(index, stepSize).AsTask(token); - } - } - // Awaited so the final sort treats folder shortcuts as folders private async Task ResolveShortcutTargetsAsync(CancellationToken token) { @@ -276,7 +224,7 @@ await DispatcherQueue.EnqueueOrInvokeAsync(() => shortcutItem.Arguments = link.Arguments; shortcutItem.WorkingDirectory = link.WorkingDirectory; shortcutItem.RunAsAdmin = link.RunAsAdmin; - shortcutItem.ShowWindowCommand = link.ShowWindowCommand; + shortcutItem.ShowWindowCommand = ((int)link.ShowWindowCommand).ToShowWindowCommand(); shortcutItem.PrimaryItemAttribute = link.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File; } }); @@ -413,506 +361,6 @@ private bool MatchesTagExpression(IEnumerable? fileTags, TagQueryExpress return false; } - private async Task SearchTagsAsync(string folder, IList results, CancellationToken token) - { - var expression = ParseTagQuery(AQSQuery); - - if (expression.OrGroups.Count == 0) - { - return; - } - - var dbInstance = FileTagsHelper.GetDbInstance(); - var matches = dbInstance.GetAllUnderPath(folder) - .Where(x => MatchesTagExpression(x.Tags, expression)); - if (string.IsNullOrEmpty(folder)) - matches = matches.Where(x => !StorageTrashBinService.IsUnderTrashBin(x.FilePath)); - - foreach (var match in matches) - { - if (token.IsCancellationRequested) - return; - - (FindCloseSafeHandle? hFile, WIN32_FIND_DATAW findData) = await Task.Run(() => - { - WIN32_FIND_DATAW findDataTsk = default; - FindCloseSafeHandle hFileTsk; - unsafe - { - hFileTsk = PInvoke.FindFirstFileEx(match.FilePath, FINDEX_INFO_LEVELS.FindExInfoBasic, - &findDataTsk, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); - } - return (hFileTsk, findDataTsk); - }).WithTimeoutAsync(TimeSpan.FromSeconds(5)); - if (token.IsCancellationRequested) - { - hFile?.Dispose(); - return; - } - - if (hFile is { IsInvalid: false } tagSearchHandle) - { - using (tagSearchHandle) - { - string fileName = findData.cFileName.ToString(); - var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; - var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - var startWithDot = fileName.StartsWith('.'); - - bool shouldBeListed = (!isHidden || - (UserSettingsService.FoldersSettingsService.ShowHiddenItems && - (!isSystem || UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) && - (!startWithDot || UserSettingsService.FoldersSettingsService.ShowDotFiles); - - if (shouldBeListed) - { - var item = GetListedItemAsync(match.FilePath, findData); - if (item is not null) - AddResult(results, item, token); - } - } - } - else - { - hFile?.Dispose(); - try - { - IStorageItem? item = (await GetStorageFileAsync(match.FilePath)).Result; - item ??= (await GetStorageFolderAsync(match.FilePath)).Result; - item = item - ?? throw new InvalidOperationException($"The search item '{match.FilePath}' could not be opened."); - if (!item.Name.StartsWith('.') || UserSettingsService.FoldersSettingsService.ShowDotFiles) - { - AddResult(results, await GetListedItemAsync(item), token); - } - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, "Error creating ListedItem from StorageItem"); - } - } - } - } - - private async Task AddItemsAsync(string folder, IList results, CancellationToken token) - { - if (OperatingSystem.IsLinux()) - { - await SearchLinuxAsync(folder, results, token); - return; - } - - if (IsTagQuery(AQSQuery)) - { - await SearchTagsAsync(folder, results, token); - } - else - { - var workingFolder = await GetStorageFolderAsync(folder); - var storageFolder = workingFolder ? workingFolder.Result : null; - - if (IsAQSQuery) - { - if (storageFolder is not null) - await SearchAsync(storageFolder, results, token); - - return; - } - - if (storageFolder is SystemStorageFolder && !DriveHelpers.IsMtpPath(folder) && !await IsFullyIndexedAsync(storageFolder)) - { - await SearchWithWin32Async(folder, false, results, token); - return; - } - - var hiddenOnlyFromWin32 = false; - if (storageFolder is not null) - { - var countBefore = results.Count; - await SearchAsync(storageFolder, results, token); - hiddenOnlyFromWin32 = results.Count != countBefore; - } - - await SearchWithWin32Async(folder, hiddenOnlyFromWin32, results, token); - } - } - - private static async Task IsFullyIndexedAsync(BaseStorageFolder folder) - { - try - { - return await folder.GetIndexedStateAsync() == IndexedState.FullyIndexed; - } - catch (Exception) - { - return false; - } - } - - private Task SearchWithWin32Async(string folder, bool hiddenOnly, IList results, CancellationToken token) - { - return Task.Factory.StartNew( - () => SearchWithWin32(folder, hiddenOnly, results, token), - token, - TaskCreationOptions.LongRunning, - TaskScheduler.Default) - .WaitAsync(token); - } - - private void SearchWithWin32(string root, bool hiddenOnly, IList results, CancellationToken token) - { - var expression = FileSystemName.TranslateWin32Expression($"*{QueryWithWildcard}"); - var showHiddenItems = UserSettingsService.FoldersSettingsService.ShowHiddenItems; - var showProtectedSystemFiles = UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles; - var showDotFiles = UserSettingsService.FoldersSettingsService.ShowDotFiles; - - var pendingFolders = new Queue<(string Path, bool UseFindTimeout)>(); - pendingFolders.Enqueue((root, RequiresFindTimeout(root))); - - while (pendingFolders.TryDequeue(out var pendingFolder)) - { - var (folder, useFindTimeout) = pendingFolder; - if (token.IsCancellationRequested || results.Count >= UsedMaxItemCount) - return; - - using (var findHandle = FindFirstFile(folder, useFindTimeout, out var findData, token)) - { - if (findHandle is null || findHandle.IsInvalid) - continue; - - do - { - var fileName = findData.cFileName.ToString(); - if (fileName is "." or "..") - continue; - - var attributes = (FileAttributes)findData.dwFileAttributes; - var isDirectory = attributes.HasFlag(FileAttributes.Directory); - var reparseTag = attributes.HasFlag(FileAttributes.ReparsePoint) ? findData.dwReserved0 : 0; - - if (isDirectory && !IsNameSurrogateReparseTag(reparseTag)) - pendingFolders.Enqueue((Path.Combine(folder, fileName), useFindTimeout || IsCloudFilesReparseTag(reparseTag))); - - if (!FileSystemName.MatchesWin32Expression(expression, fileName)) - continue; - - var isSystem = attributes.HasFlag(FileAttributes.System); - var isHidden = attributes.HasFlag(FileAttributes.Hidden); - var startWithDot = fileName.StartsWith('.'); - var isShortcut = !isDirectory && FileExtensionHelpers.IsShortcutOrUrlFile(fileName); - - bool shouldBeListed = (hiddenOnly ? - (!isHidden && isShortcut) || (isHidden && showHiddenItems && (!isSystem || showProtectedSystemFiles)) : - !isHidden || (showHiddenItems && (!isSystem || showProtectedSystemFiles))) && - (!startWithDot || showDotFiles); - - var itemPath = Path.Combine(folder, fileName); - if (!shouldBeListed || (hiddenOnly && indexedResultPaths.Contains(itemPath))) - continue; - - try - { - if ((isShortcut ? GetShortcutItem(itemPath, findData) : GetListedItemAsync(itemPath, findData)) is { } item) - AddResult(results, item, token); - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, "Error creating ListedItem from Win32 find data"); - } - } - while (!token.IsCancellationRequested && results.Count < UsedMaxItemCount && PInvoke.FindNextFile(findHandle, out findData)); - } - - RaiseSearchTickIfDue(token); - } - } - - private static FindCloseSafeHandle? FindFirstFile(string folder, bool useTimeout, out WIN32_FIND_DATAW findData, CancellationToken token) - { - if (!useTimeout) - return FindFirstFile(folder, out findData); - - var findTask = Task.Run(() => (Handle: FindFirstFile(folder, out var data), Data: data)); - try - { - if (findTask.Wait(TimeSpan.FromSeconds(5), token)) - { - (var handle, findData) = findTask.Result; - return handle; - } - } - catch (OperationCanceledException) - { - } - - _ = findTask.ContinueWith(t => t.Result.Handle.Dispose(), TaskContinuationOptions.OnlyOnRanToCompletion); - findData = default; - return null; - } - - private static unsafe FindCloseSafeHandle FindFirstFile(string folder, out WIN32_FIND_DATAW findData) - { - WIN32_FIND_DATAW data = default; - var handle = PInvoke.FindFirstFileEx(Path.Join(folder, "*"), FINDEX_INFO_LEVELS.FindExInfoBasic, - &data, FINDEX_SEARCH_OPS.FindExSearchNameMatch, FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); - findData = data; - return handle; - } - - private static bool RequiresFindTimeout(string root) - { - if (DriveHelpers.IsNetworkPath(root)) - return true; - - try - { - // Virtual cloud drives such as Google Drive report Fixed but aren't NTFS - var drive = new DriveInfo(root); - return drive.DriveType is not System.IO.DriveType.Fixed - || drive.DriveFormat is not ("NTFS" or "ReFS") - || File.GetAttributes(root).HasFlag(FileAttributes.ReparsePoint); - } - catch (Exception) - { - return true; - } - } - - // Junctions, symlinks and mount points - private static bool IsNameSurrogateReparseTag(uint reparseTag) - => (reparseTag & 0x20000000) != 0; - - // IO_REPARSE_TAG_CLOUD and IO_REPARSE_TAG_CLOUD_1 to _F - private static bool IsCloudFilesReparseTag(uint reparseTag) - => (reparseTag & 0xFFFF0FFF) == 0x9000001A; - - private static ShortcutItem GetShortcutItem(string itemPath, WIN32_FIND_DATAW findData) - { - string fileName = findData.cFileName.ToString(); - var isUrl = FileExtensionHelpers.IsWebLinkFile(fileName); - var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - PInvoke.FileTimeToSystemTime(findData.ftLastWriteTime, out SYSTEMTIME modifiedTime); - PInvoke.FileTimeToSystemTime(findData.ftCreationTime, out SYSTEMTIME createdTime); - var fileSize = Win32FindDataExtensions.GetSize(findData); - - return new ShortcutItem(null) - { - PrimaryItemAttribute = StorageItemTypes.File, - FileExtension = fileName.Contains('.', StringComparison.Ordinal) ? Path.GetExtension(itemPath)! : string.Empty, - IsHiddenItem = isHidden, - Opacity = isHidden ? Constants.UI.DimItemOpacity : 1, - FileImage = null, - LoadFileIcon = false, - ItemNameRaw = fileName, - ItemDateModifiedReal = modifiedTime.ToDateTime(), - ItemDateCreatedReal = createdTime.ToDateTime(), - ItemType = isUrl ? Strings.ShortcutWebLinkFileType.GetLocalizedResource() : Strings.Shortcut.GetLocalizedResource(), - ItemPath = itemPath, - FileSize = fileSize.ToSizeString(), - FileSizeBytes = fileSize, - IsUrl = isUrl, - }; - } - - private ListedItem? GetListedItemAsync(string itemPath, WIN32_FIND_DATAW findData) - { - string fileName = findData.cFileName.ToString(); - ListedItem? listedItem = null; - var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - var isFolder = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Directory) == FileAttributes.Directory; - PInvoke.FileTimeToSystemTime(findData.ftLastWriteTime, out SYSTEMTIME systemModifiedTimeOutput); - PInvoke.FileTimeToSystemTime(findData.ftCreationTime, out SYSTEMTIME systemCreatedTimeOutput); - - if (!isFolder) - { - string? itemFileExtension = null; - string? itemType = null; - long fileSize = Win32FindDataExtensions.GetSize(findData); - if (fileName.Contains('.', StringComparison.Ordinal)) - { - itemFileExtension = Path.GetExtension(itemPath); - itemType = itemFileExtension!.Trim('.') + " " + itemType; - } - - listedItem = new ListedItem(null) - { - PrimaryItemAttribute = StorageItemTypes.File, - ItemNameRaw = fileName, - ItemPath = itemPath, - ItemDateModifiedReal = systemModifiedTimeOutput.ToDateTime(), - ItemDateCreatedReal = systemCreatedTimeOutput.ToDateTime(), - IsHiddenItem = isHidden, - LoadFileIcon = false, - FileExtension = itemFileExtension, - ItemType = itemType, - Opacity = isHidden ? Constants.UI.DimItemOpacity : 1, - FileSize = fileSize.ToSizeString(), - FileSizeBytes = fileSize, - }; - } - else - { - if (fileName != "." && fileName != "..") - { - listedItem = new ListedItem(null) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemNameRaw = fileName, - ItemPath = itemPath, - ItemDateModifiedReal = systemModifiedTimeOutput.ToDateTime(), - ItemDateCreatedReal = systemCreatedTimeOutput.ToDateTime(), - IsHiddenItem = isHidden, - LoadFileIcon = false, - ItemType = folderTypeTextLocalized, - Opacity = isHidden ? Constants.UI.DimItemOpacity : 1 - }; - } - } - - return listedItem; - } - - private async Task GetListedItemAsync(IStorageItem item) - { - ListedItem? listedItem = null; - if (item.IsOfType(StorageItemTypes.Folder)) - { - var folder = item.AsBaseStorageFolder() - ?? throw new InvalidOperationException($"The search result '{item.Path}' could not be opened as a folder."); - - var props = await folder.GetBasicPropertiesAsync(); - if (folder is BinStorageFolder binFolder) - { - listedItem = new RecycleBinItem(null) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemNameRaw = folder.DisplayName, - ItemPath = folder.Path, - ItemDateModifiedReal = props.DateModified, - ItemDateCreatedReal = folder.DateCreated, - ItemType = folderTypeTextLocalized, - Opacity = 1, - FileSize = props.Size.ToSizeString(), - FileSizeBytes = (long)props.Size, - ItemDateDeletedReal = binFolder.DateDeleted, - ItemOriginalPath = binFolder.OriginalPath - }; - } - else - { - listedItem = new ListedItem(null) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemNameRaw = folder.DisplayName, - ItemPath = folder.Path, - ItemDateModifiedReal = props.DateModified, - ItemDateCreatedReal = folder.DateCreated, - ItemType = folderTypeTextLocalized, - Opacity = 1 - }; - } - } - else if (item.IsOfType(StorageItemTypes.File)) - { - var file = item.AsBaseStorageFile() - ?? throw new InvalidOperationException($"The search result '{item.Path}' could not be opened as a file."); - - var props = await file.GetBasicPropertiesAsync(); - string? itemFileExtension = null; - string? itemType = null; - if (file.Name.Contains('.', StringComparison.Ordinal)) - { - itemFileExtension = Path.GetExtension(file.Path); - itemType = itemFileExtension!.Trim('.') + " " + itemType; - } - - var itemSize = props.Size.ToSizeString(); - - if (file is BinStorageFile binFile) - { - listedItem = new RecycleBinItem(null) - { - PrimaryItemAttribute = StorageItemTypes.File, - ItemNameRaw = file.Name, - ItemPath = file.Path, - LoadFileIcon = false, - FileExtension = itemFileExtension, - FileSizeBytes = (long)props.Size, - FileSize = itemSize, - ItemDateModifiedReal = props.DateModified, - ItemDateCreatedReal = file.DateCreated, - ItemType = itemType, - Opacity = 1, - ItemDateDeletedReal = binFile.DateDeleted, - ItemOriginalPath = binFile.OriginalPath - }; - } - else if (FileExtensionHelpers.IsShortcutOrUrlFile(file.Path)) - { - var isUrl = FileExtensionHelpers.IsWebLinkFile(file.Path); - var shortcutItem = new ShortcutItem(null) - { - PrimaryItemAttribute = StorageItemTypes.File, - FileExtension = itemFileExtension, - IsHiddenItem = false, - Opacity = 1, - FileImage = null, - LoadFileIcon = false, - ItemNameRaw = file.Name, - ItemDateModifiedReal = props.DateModified, - ItemDateCreatedReal = file.DateCreated, - ItemType = isUrl ? Strings.ShortcutWebLinkFileType.GetLocalizedResource() : Strings.Shortcut.GetLocalizedResource(), - ItemPath = file.Path, - FileSize = itemSize, - FileSizeBytes = (long)props.Size, - IsUrl = isUrl, - }; - listedItem = shortcutItem; - } - else - { - listedItem = new ListedItem(null) - { - PrimaryItemAttribute = StorageItemTypes.File, - ItemNameRaw = file.Name, - ItemPath = file.Path, - LoadFileIcon = false, - FileExtension = itemFileExtension, - FileSizeBytes = (long)props.Size, - FileSize = itemSize, - ItemDateModifiedReal = props.DateModified, - ItemDateCreatedReal = file.DateCreated, - ItemType = itemType, - Opacity = 1 - }; - } - } - return listedItem - ?? throw new InvalidOperationException($"The search result '{item.Path}' is neither a file nor a folder."); - } - - private QueryOptions ToQueryOptions() - { - var query = new QueryOptions - { - FolderDepth = FolderDepth.Deep, - UserSearchFilter = AQSQuery ?? string.Empty, - }; - - query.IndexerOption = IndexerOption.UseIndexerWhenAvailable; - - query.SortOrder.Clear(); - query.SortOrder.Add(new SortEntry { PropertyName = "System.Search.Rank", AscendingOrder = false }); - - query.SetPropertyPrefetch(PropertyPrefetchOptions.BasicProperties, null); - - return query; - } - - private static Task> GetStorageFolderAsync(string path) - => FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path)); - private static Task> GetStorageFileAsync(string path) - => FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFileFromPathAsync(path)); } } diff --git a/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFile.cs b/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFile.cs index cd35f3d86c41..3414df304251 100644 --- a/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFile.cs @@ -41,13 +41,18 @@ public StorageProvider? Provider public abstract IStorageItemExtraProperties Properties { get; } [MaybeNull] StorageItemContentProperties IStorageItemProperties.Properties +#if WINDOWS => this is SystemStorageFile file ? file.File.Properties : null; +#else + => null; +#endif public static implicit operator BaseStorageFile?(StorageFile? value) { return value is not null ? new SystemStorageFile(value) : null; } + public abstract IAsyncOperation ToStorageFileAsync(); public abstract bool IsEqual(IStorageItem item); @@ -165,12 +170,21 @@ IAsyncOperation IStorageFile.CopyAsync(IStorageFolder destinationFo return Task.FromResult(null).AsAsyncOperation(); } +#if WINDOWS public static IAsyncOperation GetFileFromPathAsync(string path) { return AsyncInfo.Run(async (cancellationToken) => await ZipStorageFile.FromPathAsync(path) ?? await FtpStorageFile.FromPathAsync(path) ?? await ShellStorageFile.FromPathAsync(path) ?? await NativeStorageFile.FromPathAsync(path) ?? await SystemStorageFile.FromPathAsync(path) ); } +#else + // LINUX-TODO(storage): remove legacy WinRT resolution after downstream consumers use IStorableResolver. + public static IAsyncOperation GetFileFromPathAsync(string path) + { + return AsyncInfo.Run(async (cancellationToken) + => await FtpStorageFile.FromPathAsync(path) ?? await SystemStorageFile.FromPathAsync(path)); + } +#endif public async Task ReadTextAsync(int maxLength = -1) { diff --git a/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFolder.cs b/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFolder.cs index 474f59d5363c..c25b7a8fa5a2 100644 --- a/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFolder.cs +++ b/src/Files.App/Utils/Storage/StorageBaseItems/BaseStorageFolder.cs @@ -33,7 +33,11 @@ public StorageProvider? Provider [MaybeNull] StorageItemContentProperties IStorageItemProperties.Properties +#if WINDOWS => this is SystemStorageFolder folder ? folder.Folder.Properties : null; +#else + => null; +#endif public static implicit operator BaseStorageFolder?(StorageFolder? value) { @@ -43,6 +47,7 @@ value is not null : null; } + public abstract IAsyncOperation ToStorageFolderAsync(); public abstract bool IsEqual(IStorageItem item); @@ -139,12 +144,21 @@ IAsyncOperation IStorageFolder.GetFolderAsync(string name) }); } +#if WINDOWS public static IAsyncOperation GetFolderFromPathAsync(string path) { return AsyncInfo.Run(async (cancellationToken) => await ZipStorageFolder.FromPathAsync(path) ?? await FtpStorageFolder.FromPathAsync(path) ?? await ShellStorageFolder.FromPathAsync(path) ?? await SystemStorageFolder.FromPathAsync(path)); } +#else + // LINUX-TODO(storage): remove legacy WinRT resolution after downstream consumers use IStorableResolver. + public static IAsyncOperation GetFolderFromPathAsync(string path) + { + return AsyncInfo.Run(async (cancellationToken) + => await FtpStorageFolder.FromPathAsync(path) ?? await SystemStorageFolder.FromPathAsync(path)); + } +#endif public abstract IAsyncOperation?> GetFoldersAsync(); diff --git a/src/Files.App/Utils/Storage/StorageItems/FtpStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/FtpStorageFile.cs index 172195bd416b..64aab4378f2f 100644 --- a/src/Files.App/Utils/Storage/StorageItems/FtpStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageItems/FtpStorageFile.cs @@ -131,7 +131,12 @@ public override IAsyncOperation GetBasicPropertiesAsync() var inStream = await ftpClient.OpenRead(FtpPath, token: cancellationToken); var nsStream = new NonSeekableRandomAccessStreamForRead(inStream, (ulong)inStream.Length) { DisposeCallback = ftpClient.Dispose }; +#if WINDOWS return new StreamWithContentType(nsStream); +#else + // LINUX-TODO(storage): use the archive/FTP storable stream instead of the WinRT content stream. + throw new NotSupportedException("WinRT content streams are Windows-only."); +#endif }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); } public override IAsyncOperation OpenSequentialReadAsync() diff --git a/src/Files.App/Utils/Storage/StorageItems/NativeStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/NativeStorageFile.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/StorageItems/NativeStorageFile.cs rename to src/Files.App/Utils/Storage/StorageItems/NativeStorageFile.Windows.cs diff --git a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.Windows.cs similarity index 98% rename from src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs rename to src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.Windows.cs index a6af5536b394..4727af593fb0 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFile.Windows.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.Windows.cs similarity index 99% rename from src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs rename to src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.Windows.cs index 919847dd177a..e5fd5aec6e92 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ShellStorageFolder.Windows.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; } } diff --git a/src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs b/src/Files.App/Utils/Storage/StorageItems/StorageStreams.cs similarity index 60% rename from src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs rename to src/Files.App/Utils/Storage/StorageItems/StorageStreams.cs index e326951f1ac1..068ca9b7e62a 100644 --- a/src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.cs +++ b/src/Files.App/Utils/Storage/StorageItems/StorageStreams.cs @@ -2,12 +2,9 @@ // Licensed under the MIT License. using System.IO; -using System.Runtime.InteropServices.Marshalling; using System.Runtime.InteropServices.WindowsRuntime; using Windows.Foundation; using Windows.Storage.Streams; -using Windows.Win32; -using Windows.Win32.System.Com; namespace Files.App.Utils.Storage { @@ -228,134 +225,4 @@ public void Dispose() } } - public sealed partial class StreamWithContentType : IRandomAccessStreamWithContentType - { - private IRandomAccessStream baseStream; - - public StreamWithContentType(IRandomAccessStream stream) - { - baseStream = stream; - } - - public IInputStream GetInputStreamAt(ulong position) => baseStream.GetInputStreamAt(position); - - public IOutputStream GetOutputStreamAt(ulong position) => baseStream.GetOutputStreamAt(position); - - public void Seek(ulong position) => baseStream.Seek(position); - - public IRandomAccessStream CloneStream() => baseStream.CloneStream(); - - public bool CanRead => baseStream.CanRead; - - public bool CanWrite => baseStream.CanWrite; - - public ulong Position => baseStream.Position; - - public ulong Size { get => baseStream.Size; set => baseStream.Size = value; } - - public IAsyncOperationWithProgress ReadAsync(IBuffer buffer, uint count, InputStreamOptions options) - { - return baseStream.ReadAsync(buffer, count, options); - } - - public IAsyncOperationWithProgress WriteAsync(IBuffer buffer) => baseStream.WriteAsync(buffer); - - public IAsyncOperation FlushAsync() => baseStream.FlushAsync(); - - public void Dispose() - { - baseStream.Dispose(); - } - - public string ContentType { get; set; } = "application/octet-stream"; - } - - public sealed partial class ComStreamWrapper : Stream - { - private IStream? iStream; - private STATSTG iStreamStat; - private STGMEDIUM medium; - - public ComStreamWrapper(IStream stream, STGMEDIUM medium) - { - iStream = stream; - this.medium = medium; - try - { - iStream.Stat(out iStreamStat, STATFLAG.STATFLAG_NONAME).ThrowOnFailure(); - } - catch - { - ReleaseResources(); - throw; - } - } - - public override bool CanRead => true; - - public override bool CanSeek => true; - - public override bool CanWrite => false; - - public override long Length => checked((long)iStreamStat.cbSize); - - public override long Position - { - get => Seek(0, SeekOrigin.Current); - set => Seek(value, SeekOrigin.Begin); - } - - public override void Flush() - { - } - - public override int Read(byte[] buffer, int offset, int count) - { - ObjectDisposedException.ThrowIf(iStream is null, this); - iStream.Read(buffer.AsSpan(offset, count), out uint bytesRead).ThrowOnFailure(); - return checked((int)bytesRead); - } - - public override long Seek(long offset, SeekOrigin origin) - { - ObjectDisposedException.ThrowIf(iStream is null, this); - iStream.Seek(offset, origin, out ulong newPosition).ThrowOnFailure(); - return checked((long)newPosition); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); - } - - protected override void Dispose(bool disposing) - { - if (iStream is null) - return; - - base.Dispose(disposing); - ReleaseResources(); - } - - private void ReleaseResources() - { - IStream? stream = iStream; - iStream = null; - try - { - if ((object?)stream is ComObject comObject) - comObject.FinalRelease(); - } - finally - { - PInvoke.ReleaseStgMedium(ref medium); - medium = default; - } - } - } } diff --git a/src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.Windows.cs b/src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.Windows.cs new file mode 100644 index 000000000000..23c8afe68211 --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/StreamWithContentType.Windows.cs @@ -0,0 +1,144 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.IO; +using System.Runtime.InteropServices.Marshalling; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Storage.Streams; +using Windows.Win32; +using Windows.Win32.System.Com; + +namespace Files.App.Utils.Storage +{ + public sealed partial class StreamWithContentType : IRandomAccessStreamWithContentType + { + private IRandomAccessStream baseStream; + + public StreamWithContentType(IRandomAccessStream stream) + { + baseStream = stream; + } + + public IInputStream GetInputStreamAt(ulong position) => baseStream.GetInputStreamAt(position); + + public IOutputStream GetOutputStreamAt(ulong position) => baseStream.GetOutputStreamAt(position); + + public void Seek(ulong position) => baseStream.Seek(position); + + public IRandomAccessStream CloneStream() => baseStream.CloneStream(); + + public bool CanRead => baseStream.CanRead; + + public bool CanWrite => baseStream.CanWrite; + + public ulong Position => baseStream.Position; + + public ulong Size { get => baseStream.Size; set => baseStream.Size = value; } + + public IAsyncOperationWithProgress ReadAsync(IBuffer buffer, uint count, InputStreamOptions options) + { + return baseStream.ReadAsync(buffer, count, options); + } + + public IAsyncOperationWithProgress WriteAsync(IBuffer buffer) => baseStream.WriteAsync(buffer); + + public IAsyncOperation FlushAsync() => baseStream.FlushAsync(); + + public void Dispose() + { + baseStream.Dispose(); + } + + public string ContentType { get; set; } = "application/octet-stream"; + } + + public sealed partial class ComStreamWrapper : Stream + { + private IStream? iStream; + private STATSTG iStreamStat; + private STGMEDIUM medium; + + public ComStreamWrapper(IStream stream, STGMEDIUM medium) + { + iStream = stream; + this.medium = medium; + try + { + iStream.Stat(out iStreamStat, STATFLAG.STATFLAG_NONAME).ThrowOnFailure(); + } + catch + { + ReleaseResources(); + throw; + } + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => checked((long)iStreamStat.cbSize); + + public override long Position + { + get => Seek(0, SeekOrigin.Current); + set => Seek(value, SeekOrigin.Begin); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + ObjectDisposedException.ThrowIf(iStream is null, this); + iStream.Read(buffer.AsSpan(offset, count), out uint bytesRead).ThrowOnFailure(); + return checked((int)bytesRead); + } + + public override long Seek(long offset, SeekOrigin origin) + { + ObjectDisposedException.ThrowIf(iStream is null, this); + iStream.Seek(offset, origin, out ulong newPosition).ThrowOnFailure(); + return checked((long)newPosition); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (iStream is null) + return; + + base.Dispose(disposing); + ReleaseResources(); + } + + private void ReleaseResources() + { + IStream? stream = iStream; + iStream = null; + try + { + if ((object?)stream is ComObject comObject) + comObject.FinalRelease(); + } + finally + { + PInvoke.ReleaseStgMedium(ref medium); + medium = default; + } + } + } +} diff --git a/src/Files.App/Utils/Storage/StorageItems/SystemStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/SystemStorageFile.cs index d45d887e340e..017bf61e7780 100644 --- a/src/Files.App/Utils/Storage/StorageItems/SystemStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageItems/SystemStorageFile.cs @@ -21,13 +21,30 @@ public sealed partial class SystemStorageFile : BaseStorageFile public override string Name => File.Name; public override string DisplayName => File.DisplayName; public override string ContentType => File.ContentType; +#if WINDOWS public override string DisplayType => File.DisplayType; +#else + // Uno does not implement these members and throws + public override string DisplayType => string.Empty; +#endif public override string FileType => File.FileType; +#if WINDOWS public override string FolderRelativeId => File.FolderRelativeId; +#else + public override string FolderRelativeId => string.Empty; +#endif public override DateTimeOffset DateCreated => File.DateCreated; +#if WINDOWS public override Windows.Storage.FileAttributes Attributes => File.Attributes; +#else + public override Windows.Storage.FileAttributes Attributes => default; +#endif +#if WINDOWS public override IStorageItemExtraProperties Properties => File.Properties; +#else + public override IStorageItemExtraProperties Properties => null!; +#endif public SystemStorageFile(StorageFile file) { @@ -108,6 +125,7 @@ public override IAsyncOperation GetBasicPropertiesAsync() return destFile; } } +#if WINDOWS catch (UnauthorizedAccessException) // shortcuts & .url { if (!string.IsNullOrEmpty(destFolder.Path)) @@ -128,6 +146,12 @@ public override IAsyncOperation GetBasicPropertiesAsync() } throw; } +#else + catch (UnauthorizedAccessException) + { + throw; + } +#endif }); } diff --git a/src/Files.App/Utils/Storage/StorageItems/SystemStorageFolder.cs b/src/Files.App/Utils/Storage/StorageItems/SystemStorageFolder.cs index cc61e4c6e2e7..3c4e5b3e130f 100644 --- a/src/Files.App/Utils/Storage/StorageItems/SystemStorageFolder.cs +++ b/src/Files.App/Utils/Storage/StorageItems/SystemStorageFolder.cs @@ -19,12 +19,29 @@ public sealed partial class SystemStorageFolder : BaseStorageFolder public override string Path => Folder.Path; public override string Name => Folder.Name; public override string DisplayName => Folder.DisplayName; +#if WINDOWS public override string DisplayType => Folder.DisplayType; +#else + // Uno does not implement these members and throws + public override string DisplayType => string.Empty; +#endif +#if WINDOWS public override string FolderRelativeId => Folder.FolderRelativeId; +#else + public override string FolderRelativeId => string.Empty; +#endif public override DateTimeOffset DateCreated => Folder.DateCreated; + #if WINDOWS public override FileAttributes Attributes => Folder.Attributes; +#else + public override FileAttributes Attributes => default; +#endif +#if WINDOWS public override IStorageItemExtraProperties Properties => Folder.Properties; +#else + public override IStorageItemExtraProperties Properties => null!; +#endif public SystemStorageFolder(StorageFolder folder) { @@ -34,6 +51,7 @@ public SystemStorageFolder(StorageFolder folder) public static IAsyncOperation FromPathAsync(string path) { +#if WINDOWS if (path.EndsWith(ShellLibraryItem.EXTENSION)) { try @@ -56,6 +74,7 @@ public static IAsyncOperation FromPathAsync(string path) App.Logger.LogWarning(e, null); } } +#endif return AsyncInfo.Run(async (cancellationToken) => new SystemStorageFolder(await StorageFolder.GetFolderFromPathAsync(path))); } diff --git a/src/Files.App/Utils/Storage/StorageItems/VirtualStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/VirtualStorageFile.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/StorageItems/VirtualStorageFile.cs rename to src/Files.App/Utils/Storage/StorageItems/VirtualStorageFile.Windows.cs diff --git a/src/Files.App/Utils/Storage/StorageItems/VirtualStorageFolder.cs b/src/Files.App/Utils/Storage/StorageItems/VirtualStorageFolder.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/StorageItems/VirtualStorageFolder.cs rename to src/Files.App/Utils/Storage/StorageItems/VirtualStorageFolder.Windows.cs diff --git a/src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.cs b/src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.Windows.cs similarity index 100% rename from src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.cs rename to src/Files.App/Utils/Storage/StorageItems/VirtualStorageItem.Windows.cs diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs new file mode 100644 index 000000000000..59ba26636d8e --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.Windows.cs @@ -0,0 +1,848 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using ICSharpCode.SharpZipLib.Zip; +using SevenZip; +using System.IO; +using System.Text; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Streams; +using Windows.Win32; +using IO = System.IO; + +namespace Files.App.Utils.Storage +{ + public sealed partial class ZipStorageFile : BaseStorageFile, IPasswordProtectedItem + { + private readonly string containerPath; + private readonly BaseStorageFile? backingFile; + + public override string Path { get; } + public override string Name { get; } + public override string DisplayName => Name; + public override string ContentType => "application/octet-stream"; + public override string FileType => IO.Path.GetExtension(Name); + public override string FolderRelativeId => $"0\\{Name}"; + + public override string DisplayType + { + get + { + var itemType = Strings.File.GetLocalizedResource(); + if (Name.Contains('.', StringComparison.Ordinal)) + { + itemType = FileType.Trim('.') + " " + itemType; + } + return itemType; + } + } + + public override DateTimeOffset DateCreated { get; } + public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Normal | Windows.Storage.FileAttributes.ReadOnly; + + private IStorageItemExtraProperties? properties; + public override IStorageItemExtraProperties Properties => properties ??= new BaseBasicStorageItemExtraProperties(this); + + public StorageCredential? Credentials { get; set; } = new(); + private string Password => Credentials?.Password + ?? throw new InvalidOperationException("Archive credentials are not available."); + + internal Encoding? CurrentEncoding { get; set; } + + public Func>? PasswordRequestedCallback { get; set; } + + public ZipStorageFile(string path, string containerPath) + { + Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); + Path = path; + this.containerPath = containerPath; + } + public ZipStorageFile(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) + => this.backingFile = backingFile; + public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) + => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) + => this.backingFile = backingFile; + + public override IAsyncOperation ToStorageFileAsync() + => StorageFile.CreateStreamedFileAsync( + Name, + CurrentEncoding is not null && Path != containerPath + ? ZipDataStreamingHandlerWithEncoding(Path) + : ZipDataStreamingHandler(Path), + null + ); + + public static IAsyncOperation FromPathAsync(string path) + { + var containerPath = ZipStorageFolder.GetContainerPath(path); + if (containerPath is null) + return Task.FromResult(null).AsAsyncOperation(); + + if (path == containerPath) + return Task.FromResult(null).AsAsyncOperation(); // Root + + if (CheckAccess(containerPath)) + { + var file = new ZipStorageFile(path, containerPath); + if (ZipStorageFolder.TryGetEncodingForContainerPath(containerPath, out var encoding)) + file.CurrentEncoding = encoding; + return Task.FromResult(file).AsAsyncOperation(); + } + return Task.FromResult(null).AsAsyncOperation(); + } + + public override bool IsEqual(IStorageItem item) => item?.Path == Path; + public override bool IsOfType(StorageItemTypes type) => type is StorageItemTypes.File; + + public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); + public override IAsyncOperation GetBasicPropertiesAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (CurrentEncoding is not null && Path != containerPath) + return await GetBasicPropertiesWithEncodingAsync(); + + return await GetBasicProperties(); + }); + } + + public override IAsyncOperation OpenAsync(FileAccessMode accessMode) + { + if (CurrentEncoding is not null && Path != containerPath) + return OpenWithEncodingAsync(accessMode); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + bool rw = accessMode is FileAccessMode.ReadWrite; + if (Path == containerPath) + { + if (backingFile is not null) + { + return await backingFile.OpenAsync(accessMode); + } + + var file = Win32Helper.OpenFileForRead(containerPath, rw); + return file.IsInvalid ? null : new FileStream(file, rw ? FileAccess.ReadWrite : FileAccess.Read).AsRandomAccessStream(); + } + + if (!rw) + { + SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + + if (entry.FileName is not null) + { + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) + { + DisposeCallback = () => zipFile.Dispose() + }; + } + return null; + } + + throw new NotSupportedException("Can't open zip file as RW"); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation OpenWithEncodingAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + bool rw = accessMode is FileAccessMode.ReadWrite; + if (rw) + throw new NotSupportedException("Can't open zip file as RW"); + + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + var ms = new MemoryStream(); + using (var zipStream = zipFile.GetInputStream(entry)) + { + zipStream.CopyTo(ms); + } + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + } + } + + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenAsync(FileAccessMode accessMode, StorageOpenOptions options) + => OpenAsync(accessMode); + + public override IAsyncOperation OpenReadAsync() + { + if (CurrentEncoding is not null && Path != containerPath) + return OpenReadWithEncodingAsync(); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + return await backingFile.OpenReadAsync(); + } + + var hFile = Win32Helper.OpenFileForRead(containerPath); + return hFile.IsInvalid ? null : new StreamWithContentType(new FileStream(hFile, FileAccess.Read).AsRandomAccessStream()); + } + + SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return null; + } + + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + var nsStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size) + { + DisposeCallback = () => zipFile.Dispose() + }; + return new StreamWithContentType(nsStream); + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation OpenReadWithEncodingAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + var ms = new MemoryStream(); + using (var zipStream = zipFile.GetInputStream(entry)) + { + zipStream.CopyTo(ms); + } + ms.Position = 0; + var nsStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + return new StreamWithContentType(nsStream); + } + } + return null; + + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenSequentialReadAsync() + { + if (CurrentEncoding is not null && Path != containerPath) + return OpenSequentialReadWithEncodingAsync(); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + return await backingFile.OpenSequentialReadAsync(); + } + + var hFile = Win32Helper.OpenFileForRead(containerPath); + return hFile.IsInvalid ? null : new FileStream(hFile, FileAccess.Read).AsInputStream(); + } + + SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return null; + } + + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) + { + DisposeCallback = () => zipFile.Dispose() + }; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation OpenSequentialReadWithEncodingAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + var ms = new MemoryStream(); + using (var zipStream = zipFile.GetInputStream(entry)) + { + zipStream.CopyTo(ms); + } + ms.Position = 0; + return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + } + } + + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation OpenTransactedWriteAsync() + => throw new NotSupportedException(); + public override IAsyncOperation OpenTransactedWriteAsync(StorageOpenOptions options) + => throw new NotSupportedException(); + + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder) + => CopyAsync(destinationFolder, Name, NameCollisionOption.FailIfExists); + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName) + => CopyAsync(destinationFolder, desiredNewName, NameCollisionOption.FailIfExists); + public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + { + if (CurrentEncoding is not null && Path != containerPath) + return CopyWithEncodingAsync(destinationFolder, desiredNewName, option); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return null; + } + + var destFolder = destinationFolder.AsBaseStorageFolder() + ?? throw new NotSupportedException("The destination folder type is not supported."); + + if (destFolder is ICreateFileWithStream cwsf) + { + var ms = new MemoryStream(); + await zipFile.ExtractFileAsync(entry.Index, ms); + ms.Position = 0; + using var inStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size); + return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); + } + else + { + var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) + ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); + + await using var outStream = await destFile.OpenStreamForWriteAsync(); + await SafetyExtensions.WrapAsync(() => zipFile.ExtractFileAsync(entry.Index, outStream), async (_, exception) => + { + await destFile.DeleteAsync(); + throw exception; + }); + return destFile; + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation CopyWithEncodingAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + var entry = zipFile.Cast().FirstOrDefault(x => x.IsFile && string.Equals(x.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)); + if (entry is null) + { + return null; + } + + var destFolder = destinationFolder.AsBaseStorageFolder() + ?? throw new NotSupportedException("The destination folder type is not supported."); + if (destFolder is ICreateFileWithStream cwsf) + { + var ms = new MemoryStream(); + using var zipStream = zipFile.GetInputStream(entry); + zipStream.CopyTo(ms); + ms.Position = 0; + using var inStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); + return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); + } + else + { + var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) + ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); + await using var outStream = await destFile.OpenStreamForWriteAsync(); + using var zipStream = zipFile.GetInputStream(entry); + zipStream.CopyTo(outStream); + return destFile; + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + public override IAsyncAction CopyAndReplaceAsync(IStorageFile fileToReplace) + { + if (CurrentEncoding is not null && Path != containerPath) + return CopyAndReplaceWithEncodingAsync(fileToReplace); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is null) + { + return; + } + + await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) + { + outStream.SetLength(0); + await zipFile.ExtractFileAsync(entry.Index, outStream); + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncAction CopyAndReplaceWithEncodingAsync(IStorageFile fileToReplace) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + { + await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) + using (var zipStream = zipFile.GetInputStream(entry)) + { + outStream.SetLength(0); + zipStream.CopyTo(outStream); + } + return; + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder) + => throw new NotSupportedException(); + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName) + => throw new NotSupportedException(); + public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) + => throw new NotSupportedException(); + public override IAsyncAction MoveAndReplaceAsync(IStorageFile fileToReplace) + => throw new NotSupportedException(); + + public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); + public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.RenameAsync(desiredName, option); + } + else + { + var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); + var fileName = IO.Path.Combine(parentPath, desiredName); + PInvoke.MoveFileFromApp(Path, fileName); + } + } + else + { + var index = await FetchZipIndex(); + if (index < 0) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive entry has no parent."); + var fileName = IO.Path.GetRelativePath(containerPath, IO.Path.Combine(parentPath, desiredName)); + await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, fileName } }, Password, ms); + } + + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); + public override IAsyncAction DeleteAsync(StorageDeleteOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.DeleteAsync(); + } + else if (option == StorageDeleteOption.PermanentDelete) + { + PInvoke.DeleteFileFromApp(Path); + } + else + { + throw new NotSupportedException("Moving to recycle bin is not supported."); + } + } + else + { + var index = await FetchZipIndex(); + if (index < 0) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, null } }, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) + => Task.FromResult(null).AsAsyncOperation(); + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) + => Task.FromResult(null).AsAsyncOperation(); + + private string GetEntryRelativePath() + { + var relative = Path.Substring(containerPath.Length).Trim('\\', '/'); + return relative.Replace('\\', '/'); + } + + private static bool CheckAccess(string path) + { + try + { + var hFile = Win32Helper.OpenFileForRead(path); + if (hFile.IsInvalid) + { + return false; + } + using (SevenZipExtractor zipFile = new SevenZipExtractor(new FileStream(hFile, FileAccess.Read))) + { + //zipFile.IsStreamOwner = true; + return zipFile.ArchiveFileData is not null; + } + } + catch (SevenZipOpenFailedException ex) + { + return ex.Result == OperationResult.WrongPassword; + } + catch + { + return false; + } + } + + private async Task FetchZipIndex() + { + using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) + { + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return -1; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + if (entry.FileName is not null) + { + return entry.Index; + } + return -1; + } + } + + private async Task GetBasicProperties() + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return new BaseBasicProperties(); + } + + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + + return entry.FileName is null + ? new BaseBasicProperties() + : new ZipFileBasicProperties(entry); + } + + private Task GetBasicPropertiesWithEncodingAsync() + { + return Task.Run(() => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetName = GetEntryRelativePath(); + + foreach (ZipEntry entry in zipFile) + { + if (!entry.IsFile) + continue; + + if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) + return new ZipFileBasicPropertiesWithEncoding(entry); + } + + return new BaseBasicProperties(); + }); + } + + private IAsyncOperation OpenZipFileAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + var zipFile = await OpenZipFileAsync(FileAccessMode.Read); + return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; + }); + } + + private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run(async (cancellationToken) => + { + bool readWrite = accessMode == FileAccessMode.ReadWrite; + if (backingFile is not null) + { + var stream = await backingFile.OpenAsync(accessMode); + return stream?.AsStream(); + } + else + { + var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); + return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); + } + }); + } + + private StreamedFileDataRequestedHandler ZipDataStreamingHandler(string name) + { + return async request => + { + try + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + return; + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == name); + if (entry.FileName is null) + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + } + else + { + await using (var outStream = request.AsStreamForWrite()) + { + await zipFile.ExtractFileAsync(entry.Index, outStream); + } + request.Dispose(); + } + } + catch + { + request.FailAndClose(StreamedFileFailureMode.Failed); + } + }; + } + + private StreamedFileDataRequestedHandler ZipDataStreamingHandlerWithEncoding(string name) + { + return async request => + { + try + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var entry = zipFile.Cast().FirstOrDefault( + x => x.IsFile + && string.Equals( + System.IO.Path.Combine( + containerPath, x.Name.Replace('/', '\\') + ), + name, + StringComparison.OrdinalIgnoreCase + ) + ); + if (entry is not null && entry.IsFile) + { + using var zipStream = zipFile.GetInputStream(entry); + await using (var outStream = request.AsStreamForWrite()) + { + await zipStream.CopyToAsync(outStream); + } + request.Dispose(); + } + else + { + request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); + } + } + catch + { + request.FailAndClose(StreamedFileFailureMode.Failed); + } + }; + } + + private sealed partial class ZipFileBasicProperties : BaseBasicProperties + { + private ArchiveFileInfo entry; + + public ZipFileBasicProperties(ArchiveFileInfo entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; + + public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + + public override ulong Size => entry.Size; + } + + private sealed partial class ZipFileBasicPropertiesWithEncoding : BaseBasicProperties + { + private ZipEntry entry; + + public ZipFileBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; + + public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; + + public override ulong Size => (ulong)entry.Size; + } + } +} diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs index 59ba26636d8e..98f9b2ed1949 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFile.cs @@ -1,848 +1,16 @@ // Copyright (c) Files Community // Licensed under the MIT License. -using Files.Shared.Helpers; -using ICSharpCode.SharpZipLib.Zip; -using SevenZip; -using System.IO; -using System.Text; +#if !WINDOWS using System.Runtime.InteropServices.WindowsRuntime; using Windows.Foundation; -using Windows.Storage; -using Windows.Storage.FileProperties; -using Windows.Storage.Streams; -using Windows.Win32; -using IO = System.IO; namespace Files.App.Utils.Storage { - public sealed partial class ZipStorageFile : BaseStorageFile, IPasswordProtectedItem + // LINUX-TODO(archives): remove this inactive type after legacy consumers migrate to ArchiveEntryFile. + public abstract class ZipStorageFile : BaseStorageFile { - private readonly string containerPath; - private readonly BaseStorageFile? backingFile; - - public override string Path { get; } - public override string Name { get; } - public override string DisplayName => Name; - public override string ContentType => "application/octet-stream"; - public override string FileType => IO.Path.GetExtension(Name); - public override string FolderRelativeId => $"0\\{Name}"; - - public override string DisplayType - { - get - { - var itemType = Strings.File.GetLocalizedResource(); - if (Name.Contains('.', StringComparison.Ordinal)) - { - itemType = FileType.Trim('.') + " " + itemType; - } - return itemType; - } - } - - public override DateTimeOffset DateCreated { get; } - public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Normal | Windows.Storage.FileAttributes.ReadOnly; - - private IStorageItemExtraProperties? properties; - public override IStorageItemExtraProperties Properties => properties ??= new BaseBasicStorageItemExtraProperties(this); - - public StorageCredential? Credentials { get; set; } = new(); - private string Password => Credentials?.Password - ?? throw new InvalidOperationException("Archive credentials are not available."); - - internal Encoding? CurrentEncoding { get; set; } - - public Func>? PasswordRequestedCallback { get; set; } - - public ZipStorageFile(string path, string containerPath) - { - Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); - Path = path; - this.containerPath = containerPath; - } - public ZipStorageFile(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) - => this.backingFile = backingFile; - public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) - => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - public ZipStorageFile(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) - => this.backingFile = backingFile; - - public override IAsyncOperation ToStorageFileAsync() - => StorageFile.CreateStreamedFileAsync( - Name, - CurrentEncoding is not null && Path != containerPath - ? ZipDataStreamingHandlerWithEncoding(Path) - : ZipDataStreamingHandler(Path), - null - ); - - public static IAsyncOperation FromPathAsync(string path) - { - var containerPath = ZipStorageFolder.GetContainerPath(path); - if (containerPath is null) - return Task.FromResult(null).AsAsyncOperation(); - - if (path == containerPath) - return Task.FromResult(null).AsAsyncOperation(); // Root - - if (CheckAccess(containerPath)) - { - var file = new ZipStorageFile(path, containerPath); - if (ZipStorageFolder.TryGetEncodingForContainerPath(containerPath, out var encoding)) - file.CurrentEncoding = encoding; - return Task.FromResult(file).AsAsyncOperation(); - } - return Task.FromResult(null).AsAsyncOperation(); - } - - public override bool IsEqual(IStorageItem item) => item?.Path == Path; - public override bool IsOfType(StorageItemTypes type) => type is StorageItemTypes.File; - - public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); - public override IAsyncOperation GetBasicPropertiesAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (CurrentEncoding is not null && Path != containerPath) - return await GetBasicPropertiesWithEncodingAsync(); - - return await GetBasicProperties(); - }); - } - - public override IAsyncOperation OpenAsync(FileAccessMode accessMode) - { - if (CurrentEncoding is not null && Path != containerPath) - return OpenWithEncodingAsync(accessMode); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - bool rw = accessMode is FileAccessMode.ReadWrite; - if (Path == containerPath) - { - if (backingFile is not null) - { - return await backingFile.OpenAsync(accessMode); - } - - var file = Win32Helper.OpenFileForRead(containerPath, rw); - return file.IsInvalid ? null : new FileStream(file, rw ? FileAccess.ReadWrite : FileAccess.Read).AsRandomAccessStream(); - } - - if (!rw) - { - SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - - if (entry.FileName is not null) - { - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) - { - DisposeCallback = () => zipFile.Dispose() - }; - } - return null; - } - - throw new NotSupportedException("Can't open zip file as RW"); - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation OpenWithEncodingAsync(FileAccessMode accessMode) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - bool rw = accessMode is FileAccessMode.ReadWrite; - if (rw) - throw new NotSupportedException("Can't open zip file as RW"); - - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - var ms = new MemoryStream(); - using (var zipStream = zipFile.GetInputStream(entry)) - { - zipStream.CopyTo(ms); - } - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - } - } - - return null; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation OpenAsync(FileAccessMode accessMode, StorageOpenOptions options) - => OpenAsync(accessMode); - - public override IAsyncOperation OpenReadAsync() - { - if (CurrentEncoding is not null && Path != containerPath) - return OpenReadWithEncodingAsync(); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - return await backingFile.OpenReadAsync(); - } - - var hFile = Win32Helper.OpenFileForRead(containerPath); - return hFile.IsInvalid ? null : new StreamWithContentType(new FileStream(hFile, FileAccess.Read).AsRandomAccessStream()); - } - - SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return null; - } - - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - var nsStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size) - { - DisposeCallback = () => zipFile.Dispose() - }; - return new StreamWithContentType(nsStream); - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation OpenReadWithEncodingAsync() - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - var ms = new MemoryStream(); - using (var zipStream = zipFile.GetInputStream(entry)) - { - zipStream.CopyTo(ms); - } - ms.Position = 0; - var nsStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - return new StreamWithContentType(nsStream); - } - } - return null; - - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation OpenSequentialReadAsync() - { - if (CurrentEncoding is not null && Path != containerPath) - return OpenSequentialReadWithEncodingAsync(); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - return await backingFile.OpenSequentialReadAsync(); - } - - var hFile = Win32Helper.OpenFileForRead(containerPath); - return hFile.IsInvalid ? null : new FileStream(hFile, FileAccess.Read).AsInputStream(); - } - - SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return null; - } - - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, entry.Size) - { - DisposeCallback = () => zipFile.Dispose() - }; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation OpenSequentialReadWithEncodingAsync() - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - var ms = new MemoryStream(); - using (var zipStream = zipFile.GetInputStream(entry)) - { - zipStream.CopyTo(ms); - } - ms.Position = 0; - return new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - } - } - - return null; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation OpenTransactedWriteAsync() - => throw new NotSupportedException(); - public override IAsyncOperation OpenTransactedWriteAsync(StorageOpenOptions options) - => throw new NotSupportedException(); - - public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder) - => CopyAsync(destinationFolder, Name, NameCollisionOption.FailIfExists); - public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName) - => CopyAsync(destinationFolder, desiredNewName, NameCollisionOption.FailIfExists); - public override IAsyncOperation CopyAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) - { - if (CurrentEncoding is not null && Path != containerPath) - return CopyWithEncodingAsync(destinationFolder, desiredNewName, option); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return null; - } - - var destFolder = destinationFolder.AsBaseStorageFolder() - ?? throw new NotSupportedException("The destination folder type is not supported."); - - if (destFolder is ICreateFileWithStream cwsf) - { - var ms = new MemoryStream(); - await zipFile.ExtractFileAsync(entry.Index, ms); - ms.Position = 0; - using var inStream = new NonSeekableRandomAccessStreamForRead(ms, entry.Size); - return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); - } - else - { - var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) - ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); - - await using var outStream = await destFile.OpenStreamForWriteAsync(); - await SafetyExtensions.WrapAsync(() => zipFile.ExtractFileAsync(entry.Index, outStream), async (_, exception) => - { - await destFile.DeleteAsync(); - throw exception; - }); - return destFile; - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation CopyWithEncodingAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - var entry = zipFile.Cast().FirstOrDefault(x => x.IsFile && string.Equals(x.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)); - if (entry is null) - { - return null; - } - - var destFolder = destinationFolder.AsBaseStorageFolder() - ?? throw new NotSupportedException("The destination folder type is not supported."); - if (destFolder is ICreateFileWithStream cwsf) - { - var ms = new MemoryStream(); - using var zipStream = zipFile.GetInputStream(entry); - zipStream.CopyTo(ms); - ms.Position = 0; - using var inStream = new NonSeekableRandomAccessStreamForRead(ms, (ulong)entry.Size); - return await cwsf.CreateFileAsync(inStream.AsStreamForRead(), desiredNewName, option.Convert()); - } - else - { - var destFile = await destFolder.CreateFileAsync(desiredNewName, option.Convert()) - ?? throw new IOException($"Failed to create destination file '{desiredNewName}'."); - await using var outStream = await destFile.OpenStreamForWriteAsync(); - using var zipStream = zipFile.GetInputStream(entry); - zipStream.CopyTo(outStream); - return destFile; - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - public override IAsyncAction CopyAndReplaceAsync(IStorageFile fileToReplace) - { - if (CurrentEncoding is not null && Path != containerPath) - return CopyAndReplaceWithEncodingAsync(fileToReplace); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is null) - { - return; - } - - await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) - { - outStream.SetLength(0); - await zipFile.ExtractFileAsync(entry.Index, outStream); - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncAction CopyAndReplaceWithEncodingAsync(IStorageFile fileToReplace) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - { - await using (var outStream = await fileToReplace.OpenStreamForWriteAsync()) - using (var zipStream = zipFile.GetInputStream(entry)) - { - outStream.SetLength(0); - zipStream.CopyTo(outStream); - } - return; - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncAction MoveAsync(IStorageFolder destinationFolder) - => throw new NotSupportedException(); - public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName) - => throw new NotSupportedException(); - public override IAsyncAction MoveAsync(IStorageFolder destinationFolder, string desiredNewName, NameCollisionOption option) - => throw new NotSupportedException(); - public override IAsyncAction MoveAndReplaceAsync(IStorageFile fileToReplace) - => throw new NotSupportedException(); - - public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); - public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.RenameAsync(desiredName, option); - } - else - { - var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); - var fileName = IO.Path.Combine(parentPath, desiredName); - PInvoke.MoveFileFromApp(Path, fileName); - } - } - else - { - var index = await FetchZipIndex(); - if (index < 0) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive entry has no parent."); - var fileName = IO.Path.GetRelativePath(containerPath, IO.Path.Combine(parentPath, desiredName)); - await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, fileName } }, Password, ms); - } - - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); - public override IAsyncAction DeleteAsync(StorageDeleteOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.DeleteAsync(); - } - else if (option == StorageDeleteOption.PermanentDelete) - { - PInvoke.DeleteFileFromApp(Path); - } - else - { - throw new NotSupportedException("Moving to recycle bin is not supported."); - } - } - else - { - var index = await FetchZipIndex(); - if (index < 0) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - await compressor.ModifyArchiveAsync(archiveStream, new Dictionary() { { index, null } }, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) - => Task.FromResult(null).AsAsyncOperation(); - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) - => Task.FromResult(null).AsAsyncOperation(); - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) - => Task.FromResult(null).AsAsyncOperation(); - - private string GetEntryRelativePath() - { - var relative = Path.Substring(containerPath.Length).Trim('\\', '/'); - return relative.Replace('\\', '/'); - } - - private static bool CheckAccess(string path) - { - try - { - var hFile = Win32Helper.OpenFileForRead(path); - if (hFile.IsInvalid) - { - return false; - } - using (SevenZipExtractor zipFile = new SevenZipExtractor(new FileStream(hFile, FileAccess.Read))) - { - //zipFile.IsStreamOwner = true; - return zipFile.ArchiveFileData is not null; - } - } - catch (SevenZipOpenFailedException ex) - { - return ex.Result == OperationResult.WrongPassword; - } - catch - { - return false; - } - } - - private async Task FetchZipIndex() - { - using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) - { - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return -1; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - if (entry.FileName is not null) - { - return entry.Index; - } - return -1; - } - } - - private async Task GetBasicProperties() - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return new BaseBasicProperties(); - } - - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - - return entry.FileName is null - ? new BaseBasicProperties() - : new ZipFileBasicProperties(entry); - } - - private Task GetBasicPropertiesWithEncodingAsync() - { - return Task.Run(() => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetName = GetEntryRelativePath(); - - foreach (ZipEntry entry in zipFile) - { - if (!entry.IsFile) - continue; - - if (string.Equals(entry.Name.Replace('\\', '/'), targetName, StringComparison.OrdinalIgnoreCase)) - return new ZipFileBasicPropertiesWithEncoding(entry); - } - - return new BaseBasicProperties(); - }); - } - - private IAsyncOperation OpenZipFileAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - var zipFile = await OpenZipFileAsync(FileAccessMode.Read); - return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; - }); - } - - private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) - { - return AsyncInfo.Run(async (cancellationToken) => - { - bool readWrite = accessMode == FileAccessMode.ReadWrite; - if (backingFile is not null) - { - var stream = await backingFile.OpenAsync(accessMode); - return stream?.AsStream(); - } - else - { - var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); - return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); - } - }); - } - - private StreamedFileDataRequestedHandler ZipDataStreamingHandler(string name) - { - return async request => - { - try - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); - return; - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == name); - if (entry.FileName is null) - { - request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); - } - else - { - await using (var outStream = request.AsStreamForWrite()) - { - await zipFile.ExtractFileAsync(entry.Index, outStream); - } - request.Dispose(); - } - } - catch - { - request.FailAndClose(StreamedFileFailureMode.Failed); - } - }; - } - - private StreamedFileDataRequestedHandler ZipDataStreamingHandlerWithEncoding(string name) - { - return async request => - { - try - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var entry = zipFile.Cast().FirstOrDefault( - x => x.IsFile - && string.Equals( - System.IO.Path.Combine( - containerPath, x.Name.Replace('/', '\\') - ), - name, - StringComparison.OrdinalIgnoreCase - ) - ); - if (entry is not null && entry.IsFile) - { - using var zipStream = zipFile.GetInputStream(entry); - await using (var outStream = request.AsStreamForWrite()) - { - await zipStream.CopyToAsync(outStream); - } - request.Dispose(); - } - else - { - request.FailAndClose(StreamedFileFailureMode.CurrentlyUnavailable); - } - } - catch - { - request.FailAndClose(StreamedFileFailureMode.Failed); - } - }; - } - - private sealed partial class ZipFileBasicProperties : BaseBasicProperties - { - private ArchiveFileInfo entry; - - public ZipFileBasicProperties(ArchiveFileInfo entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; - - public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - - public override ulong Size => entry.Size; - } - - private sealed partial class ZipFileBasicPropertiesWithEncoding : BaseBasicProperties - { - private ZipEntry entry; - - public ZipFileBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; - - public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; - - public override ulong Size => (ulong)entry.Size; - } + public static IAsyncOperation FromPathAsync(string path) => Task.FromResult(null).AsAsyncOperation(); } } +#endif diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs new file mode 100644 index 000000000000..b72ef63f46ab --- /dev/null +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.Windows.cs @@ -0,0 +1,898 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using ICSharpCode.SharpZipLib.Zip; +using SevenZip; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Text; +using Windows.ApplicationModel; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Search; +using Windows.Win32; +using IO = System.IO; + +namespace Files.App.Utils.Storage +{ + public sealed partial class ZipStorageFolder : BaseStorageFolder, ICreateFileWithStream, IPasswordProtectedItem + { + private readonly string containerPath; + private BaseStorageFile? backingFile; + + private Encoding? _currentEncoding; + + // Maps container paths to their configured encoding. + // - Key missing: encoding not yet set; detection required. + // - Value null: archive opened with system default encoding. + // - Value set: archive opened with the specified encoding. + private static readonly ConcurrentDictionary _encodingByContainerPath = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the encoding to use when browsing this ZIP file. + /// When set, SharpZipLib is used instead of SevenZipSharp. + /// + internal Encoding? CurrentEncoding + { + get => _currentEncoding; + set + { + _currentEncoding = value; + _encodingByContainerPath[containerPath] = value; + } + } + + internal static bool TryGetEncodingForContainerPath(string containerPath, out Encoding? encoding) + => _encodingByContainerPath.TryGetValue(containerPath, out encoding); + + internal static void SetEncodingForContainerPath(string containerPath, Encoding? encoding) + { + _encodingByContainerPath[containerPath] = encoding; + } + + public override string Path { get; } + public override string Name { get; } + public override string DisplayName => Name; + public override string DisplayType => Strings.Folder.GetLocalizedResource(); + public override string FolderRelativeId => $"0\\{Name}"; + + public override DateTimeOffset DateCreated { get; } + public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Directory; + public override IStorageItemExtraProperties Properties => new BaseBasicStorageItemExtraProperties(this); + + public StorageCredential? Credentials { get; set; } = new(); + private string Password => Credentials?.Password + ?? throw new InvalidOperationException("Archive credentials are not available."); + + public Func>? PasswordRequestedCallback { get; set; } + + public ZipStorageFolder(string path, string containerPath) + { + Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); + Path = path; + this.containerPath = containerPath; + _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); + } + public ZipStorageFolder(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) + => this.backingFile = backingFile; + public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) + => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + public ZipStorageFolder(BaseStorageFile backingFile) + { + ArgumentException.ThrowIfNullOrEmpty(backingFile.Path); + Name = IO.Path.GetFileName(backingFile.Path.TrimEnd('\\', '/')); + Path = backingFile.Path; + this.containerPath = backingFile.Path; + this.backingFile = backingFile; + _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); + } + public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) + => this.backingFile = backingFile; + + public static string? GetContainerPath(string path) + { + if (!FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) + return null; + + var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); + if (marker is -1) + return null; + + return path.Substring(0, marker + ext.Length); + } + + public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) + { + if (path is null || !FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) + { + return false; + } + var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); + if (marker is -1) + { + return false; + } + marker += ext.Length; + // If IO.Path.Exists returns true, it is not a zip path but a normal directory path that contains ".zip". + return (marker == path.Length && includeRoot && !IO.Path.Exists(path + "\\")) + || (marker < path.Length && path[marker] is '\\' && !IO.Path.Exists(path)); + } + + public async Task GetUncompressedSize() + { + long uncompressedSize = 0; + using SevenZipExtractor? zipFile = await FilesystemTasks.WrapNullable(async () => + { + var arch = await OpenZipFileAsync(); + return arch?.ArchiveFileData is null ? null : arch; // Force load archive (1665013614u) + }); + + if (zipFile is not null) + { + foreach (var info in zipFile.ArchiveFileData.Where(x => !x.IsDirectory)) + { + uncompressedSize += (long)info.Size; + } + } + + return uncompressedSize; + } + + private static ConcurrentDictionary> defaultAppDict = new(); + public static async Task CheckDefaultZipApp(string filePath) + { + Func> queryFileAssoc = async () => + { + var assoc = await Win32Helper.GetDefaultFileAssociationAsync(filePath); + if (assoc is not null) + { + return Constants.Distributions.KnownAppNames.Any(x => assoc.StartsWith(x, StringComparison.OrdinalIgnoreCase)) + || assoc == Package.Current.Id.FamilyName + || assoc.EndsWith("Files.exe", StringComparison.OrdinalIgnoreCase) + || assoc.Equals(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), StringComparison.OrdinalIgnoreCase); + } + return true; + }; + var ext = IO.Path.GetExtension(filePath)?.ToLowerInvariant(); + return await defaultAppDict.GetAsync(ext ?? "", queryFileAssoc); + } + + public static IAsyncOperation FromPathAsync(string path) + { + var containerPath = GetContainerPath(path); + if (containerPath is not null && CheckAccess(containerPath)) + { + return Task.FromResult(new ZipStorageFolder(path, containerPath)).AsAsyncOperation(); + } + return Task.FromResult(null).AsAsyncOperation(); + } + + public static IAsyncOperation FromStorageFileAsync(BaseStorageFile file) + => AsyncInfo.Run(async (cancellationToken) => await CheckAccess(file) ? new ZipStorageFolder(file) : null); + + public override IAsyncOperation ToStorageFolderAsync() => throw new NotSupportedException(); + + public override bool IsEqual(IStorageItem item) => item?.Path == Path; + public override bool IsOfType(StorageItemTypes type) => type == StorageItemTypes.Folder; + + public override IAsyncOperation GetIndexedStateAsync() => Task.FromResult(IndexedState.NotIndexed).AsAsyncOperation(); + + public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); + + private async Task GetBasicProperties() + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return new BaseBasicProperties(); + } + //zipFile.IsStreamOwner = true; + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); + return entry.FileName is null + ? new BaseBasicProperties() + : new ZipFolderBasicProperties(entry); + } + private Task GetBasicPropertiesWithEncodingAsync() + { + return Task.Run(() => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var normalizedTarget = Path.TrimEnd('\\', '/'); + + foreach (ZipEntry entry in zipFile) + { + var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); + var normalizedEntry = entryPath.TrimEnd('\\', '/'); + + if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) + return new ZipFolderBasicPropertiesWithEncoding(entry); + } + + return new BaseBasicProperties(); + }); + } + + public override IAsyncOperation GetBasicPropertiesAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path == containerPath) + { + var zipFile = new SystemStorageFile(await StorageFile.GetFileFromPathAsync(Path)); + return await zipFile.GetBasicPropertiesAsync(); + } + + if (CurrentEncoding is not null) + return await GetBasicPropertiesWithEncodingAsync(); + + return await GetBasicProperties(); + }); + } + + public override IAsyncOperation GetItemAsync(string name) + { + if (CurrentEncoding is not null) + return GetItemWithEncodingAsync(name); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + + var filePath = System.IO.Path.Combine(Path, name); + + var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == filePath); + if (entry.FileName is null) + { + return null; + } + + if (entry.IsDirectory) + { + var folder = new ZipStorageFolder(filePath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + + var file = new ZipStorageFile(filePath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + return file; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation GetItemWithEncodingAsync(string name) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var targetPath = System.IO.Path.Combine(Path, name); + var normalizedTarget = targetPath.TrimEnd('\\', '/'); + bool foundChild = false; + + foreach (ZipEntry entry in zipFile) + { + var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); + var normalizedEntry = entryPath.TrimEnd('\\', '/'); + + if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) + { + if (entry.IsDirectory) + { + var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + else + { + var file = new ZipStorageFile(targetPath, containerPath, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + return file; + } + } + + if (!foundChild && normalizedEntry.StartsWith(normalizedTarget + "\\", StringComparison.OrdinalIgnoreCase)) + foundChild = true; + } + + // No exact match found; check if target is an implicit directory + if (foundChild) + { + var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + } + + return null; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation TryGetItemAsync(string name) + { + return AsyncInfo.Run(async (cancellationToken) => + { + try + { + return await GetItemAsync(name); + } + catch + { + return null; + } + }); + } + public override IAsyncOperation?> GetItemsAsync() + { + if (CurrentEncoding is not null) + return GetItemsWithEncodingAsync(); + + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + var items = new List(); + foreach (var entry in zipFile.GetArchiveFileData(containerPath)) // Returns all items recursively + { + string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.FileName); + if (winPath.StartsWith(Path.WithEnding("\\"), StringComparison.Ordinal)) // Child of self + { + var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); + if (split.Length > 0) + { + if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry + { + var itemPath = System.IO.Path.Combine(Path, split[0]); + if (!items.Any(x => x.Path == itemPath)) + { + var folder = new ZipStorageFolder(itemPath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + items.Add(folder); + } + } + else + { + var file = new ZipStorageFile(winPath, containerPath, entry, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + items.Add(file); + } + } + } + } + return items; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private IAsyncOperation?> GetItemsWithEncodingAsync() + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => + { + var encoding = CurrentEncoding + ?? throw new InvalidOperationException("The archive encoding has not been initialized."); + using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); + + if (!string.IsNullOrEmpty(Password)) + zipFile.Password = Password; + + var items = new List(); + var dirPrefix = Path.WithEnding("\\"); + + foreach (ZipEntry entry in zipFile) + { + string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); + if (!winPath.StartsWith(dirPrefix, StringComparison.Ordinal)) + continue; + + var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); + if (split.Length <= 0) + continue; + + if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry + { + var itemPath = System.IO.Path.Combine(Path, split[0]); + if (!items.Any(x => x.Path == itemPath)) + { + var folder = new ZipStorageFolder(itemPath, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + items.Add(folder); + } + } + else + { + var file = new ZipStorageFile(winPath, containerPath, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + items.Add(file); + } + + } + return items; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation?> GetItemsAsync(uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetItemsAsync() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); + }); + + public override IAsyncOperation GetFileAsync(string name) + => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFile); + public override IAsyncOperation?> GetFilesAsync() + => AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetItemsAsync(); + return items?.OfType().ToList(); + }); + public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query) + => AsyncInfo.Run?>(async (cancellationToken) => await GetFilesAsync()); + public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query, uint startIndex, uint maxItemsToRetrieve) + => AsyncInfo.Run?>(async (cancellationToken) => + { + var files = await GetFilesAsync() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + return files.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); + }); + + public override IAsyncOperation GetFolderAsync(string name) + => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFolder); + public override IAsyncOperation?> GetFoldersAsync() + => AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetItemsAsync(); + return items?.OfType().ToList(); + }); + public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query) + => AsyncInfo.Run?>(async (cancellationToken) => await GetFoldersAsync()); + public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query, uint startIndex, uint maxItemsToRetrieve) + { + return AsyncInfo.Run?>(async (cancellationToken) => + { + var items = await GetFoldersAsync() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); + }); + } + + public override IAsyncOperation CreateFileAsync(string desiredName) + => CreateFileAsync(desiredName, CreationCollisionOption.FailIfExists); + public override IAsyncOperation CreateFileAsync(string desiredName, CreationCollisionOption options) + => CreateFileAsync(new MemoryStream(), desiredName, options); + + public override IAsyncOperation CreateFolderAsync(string desiredName) + => CreateFolderAsync(desiredName, CreationCollisionOption.FailIfExists); + public override IAsyncOperation CreateFolderAsync(string desiredName, CreationCollisionOption options) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var zipDesiredName = System.IO.Path.Combine(Path, desiredName); + var item = await GetItemAsync(desiredName); + if (item is not null) + { + if (options != CreationCollisionOption.ReplaceExisting) + { + return null; + } + await item.DeleteAsync(); + } + + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); + await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, null } }, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + + var folder = new ZipStorageFolder(zipDesiredName, containerPath, backingFile); + ((IPasswordProtectedItem)folder).CopyFrom(this); + return folder; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder) => throw new NotSupportedException(); + public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder, NameCollisionOption option) => throw new NotSupportedException(); + + public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); + public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.RenameAsync(desiredName, option); + } + else + { + var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); + var fileName = IO.Path.Combine(parentPath, desiredName); + PInvoke.MoveFileFromApp(Path, fileName); + } + } + else + { + var index = await FetchZipIndex() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + if (index.IsEmpty()) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var folderKey = IO.Path.GetRelativePath(containerPath, Path); + var parentPath = IO.Path.GetDirectoryName(folderKey) + ?? throw new InvalidOperationException("The archive entry has no parent."); + var folderDes = IO.Path.Combine(parentPath, desiredName); + var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, + IO.Path.Combine(folderDes, IO.Path.GetRelativePath(folderKey, x.Key))))); + await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); + public override IAsyncAction DeleteAsync(StorageDeleteOption option) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => + { + if (Path == containerPath) + { + if (backingFile is not null) + { + await backingFile.DeleteAsync(); + } + else if (option == StorageDeleteOption.PermanentDelete) + { + PInvoke.DeleteFileFromApp(Path); + } + else + { + throw new NotSupportedException("Moving to recycle bin is not supported."); + } + } + else + { + var index = await FetchZipIndex() + ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); + if (index.IsEmpty()) + { + return; + } + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, null))); + await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + } + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + public override bool AreQueryOptionsSupported(QueryOptions queryOptions) => false; + public override bool IsCommonFileQuerySupported(CommonFileQuery query) => false; + public override bool IsCommonFolderQuerySupported(CommonFolderQuery query) => false; + + public override StorageItemQueryResult CreateItemQuery() => throw new NotSupportedException(); + public override BaseStorageItemQueryResult CreateItemQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override StorageFileQueryResult CreateFileQuery() => throw new NotSupportedException(); + public override StorageFileQueryResult CreateFileQuery(CommonFileQuery query) => throw new NotSupportedException(); + public override BaseStorageFileQueryResult CreateFileQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override StorageFolderQueryResult CreateFolderQuery() => throw new NotSupportedException(); + public override StorageFolderQueryResult CreateFolderQuery(CommonFolderQuery query) => throw new NotSupportedException(); + public override BaseStorageFolderQueryResult CreateFolderQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); + + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path != containerPath) + { + return null; + } + var zipFile = await StorageFile.GetFileFromPathAsync(Path); + return await zipFile.GetThumbnailAsync(mode); + }); + } + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path != containerPath) + { + return null; + } + var zipFile = await StorageFile.GetFileFromPathAsync(Path); + return await zipFile.GetThumbnailAsync(mode, requestedSize); + }); + } + public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) + { + return AsyncInfo.Run(async (cancellationToken) => + { + if (Path != containerPath) + { + return null; + } + var zipFile = await StorageFile.GetFileFromPathAsync(Path); + return await zipFile.GetThumbnailAsync(mode, requestedSize, options); + }); + } + + private static bool CheckAccess(string path) + { + return SafetyExtensions.IgnoreExceptions(() => + { + var hFile = Win32Helper.OpenFileForRead(path); + if (hFile.IsInvalid) + { + return false; + } + using var stream = new FileStream(hFile, FileAccess.Read); + return CheckAccess(stream); + }); + } + private static bool CheckAccess(Stream stream) + { + try + { + using (SevenZipExtractor zipFile = new SevenZipExtractor(stream)) + { + //zipFile.IsStreamOwner = false; + return zipFile.ArchiveFileData is not null; + } + } + catch (SevenZipOpenFailedException ex) + { + return ex.Result == OperationResult.WrongPassword; + } + catch + { + return false; + } + } + private static async Task CheckAccess(BaseStorageFile file) + { + return await SafetyExtensions.IgnoreExceptions(async () => + { + using var stream = await file.OpenReadAsync() + ?? throw new IOException("The archive could not be opened for reading."); + return CheckAccess(stream.AsStream()); + }); + } + + public static Task InitArchive(string path, OutArchiveFormat format) + { + return SafetyExtensions.IgnoreExceptions(() => + { + var hFile = Win32Helper.OpenFileForRead(path, true); + if (hFile.IsInvalid) + { + return Task.FromResult(false); + } + using var stream = new FileStream(hFile, FileAccess.ReadWrite); + return InitArchive(stream, format); + }); + } + public static Task InitArchive(IStorageFile file, OutArchiveFormat format) + { + return SafetyExtensions.IgnoreExceptions(async () => + { + using var fileStream = await file.OpenAsync(FileAccessMode.ReadWrite); + await using var stream = fileStream.AsStream(); + return await InitArchive(stream, format); + }); + } + private static async Task InitArchive(Stream stream, OutArchiveFormat format) + { + stream.SetLength(0); + var compressor = new SevenZipCompressor() + { + CompressionMode = CompressionMode.Create, + ArchiveFormat = format + }; + compressor.CustomParameters.Add("cu", "on"); + await compressor.CompressStreamDictionaryAsync(stream, new Dictionary()); + await stream.FlushAsync(); + return true; + } + + public async Task ValidateCredentialsAsync() + { + try + { + using SevenZipExtractor? zipFile = await OpenZipFileAsync(); + return zipFile?.ArchiveFileData is not null; + } + catch (Exception) // SevenZipOpenFailedException(WrongPassword) for bad credentials; IO exceptions (e.g. archive deleted meanwhile) equally mean the credentials can't be verified + { + return false; + } + } + + private IAsyncOperation OpenZipFileAsync() + { + return AsyncInfo.Run(async (cancellationToken) => + { + var zipFile = await OpenZipFileAsync(FileAccessMode.Read); + return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; + }); + } + + private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) + { + return AsyncInfo.Run(async (cancellationToken) => + { + bool readWrite = accessMode is FileAccessMode.ReadWrite; + if (backingFile is not null) + { + var stream = await backingFile.OpenAsync(accessMode); + return stream?.AsStream(); + } + else + { + var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); + return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); + } + }); + } + + private async Task?> FetchZipIndex() + { + using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) + { + if (zipFile is null || zipFile.ArchiveFileData is null) + { + return null; + } + //zipFile.IsStreamOwner = true; + return zipFile.GetArchiveFileData(containerPath).Where(x => System.IO.Path.Combine(containerPath, x.FileName).IsSubPathOf(Path)).Select(e => (e.Index, e.FileName)); + } + } + + public IAsyncOperation CreateFileAsync(Stream contents, string desiredName) + => CreateFileAsync(new MemoryStream(), desiredName, CreationCollisionOption.FailIfExists); + + public IAsyncOperation CreateFileAsync(Stream contents, string desiredName, CreationCollisionOption options) + { + return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => + { + var zipDesiredName = System.IO.Path.Combine(Path, desiredName); + var item = await GetItemAsync(desiredName); + if (item is not null) + { + if (options != CreationCollisionOption.ReplaceExisting) + { + return null; + } + await item.DeleteAsync(); + } + + using (var ms = new MemoryStream()) + { + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) + { + SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; + compressor.CustomParameters.Add("cu", "on"); + compressor.SetFormatFromExistingArchive(archiveStream); + var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); + await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, contents } }, Password, ms); + } + await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) + ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) + { + ms.Position = 0; + await ms.CopyToAsync(archiveStream); + await ms.FlushAsync(); + archiveStream.SetLength(archiveStream.Position); + } + } + + var file = new ZipStorageFile(zipDesiredName, containerPath, backingFile); + ((IPasswordProtectedItem)file).CopyFrom(this); + file.CurrentEncoding = CurrentEncoding; + return file; + }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); + } + + private sealed partial class ZipFolderBasicProperties : BaseBasicProperties + { + private ArchiveFileInfo entry; + + public ZipFolderBasicProperties(ArchiveFileInfo entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; + + public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; + + public override ulong Size => entry.Size; + } + + private sealed partial class ZipFolderBasicPropertiesWithEncoding : BaseBasicProperties + { + private ZipEntry entry; + + public ZipFolderBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; + + public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; + + public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; + + public override ulong Size => (ulong)entry.Size; + } + } +} diff --git a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs index b72ef63f46ab..d19ef26bca0a 100644 --- a/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs +++ b/src/Files.App/Utils/Storage/StorageItems/ZipStorageFolder.cs @@ -1,898 +1,35 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.Shared.Helpers; -using ICSharpCode.SharpZipLib.Zip; -using SevenZip; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.InteropServices.WindowsRuntime; using System.Text; -using Windows.ApplicationModel; using Windows.Foundation; using Windows.Storage; -using Windows.Storage.FileProperties; -using Windows.Storage.Search; -using Windows.Win32; -using IO = System.IO; namespace Files.App.Utils.Storage { - public sealed partial class ZipStorageFolder : BaseStorageFolder, ICreateFileWithStream, IPasswordProtectedItem + // LINUX-TODO(archives): remove these inactive legacy consumer hooks after P4-C/E/H/I migrate to ArchiveFolder. + public abstract class ZipStorageFolder : BaseStorageFolder, IPasswordProtectedItem { - private readonly string containerPath; - private BaseStorageFile? backingFile; - - private Encoding? _currentEncoding; - - // Maps container paths to their configured encoding. - // - Key missing: encoding not yet set; detection required. - // - Value null: archive opened with system default encoding. - // - Value set: archive opened with the specified encoding. - private static readonly ConcurrentDictionary _encodingByContainerPath = new(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets or sets the encoding to use when browsing this ZIP file. - /// When set, SharpZipLib is used instead of SevenZipSharp. - /// - internal Encoding? CurrentEncoding - { - get => _currentEncoding; - set - { - _currentEncoding = value; - _encodingByContainerPath[containerPath] = value; - } - } - - internal static bool TryGetEncodingForContainerPath(string containerPath, out Encoding? encoding) - => _encodingByContainerPath.TryGetValue(containerPath, out encoding); - - internal static void SetEncodingForContainerPath(string containerPath, Encoding? encoding) - { - _encodingByContainerPath[containerPath] = encoding; - } - - public override string Path { get; } - public override string Name { get; } - public override string DisplayName => Name; - public override string DisplayType => Strings.Folder.GetLocalizedResource(); - public override string FolderRelativeId => $"0\\{Name}"; - - public override DateTimeOffset DateCreated { get; } - public override Windows.Storage.FileAttributes Attributes => Windows.Storage.FileAttributes.Directory; - public override IStorageItemExtraProperties Properties => new BaseBasicStorageItemExtraProperties(this); - - public StorageCredential? Credentials { get; set; } = new(); - private string Password => Credentials?.Password - ?? throw new InvalidOperationException("Archive credentials are not available."); - + private static readonly ConcurrentDictionary Encodings = new(StringComparer.Ordinal); + public StorageCredential? Credentials { get; set; } public Func>? PasswordRequestedCallback { get; set; } - - public ZipStorageFolder(string path, string containerPath) - { - Name = IO.Path.GetFileName(path.TrimEnd('\\', '/')); - Path = path; - this.containerPath = containerPath; - _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); - } - public ZipStorageFolder(string path, string containerPath, BaseStorageFile? backingFile) : this(path, containerPath) - => this.backingFile = backingFile; - public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry) : this(path, containerPath) - => DateCreated = entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - public ZipStorageFolder(BaseStorageFile backingFile) - { - ArgumentException.ThrowIfNullOrEmpty(backingFile.Path); - Name = IO.Path.GetFileName(backingFile.Path.TrimEnd('\\', '/')); - Path = backingFile.Path; - this.containerPath = backingFile.Path; - this.backingFile = backingFile; - _encodingByContainerPath.TryGetValue(containerPath, out _currentEncoding); - } - public ZipStorageFolder(string path, string containerPath, ArchiveFileInfo entry, BaseStorageFile? backingFile) : this(path, containerPath, entry) - => this.backingFile = backingFile; - - public static string? GetContainerPath(string path) - { - if (!FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) - return null; - - var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); - if (marker is -1) - return null; - - return path.Substring(0, marker + ext.Length); - } - - public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) - { - if (path is null || !FileExtensionHelpers.IsBrowsableZipFile(path, out var ext)) - { - return false; - } - var marker = path.IndexOf(ext, StringComparison.OrdinalIgnoreCase); - if (marker is -1) - { - return false; - } - marker += ext.Length; - // If IO.Path.Exists returns true, it is not a zip path but a normal directory path that contains ".zip". - return (marker == path.Length && includeRoot && !IO.Path.Exists(path + "\\")) - || (marker < path.Length && path[marker] is '\\' && !IO.Path.Exists(path)); - } - - public async Task GetUncompressedSize() - { - long uncompressedSize = 0; - using SevenZipExtractor? zipFile = await FilesystemTasks.WrapNullable(async () => - { - var arch = await OpenZipFileAsync(); - return arch?.ArchiveFileData is null ? null : arch; // Force load archive (1665013614u) - }); - - if (zipFile is not null) - { - foreach (var info in zipFile.ArchiveFileData.Where(x => !x.IsDirectory)) - { - uncompressedSize += (long)info.Size; - } - } - - return uncompressedSize; - } - - private static ConcurrentDictionary> defaultAppDict = new(); - public static async Task CheckDefaultZipApp(string filePath) - { - Func> queryFileAssoc = async () => - { - var assoc = await Win32Helper.GetDefaultFileAssociationAsync(filePath); - if (assoc is not null) - { - return Constants.Distributions.KnownAppNames.Any(x => assoc.StartsWith(x, StringComparison.OrdinalIgnoreCase)) - || assoc == Package.Current.Id.FamilyName - || assoc.EndsWith("Files.exe", StringComparison.OrdinalIgnoreCase) - || assoc.Equals(IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "explorer.exe"), StringComparison.OrdinalIgnoreCase); - } - return true; - }; - var ext = IO.Path.GetExtension(filePath)?.ToLowerInvariant(); - return await defaultAppDict.GetAsync(ext ?? "", queryFileAssoc); - } - - public static IAsyncOperation FromPathAsync(string path) - { - var containerPath = GetContainerPath(path); - if (containerPath is not null && CheckAccess(containerPath)) - { - return Task.FromResult(new ZipStorageFolder(path, containerPath)).AsAsyncOperation(); - } - return Task.FromResult(null).AsAsyncOperation(); - } - - public static IAsyncOperation FromStorageFileAsync(BaseStorageFile file) - => AsyncInfo.Run(async (cancellationToken) => await CheckAccess(file) ? new ZipStorageFolder(file) : null); - - public override IAsyncOperation ToStorageFolderAsync() => throw new NotSupportedException(); - - public override bool IsEqual(IStorageItem item) => item?.Path == Path; - public override bool IsOfType(StorageItemTypes type) => type == StorageItemTypes.Folder; - - public override IAsyncOperation GetIndexedStateAsync() => Task.FromResult(IndexedState.NotIndexed).AsAsyncOperation(); - - public override IAsyncOperation GetParentAsync() => throw new NotSupportedException(); - - private async Task GetBasicProperties() - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return new BaseBasicProperties(); - } - //zipFile.IsStreamOwner = true; - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == Path); - return entry.FileName is null - ? new BaseBasicProperties() - : new ZipFolderBasicProperties(entry); - } - private Task GetBasicPropertiesWithEncodingAsync() - { - return Task.Run(() => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var normalizedTarget = Path.TrimEnd('\\', '/'); - - foreach (ZipEntry entry in zipFile) - { - var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); - var normalizedEntry = entryPath.TrimEnd('\\', '/'); - - if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) - return new ZipFolderBasicPropertiesWithEncoding(entry); - } - - return new BaseBasicProperties(); - }); - } - - public override IAsyncOperation GetBasicPropertiesAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path == containerPath) - { - var zipFile = new SystemStorageFile(await StorageFile.GetFileFromPathAsync(Path)); - return await zipFile.GetBasicPropertiesAsync(); - } - - if (CurrentEncoding is not null) - return await GetBasicPropertiesWithEncodingAsync(); - - return await GetBasicProperties(); - }); - } - - public override IAsyncOperation GetItemAsync(string name) - { - if (CurrentEncoding is not null) - return GetItemWithEncodingAsync(name); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - - var filePath = System.IO.Path.Combine(Path, name); - - var entry = zipFile.GetArchiveFileData(containerPath).FirstOrDefault(x => System.IO.Path.Combine(containerPath, x.FileName) == filePath); - if (entry.FileName is null) - { - return null; - } - - if (entry.IsDirectory) - { - var folder = new ZipStorageFolder(filePath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - } - - var file = new ZipStorageFile(filePath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - return file; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation GetItemWithEncodingAsync(string name) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var targetPath = System.IO.Path.Combine(Path, name); - var normalizedTarget = targetPath.TrimEnd('\\', '/'); - bool foundChild = false; - - foreach (ZipEntry entry in zipFile) - { - var entryPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); - var normalizedEntry = entryPath.TrimEnd('\\', '/'); - - if (normalizedEntry.Equals(normalizedTarget, StringComparison.OrdinalIgnoreCase)) - { - if (entry.IsDirectory) - { - var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - } - else - { - var file = new ZipStorageFile(targetPath, containerPath, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - return file; - } - } - - if (!foundChild && normalizedEntry.StartsWith(normalizedTarget + "\\", StringComparison.OrdinalIgnoreCase)) - foundChild = true; - } - - // No exact match found; check if target is an implicit directory - if (foundChild) - { - var folder = new ZipStorageFolder(targetPath, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - } - - return null; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation TryGetItemAsync(string name) - { - return AsyncInfo.Run(async (cancellationToken) => - { - try - { - return await GetItemAsync(name); - } - catch - { - return null; - } - }); - } - public override IAsyncOperation?> GetItemsAsync() - { - if (CurrentEncoding is not null) - return GetItemsWithEncodingAsync(); - - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - var items = new List(); - foreach (var entry in zipFile.GetArchiveFileData(containerPath)) // Returns all items recursively - { - string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.FileName); - if (winPath.StartsWith(Path.WithEnding("\\"), StringComparison.Ordinal)) // Child of self - { - var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); - if (split.Length > 0) - { - if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry - { - var itemPath = System.IO.Path.Combine(Path, split[0]); - if (!items.Any(x => x.Path == itemPath)) - { - var folder = new ZipStorageFolder(itemPath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - items.Add(folder); - } - } - else - { - var file = new ZipStorageFile(winPath, containerPath, entry, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - items.Add(file); - } - } - } - } - return items; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private IAsyncOperation?> GetItemsWithEncodingAsync() - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap?>(async () => - { - var encoding = CurrentEncoding - ?? throw new InvalidOperationException("The archive encoding has not been initialized."); - using var zipFile = new ZipFile(containerPath, StringCodec.FromEncoding(encoding)); - - if (!string.IsNullOrEmpty(Password)) - zipFile.Password = Password; - - var items = new List(); - var dirPrefix = Path.WithEnding("\\"); - - foreach (ZipEntry entry in zipFile) - { - string winPath = System.IO.Path.Combine(System.IO.Path.GetFullPath(containerPath), entry.Name.Replace("/", "\\")); - if (!winPath.StartsWith(dirPrefix, StringComparison.Ordinal)) - continue; - - var split = winPath.Substring(Path.Length).Split('\\', StringSplitOptions.RemoveEmptyEntries); - if (split.Length <= 0) - continue; - - if (entry.IsDirectory || split.Length > 1) // Not all folders have a ZipEntry - { - var itemPath = System.IO.Path.Combine(Path, split[0]); - if (!items.Any(x => x.Path == itemPath)) - { - var folder = new ZipStorageFolder(itemPath, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - items.Add(folder); - } - } - else - { - var file = new ZipStorageFile(winPath, containerPath, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - items.Add(file); - } - - } - return items; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation?> GetItemsAsync(uint startIndex, uint maxItemsToRetrieve) - => AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetItemsAsync() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); - }); - - public override IAsyncOperation GetFileAsync(string name) - => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFile); - public override IAsyncOperation?> GetFilesAsync() - => AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetItemsAsync(); - return items?.OfType().ToList(); - }); - public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query) - => AsyncInfo.Run?>(async (cancellationToken) => await GetFilesAsync()); - public override IAsyncOperation?> GetFilesAsync(CommonFileQuery query, uint startIndex, uint maxItemsToRetrieve) - => AsyncInfo.Run?>(async (cancellationToken) => - { - var files = await GetFilesAsync() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - return files.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); - }); - - public override IAsyncOperation GetFolderAsync(string name) - => AsyncInfo.Run(async (cancellationToken) => await GetItemAsync(name) as ZipStorageFolder); - public override IAsyncOperation?> GetFoldersAsync() - => AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetItemsAsync(); - return items?.OfType().ToList(); - }); - public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query) - => AsyncInfo.Run?>(async (cancellationToken) => await GetFoldersAsync()); - public override IAsyncOperation?> GetFoldersAsync(CommonFolderQuery query, uint startIndex, uint maxItemsToRetrieve) - { - return AsyncInfo.Run?>(async (cancellationToken) => - { - var items = await GetFoldersAsync() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - return items.Skip((int)startIndex).Take((int)maxItemsToRetrieve).ToList(); - }); - } - - public override IAsyncOperation CreateFileAsync(string desiredName) - => CreateFileAsync(desiredName, CreationCollisionOption.FailIfExists); - public override IAsyncOperation CreateFileAsync(string desiredName, CreationCollisionOption options) - => CreateFileAsync(new MemoryStream(), desiredName, options); - - public override IAsyncOperation CreateFolderAsync(string desiredName) - => CreateFolderAsync(desiredName, CreationCollisionOption.FailIfExists); - public override IAsyncOperation CreateFolderAsync(string desiredName, CreationCollisionOption options) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var zipDesiredName = System.IO.Path.Combine(Path, desiredName); - var item = await GetItemAsync(desiredName); - if (item is not null) - { - if (options != CreationCollisionOption.ReplaceExisting) - { - return null; - } - await item.DeleteAsync(); - } - - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); - await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, null } }, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - - var folder = new ZipStorageFolder(zipDesiredName, containerPath, backingFile); - ((IPasswordProtectedItem)folder).CopyFrom(this); - return folder; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder) => throw new NotSupportedException(); - public override IAsyncOperation MoveAsync(IStorageFolder destinationFolder, NameCollisionOption option) => throw new NotSupportedException(); - - public override IAsyncAction RenameAsync(string desiredName) => RenameAsync(desiredName, NameCollisionOption.FailIfExists); - public override IAsyncAction RenameAsync(string desiredName, NameCollisionOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.RenameAsync(desiredName, option); - } - else - { - var parentPath = IO.Path.GetDirectoryName(Path) ?? throw new InvalidOperationException("The archive path has no parent."); - var fileName = IO.Path.Combine(parentPath, desiredName); - PInvoke.MoveFileFromApp(Path, fileName); - } - } - else - { - var index = await FetchZipIndex() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - if (index.IsEmpty()) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var folderKey = IO.Path.GetRelativePath(containerPath, Path); - var parentPath = IO.Path.GetDirectoryName(folderKey) - ?? throw new InvalidOperationException("The archive entry has no parent."); - var folderDes = IO.Path.Combine(parentPath, desiredName); - var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, - IO.Path.Combine(folderDes, IO.Path.GetRelativePath(folderKey, x.Key))))); - await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override IAsyncAction DeleteAsync() => DeleteAsync(StorageDeleteOption.Default); - public override IAsyncAction DeleteAsync(StorageDeleteOption option) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.WrapAsync(async () => - { - if (Path == containerPath) - { - if (backingFile is not null) - { - await backingFile.DeleteAsync(); - } - else if (option == StorageDeleteOption.PermanentDelete) - { - PInvoke.DeleteFileFromApp(Path); - } - else - { - throw new NotSupportedException("Moving to recycle bin is not supported."); - } - } - else - { - var index = await FetchZipIndex() - ?? throw new InvalidDataException($"The archive '{containerPath}' could not be read."); - if (index.IsEmpty()) - { - return; - } - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var entriesMap = new Dictionary(index.Select(x => new KeyValuePair(x.Index, null))); - await compressor.ModifyArchiveAsync(archiveStream, entriesMap, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - } - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - public override bool AreQueryOptionsSupported(QueryOptions queryOptions) => false; - public override bool IsCommonFileQuerySupported(CommonFileQuery query) => false; - public override bool IsCommonFolderQuerySupported(CommonFolderQuery query) => false; - - public override StorageItemQueryResult CreateItemQuery() => throw new NotSupportedException(); - public override BaseStorageItemQueryResult CreateItemQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); - - public override StorageFileQueryResult CreateFileQuery() => throw new NotSupportedException(); - public override StorageFileQueryResult CreateFileQuery(CommonFileQuery query) => throw new NotSupportedException(); - public override BaseStorageFileQueryResult CreateFileQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); - - public override StorageFolderQueryResult CreateFolderQuery() => throw new NotSupportedException(); - public override StorageFolderQueryResult CreateFolderQuery(CommonFolderQuery query) => throw new NotSupportedException(); - public override BaseStorageFolderQueryResult CreateFolderQueryWithOptions(QueryOptions queryOptions) => new(this, queryOptions); - - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode) - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path != containerPath) - { - return null; - } - var zipFile = await StorageFile.GetFileFromPathAsync(Path); - return await zipFile.GetThumbnailAsync(mode); - }); - } - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize) - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path != containerPath) - { - return null; - } - var zipFile = await StorageFile.GetFileFromPathAsync(Path); - return await zipFile.GetThumbnailAsync(mode, requestedSize); - }); - } - public override IAsyncOperation GetThumbnailAsync(ThumbnailMode mode, uint requestedSize, ThumbnailOptions options) - { - return AsyncInfo.Run(async (cancellationToken) => - { - if (Path != containerPath) - { - return null; - } - var zipFile = await StorageFile.GetFileFromPathAsync(Path); - return await zipFile.GetThumbnailAsync(mode, requestedSize, options); - }); - } - - private static bool CheckAccess(string path) - { - return SafetyExtensions.IgnoreExceptions(() => - { - var hFile = Win32Helper.OpenFileForRead(path); - if (hFile.IsInvalid) - { - return false; - } - using var stream = new FileStream(hFile, FileAccess.Read); - return CheckAccess(stream); - }); - } - private static bool CheckAccess(Stream stream) - { - try - { - using (SevenZipExtractor zipFile = new SevenZipExtractor(stream)) - { - //zipFile.IsStreamOwner = false; - return zipFile.ArchiveFileData is not null; - } - } - catch (SevenZipOpenFailedException ex) - { - return ex.Result == OperationResult.WrongPassword; - } - catch - { - return false; - } - } - private static async Task CheckAccess(BaseStorageFile file) - { - return await SafetyExtensions.IgnoreExceptions(async () => - { - using var stream = await file.OpenReadAsync() - ?? throw new IOException("The archive could not be opened for reading."); - return CheckAccess(stream.AsStream()); - }); - } - - public static Task InitArchive(string path, OutArchiveFormat format) - { - return SafetyExtensions.IgnoreExceptions(() => - { - var hFile = Win32Helper.OpenFileForRead(path, true); - if (hFile.IsInvalid) - { - return Task.FromResult(false); - } - using var stream = new FileStream(hFile, FileAccess.ReadWrite); - return InitArchive(stream, format); - }); - } - public static Task InitArchive(IStorageFile file, OutArchiveFormat format) - { - return SafetyExtensions.IgnoreExceptions(async () => - { - using var fileStream = await file.OpenAsync(FileAccessMode.ReadWrite); - await using var stream = fileStream.AsStream(); - return await InitArchive(stream, format); - }); - } - private static async Task InitArchive(Stream stream, OutArchiveFormat format) - { - stream.SetLength(0); - var compressor = new SevenZipCompressor() - { - CompressionMode = CompressionMode.Create, - ArchiveFormat = format - }; - compressor.CustomParameters.Add("cu", "on"); - await compressor.CompressStreamDictionaryAsync(stream, new Dictionary()); - await stream.FlushAsync(); - return true; - } - - public async Task ValidateCredentialsAsync() - { - try - { - using SevenZipExtractor? zipFile = await OpenZipFileAsync(); - return zipFile?.ArchiveFileData is not null; - } - catch (Exception) // SevenZipOpenFailedException(WrongPassword) for bad credentials; IO exceptions (e.g. archive deleted meanwhile) equally mean the credentials can't be verified - { - return false; - } - } - - private IAsyncOperation OpenZipFileAsync() - { - return AsyncInfo.Run(async (cancellationToken) => - { - var zipFile = await OpenZipFileAsync(FileAccessMode.Read); - return zipFile is not null ? new SevenZipExtractor(zipFile, Password) : null; - }); - } - - private IAsyncOperation OpenZipFileAsync(FileAccessMode accessMode) - { - return AsyncInfo.Run(async (cancellationToken) => - { - bool readWrite = accessMode is FileAccessMode.ReadWrite; - if (backingFile is not null) - { - var stream = await backingFile.OpenAsync(accessMode); - return stream?.AsStream(); - } - else - { - var hFile = Win32Helper.OpenFileForRead(containerPath, readWrite); - return hFile.IsInvalid ? null : new FileStream(hFile, readWrite ? FileAccess.ReadWrite : FileAccess.Read); - } - }); - } - - private async Task?> FetchZipIndex() - { - using (SevenZipExtractor? zipFile = await OpenZipFileAsync()) - { - if (zipFile is null || zipFile.ArchiveFileData is null) - { - return null; - } - //zipFile.IsStreamOwner = true; - return zipFile.GetArchiveFileData(containerPath).Where(x => System.IO.Path.Combine(containerPath, x.FileName).IsSubPathOf(Path)).Select(e => (e.Index, e.FileName)); - } - } - - public IAsyncOperation CreateFileAsync(Stream contents, string desiredName) - => CreateFileAsync(new MemoryStream(), desiredName, CreationCollisionOption.FailIfExists); - - public IAsyncOperation CreateFileAsync(Stream contents, string desiredName, CreationCollisionOption options) - { - return AsyncInfo.Run((cancellationToken) => SafetyExtensions.Wrap(async () => - { - var zipDesiredName = System.IO.Path.Combine(Path, desiredName); - var item = await GetItemAsync(desiredName); - if (item is not null) - { - if (options != CreationCollisionOption.ReplaceExisting) - { - return null; - } - await item.DeleteAsync(); - } - - using (var ms = new MemoryStream()) - { - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.Read) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for reading.")) - { - SevenZipCompressor compressor = new SevenZipCompressor() { CompressionMode = CompressionMode.Append }; - compressor.CustomParameters.Add("cu", "on"); - compressor.SetFormatFromExistingArchive(archiveStream); - var fileName = IO.Path.GetRelativePath(containerPath, zipDesiredName); - await compressor.CompressStreamDictionaryAsync(archiveStream, new Dictionary() { { fileName, contents } }, Password, ms); - } - await using (var archiveStream = await OpenZipFileAsync(FileAccessMode.ReadWrite) - ?? throw new IOException($"The archive '{containerPath}' could not be opened for writing.")) - { - ms.Position = 0; - await ms.CopyToAsync(archiveStream); - await ms.FlushAsync(); - archiveStream.SetLength(archiveStream.Position); - } - } - - var file = new ZipStorageFile(zipDesiredName, containerPath, backingFile); - ((IPasswordProtectedItem)file).CopyFrom(this); - file.CurrentEncoding = CurrentEncoding; - return file; - }, ((IPasswordProtectedItem)this).RetryWithCredentialsAsync)); - } - - private sealed partial class ZipFolderBasicProperties : BaseBasicProperties - { - private ArchiveFileInfo entry; - - public ZipFolderBasicProperties(ArchiveFileInfo entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.LastWriteTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.LastWriteTime; - - public override DateTimeOffset DateCreated => entry.CreationTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.CreationTime; - - public override ulong Size => entry.Size; - } - - private sealed partial class ZipFolderBasicPropertiesWithEncoding : BaseBasicProperties - { - private ZipEntry entry; - - public ZipFolderBasicPropertiesWithEncoding(ZipEntry entry) => this.entry = entry; - - public override DateTimeOffset DateModified => entry.DateTime == DateTime.MinValue ? DateTimeOffset.MinValue : entry.DateTime; - - public override DateTimeOffset DateCreated => DateTimeOffset.MinValue; - - public override ulong Size => (ulong)entry.Size; - } + public static string? GetContainerPath(string path) => FileExtensionHelpers.GetArchiveContainerPath(path); + public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) => FileExtensionHelpers.IsZipPath(path, includeRoot); + internal static bool TryGetEncodingForContainerPath(string path, out Encoding? encoding) => Encodings.TryGetValue(path, out encoding); + internal static void SetEncodingForContainerPath(string path, Encoding? encoding) => Encodings[path] = encoding; + public static Task CheckDefaultZipApp(string path) => Task.FromResult(true); + public static IAsyncOperation FromPathAsync(string path) => Task.FromResult(null).AsAsyncOperation(); + public static IAsyncOperation FromStorageFileAsync(BaseStorageFile file) => Task.FromResult(null).AsAsyncOperation(); + public static Task InitArchive(IStorageFile file, SevenZip.OutArchiveFormat format) => Task.FromException(new NotSupportedException()); + public Task GetUncompressedSize() => Task.FromException(new NotSupportedException()); + public Task ValidateCredentialsAsync() => Task.FromResult(false); + public IAsyncOperation CreateFileAsync(Stream contents, string name, CreationCollisionOption options) => Task.FromException(new NotSupportedException()).AsAsyncOperation(); } } +#endif 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() { diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs new file mode 100644 index 000000000000..92938188c57a --- /dev/null +++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.Windows.cs @@ -0,0 +1,190 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.IO; +using System.Text; +using System.Windows.Input; +using Windows.Storage; + +namespace Files.App.ViewModels.Dialogs +{ + public sealed partial class DecompressArchiveDialogViewModel : ObservableObject + { + // Services + private ICommonDialogService CommonDialogService { get; } = Ioc.Default.GetRequiredService(); + private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); + + // Fields + private readonly IStorageFile archive; + + // Properties + public BaseStorageFolder? DestinationFolder { get; private set; } + + private string destinationFolderPath; + public string DestinationFolderPath + { + get => destinationFolderPath; + set + { + if (SetProperty(ref destinationFolderPath, value)) + { + OnPropertyChanged(nameof(IsDestinationPathValid)); + } + } + } + + public bool IsDestinationPathValid + { + get + { + try + { + if (string.IsNullOrWhiteSpace(DestinationFolderPath)) + return false; + + string? parentDir = Path.GetDirectoryName(DestinationFolderPath); + string finalSegment = Path.GetFileName(DestinationFolderPath); + + // Check parent directory exists + if (string.IsNullOrWhiteSpace(parentDir) || !Directory.Exists(parentDir)) + return false; + + // Check for invalid characters (IsValidForFilename already does this) + if (!FilesystemHelpers.IsValidForFilename(finalSegment)) + return false; + + return true; + } + catch + { + // Catch any exception to prevent crashes + return false; + } + } + } + + private bool openDestinationFolderOnCompletion; + public bool OpenDestinationFolderOnCompletion + { + get => openDestinationFolderOnCompletion; + set => SetProperty(ref openDestinationFolderOnCompletion, value); + } + + private bool isArchiveEncrypted; + public bool IsArchiveEncrypted + { + get => isArchiveEncrypted; + set => SetProperty(ref isArchiveEncrypted, value); + } + + private bool isArchiveEncodingUndetermined; + public bool IsArchiveEncodingUndetermined + { + get => isArchiveEncodingUndetermined; + set => SetProperty(ref isArchiveEncodingUndetermined, value); + } + + private Encoding? detectedEncoding; + public Encoding? DetectedEncoding + { + get => detectedEncoding; + set + { + SetProperty(ref detectedEncoding, value); + RefreshEncodingOptions(); + } + } + + private bool showPathSelection; + public bool ShowPathSelection + { + get => showPathSelection; + set => SetProperty(ref showPathSelection, value); + } + + public DisposableArray? Password { get; private set; } + + public EncodingItem[] EncodingOptions { get; set; } = EncodingItem.Defaults; + + public EncodingItem SelectedEncoding { get; set; } + + public ObservableCollection PreviousExtractionLocations { get; } = []; + + // Commands + public IRelayCommand PrimaryButtonClickCommand { get; private set; } + public ICommand SelectDestinationCommand { get; private set; } + public ICommand? QuerySubmittedCommand { get; private set; } + + // Constructor + public DecompressArchiveDialogViewModel(IStorageFile archive) + { + this.archive = archive; + destinationFolderPath = DefaultDestinationFolderPath(); + SelectedEncoding = EncodingOptions[0]; + + // Create commands + SelectDestinationCommand = new AsyncRelayCommand(SelectDestinationAsync); + PrimaryButtonClickCommand = new RelayCommand(password => Password = password); + } + + // Private Methods + private string DefaultDestinationFolderPath() + { + var directory = Path.GetDirectoryName(archive.Path); +#if HAS_UNO + var fileName = Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archive.Path); +#else + var fileName = Path.GetFileNameWithoutExtension(archive.Path); +#endif + return string.IsNullOrEmpty(directory) ? fileName : Path.Combine(directory, fileName); + } + + private async Task SelectDestinationAsync() + { + bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath); + if (!result) + return; + + DestinationFolder = await StorageHelpers.ToStorageItem(filePath); + DestinationFolderPath = (DestinationFolder is not null) ? DestinationFolder.Path : DefaultDestinationFolderPath(); + } + + private void RefreshEncodingOptions() + { + if (detectedEncoding != null) + { + EncodingOptions = EncodingItem.Defaults + .Prepend(new EncodingItem( + detectedEncoding, + string.Format(Strings.EncodingDetected.GetLocalizedResource(), detectedEncoding.EncodingName) + )) + .ToArray(); + } + else + { + EncodingOptions = EncodingItem.Defaults; + } + SelectedEncoding = EncodingOptions[0]; + } + + // Public Methods + public void UpdateSuggestions(string query) + { + var allItems = UserSettingsService.GeneralSettingsService.PreviousArchiveExtractionLocations; + if (allItems is null) + return; + + var filtered = allItems + .Where(item => item.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Only update if results changed to prevent flickering + if (!filtered.SequenceEqual(PreviousExtractionLocations)) + { + PreviousExtractionLocations.Clear(); + foreach (var item in filtered) + PreviousExtractionLocations.Add(item); + } + } + } +} diff --git a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs index 92938188c57a..b5705d9988b4 100644 --- a/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs +++ b/src/Files.App/ViewModels/Dialogs/DecompressArchiveDialogViewModel.cs @@ -1,10 +1,11 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using System.IO; using System.Text; using System.Windows.Input; -using Windows.Storage; + namespace Files.App.ViewModels.Dialogs { @@ -15,10 +16,9 @@ public sealed partial class DecompressArchiveDialogViewModel : ObservableObject private readonly IUserSettingsService UserSettingsService = Ioc.Default.GetRequiredService(); // Fields - private readonly IStorageFile archive; + private readonly string archivePath; // Properties - public BaseStorageFolder? DestinationFolder { get; private set; } private string destinationFolderPath; public string DestinationFolderPath @@ -116,9 +116,9 @@ public bool ShowPathSelection public ICommand? QuerySubmittedCommand { get; private set; } // Constructor - public DecompressArchiveDialogViewModel(IStorageFile archive) + public DecompressArchiveDialogViewModel(string archivePath) { - this.archive = archive; + this.archivePath = archivePath; destinationFolderPath = DefaultDestinationFolderPath(); SelectedEncoding = EncodingOptions[0]; @@ -130,23 +130,18 @@ public DecompressArchiveDialogViewModel(IStorageFile archive) // Private Methods private string DefaultDestinationFolderPath() { - var directory = Path.GetDirectoryName(archive.Path); -#if HAS_UNO - var fileName = Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archive.Path); -#else - var fileName = Path.GetFileNameWithoutExtension(archive.Path); -#endif + var directory = Path.GetDirectoryName(archivePath); + var fileName = Ioc.Default.GetRequiredService().GetDefaultExtractFolderName(archivePath); + return string.IsNullOrEmpty(directory) ? fileName : Path.Combine(directory, fileName); } - private async Task SelectDestinationAsync() + private Task SelectDestinationAsync() { bool result = CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var filePath); - if (!result) - return; - - DestinationFolder = await StorageHelpers.ToStorageItem(filePath); - DestinationFolderPath = (DestinationFolder is not null) ? DestinationFolder.Path : DefaultDestinationFolderPath(); + if (result) + DestinationFolderPath = filePath; + return Task.CompletedTask; } private void RefreshEncodingOptions() @@ -188,3 +183,5 @@ public void UpdateSuggestions(string query) } } } + +#endif diff --git a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs index e56090bbb1cc..349e1deb66a4 100644 --- a/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs +++ b/src/Files.App/ViewModels/Layouts/BaseLayoutViewModel.cs @@ -190,9 +190,7 @@ public async Task DragOverAsync(DragEventArgs? e) // Some applications such as Edge can't raise the drop event by the Move flag (#14008), so we set the Copy flag as well. e.AcceptedOperation = DataPackageOperation.Move | DataPackageOperation.Copy; } - else if (draggedItems.Any(x => - x.Item is ZipStorageFile || - x.Item is ZipStorageFolder) || + else if (draggedItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(workingDirectory)) { e.DragUIOverride.Caption = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), folderName); diff --git a/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs b/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs index f679f6d2f8de..eccb2a35f8e5 100644 --- a/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs +++ b/src/Files.App/ViewModels/Properties/Items/LibraryProperties.cs @@ -44,7 +44,12 @@ public override void GetBaseProperties() public async override Task GetSpecialPropertiesAsync() { var libraryPath = Library.GetRequiredPath(); +#if WINDOWS var fileAttributes = Win32Helper.GetFileAttributes(libraryPath); +#else + // Windows libraries (.library-ms) don't exist on Linux + var fileAttributes = default(System.IO.FileAttributes); +#endif ViewModel.IsReadOnly = fileAttributes.HasFlag(System.IO.FileAttributes.ReadOnly); ViewModel.IsHidden = fileAttributes.HasFlag(System.IO.FileAttributes.Hidden); ViewModel.CanCompressContent = false; @@ -141,6 +146,7 @@ private async Task GetLibrarySizeAsync(List storageFolders, C private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { +#if WINDOWS var libraryPath = Library.GetRequiredPath(); switch (e.PropertyName) { @@ -166,6 +172,7 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro break; } +#endif } } } diff --git a/src/Files.App/ViewModels/ShellViewModel.Windows.cs b/src/Files.App/ViewModels/ShellViewModel.Windows.cs new file mode 100644 index 000000000000..772e11ccca35 --- /dev/null +++ b/src/Files.App/ViewModels/ShellViewModel.Windows.cs @@ -0,0 +1,938 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.FileProperties; +using Windows.Storage.Search; +using Windows.Win32; +using Windows.Win32.System.SystemServices; +using static Files.App.Helpers.Win32PInvoke; +using ByteSize = ByteSizeLib.ByteSize; +using FileAttributes = System.IO.FileAttributes; + +namespace Files.App.ViewModels +{ + /// + /// Windows folder listing (FindFirstFileEx, StorageFolder queries), ReadDirectoryChangesW watchers, alternate data streams and + /// Recycle Bin shell items of . Kept byte-identical to upstream where possible. + /// + public sealed partial class ShellViewModel + { + private async void RecycleBinRefreshRequestedAsync(object? sender, FileSystemEventArgs e) + { + if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) + return; + + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + RefreshItems(null); + }); + } + + private async void RecycleBinItemDeletedAsync(object? sender, FileSystemEventArgs e) + { + if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) + return; + + var removedItem = await RemoveFileOrFolderAsync(e.FullPath); + + if (removedItem is not null) + await ApplyFilesAndFoldersChangesAsync(); + } + + private async void RecycleBinItemCreatedAsync(object? sender, FileSystemEventArgs e) + { + if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) + return; + + using var folderItem = SafetyExtensions.IgnoreExceptions(() => new ShellItem(e.FullPath)); + if (folderItem is null) + return; + + var shellFileItem = ShellFolderExtensions.GetShellFileItem(folderItem); + if (shellFileItem is null) + throw new InvalidDataException("The recycle-bin item could not be converted to a shell item."); + + var newListedItem = await AddFileOrFolderFromShellFile(shellFileItem); + if (newListedItem is null) + return; + + await AddFileOrFolderAsync(newListedItem); + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + } + + partial void ApplyDriveStorageDetails(ListedItem item, IDictionary properties) + { + // Drive Storage Details + if (properties["System.SFGAOFlags"] is uint attributesRaw && + properties["System.Capacity"] is ulong capacityRaw && + properties["System.FreeSpace"] is ulong freeSpaceRaw && + ((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_REMOVABLE) && + !((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_FILESYSTEM)) + { + var maxSpace = ByteSize.FromBytes(capacityRaw); + var freeSpace = ByteSize.FromBytes(freeSpaceRaw); + + item.MaxSpace = maxSpace; + item.SpaceUsed = maxSpace - freeSpace; + item.FileSize = string.Format(Strings.DriveFreeSpaceAndCapacity.GetLocalizedResource(), freeSpace.ToSizeString(), maxSpace.ToSizeString()); + item.ShowDriveStorageDetails = true; + } + } + + private async Task PromptToUnlockBitlockerIfLockedAsync(string path, string pathRoot) + { + try + { + var rootFolder = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path)); + if (await FolderHelpers.CheckBitlockerStatusAsync(rootFolder, path)) + await ContextMenu.InvokeVerb("unlock-bde", pathRoot); + } + catch (Exception ex) + { + // Runs detached, so swallow: the property probe or the unlock-bde shell verb can throw (e.g. COMException) + App.Logger.LogWarning(ex, ex.Message); + } + } + + private async Task EnumerateWindowsFolderAsync(string path, CancellationToken cancellationToken, LibraryItem? library) + { + // Flag to use FindFirstFileExFromApp or StorageFolder enumeration - Use storage folder for Box Drive (#4629) + var isBoxFolder = CloudDrivesManager.Drives.FirstOrDefault(x => x.Text == "Box")?.Path?.TrimEnd('\\') is string boxFolder && path.StartsWith(boxFolder); + bool isWslDistro = path.StartsWith(@"\\wsl$\", StringComparison.OrdinalIgnoreCase) || path.StartsWith(@"\\wsl.localhost\", StringComparison.OrdinalIgnoreCase) + || path.Equals(@"\\wsl$", StringComparison.OrdinalIgnoreCase) || path.Equals(@"\\wsl.localhost", StringComparison.OrdinalIgnoreCase); + bool isMtp = path.StartsWith(@"\\?\", StringComparison.Ordinal); + bool isShellFolder = path.StartsWith(@"\\SHELL\", StringComparison.Ordinal); + bool isNetwork = path.StartsWith(@"\\", StringComparison.Ordinal) && + !isMtp && + !isShellFolder && + !isWslDistro; + bool isNetdisk = false; + + try + { + // Special handling for network drives + if (!isNetwork) + isNetdisk = await Task.Run(() => new DriveInfo(path).DriveType == System.IO.DriveType.Network); + } + catch { } + + bool isFtp = FtpHelpers.IsFtpPath(path); + bool enumFromStorageFolder = isBoxFolder || isFtp; + + BaseStorageFolder? rootFolder = null; + + if (isNetwork || isNetdisk) + { + var auth = await NetworkService.AuthenticateNetworkShare(path, cancellationToken); + if (!auth) + return -1; + } + + // Off the UI thread: FindFirstFileEx blocks until the SMB timeout on an unreachable share. + Win32PInvoke.SafeFindHandle? hFile = null; + WIN32_FIND_DATA findData = default; + int errorCode = 0; + if (!enumFromStorageFolder) + { + (hFile, findData, errorCode) = await Task.Run(() => + { + var hFileTsk = FindFirstFileExFromAppSafe( + path + "\\*.*", + FINDEX_INFO_LEVELS.FindExInfoBasic, + out WIN32_FIND_DATA findDataTsk, + FINDEX_SEARCH_OPS.FindExSearchNameMatch, + IntPtr.Zero, + FIND_FIRST_EX_LARGE_FETCH); + + return (hFileTsk, findDataTsk, hFileTsk.IsInvalid ? Marshal.GetLastWin32Error() : 0); + }) + .WithTimeoutAsync(TimeSpan.FromSeconds(5)); + } + + if (!enumFromStorageFolder && hFile is not null && !hFile.IsInvalid) + { + // Enumerate with the handle opened above; rootFolder only used for Bitlocker + currentStorageFolder = null; + } + else if (workingRoot is not null) + { + var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); + if (!res) + return -1; + + var storageFolder = res.Result + ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); + + currentStorageFolder = storageFolder; + rootFolder = storageFolder.Item; + enumFromStorageFolder = true; + } + else + { + var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); + if (res) + { + var storageFolder = res.Result + ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); + currentStorageFolder = storageFolder; + rootFolder = storageFolder.Item; + } + else if (res == FileSystemStatusCode.Unauthorized) + { + ShowLocationInaccessibleOrMissing(path); + + return -1; + } + else if (res == FileSystemStatusCode.NotFound) + { + ShowLocationInaccessibleOrMissing(path); + + return -1; + } + else + { + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged, res.ErrorCode.ToString()); + + return -1; + } + } + + var pathRoot = Path.GetPathRoot(path); + if (Path.IsPathRooted(path) && pathRoot == path) + { + // Off the critical path: a locked drive fails enumeration anyway, so don't block the listing on the BitLocker probe. + _ = PromptToUnlockBitlockerIfLockedAsync(path, pathRoot); + } + + HasNoWatcher = isFtp || isWslDistro || isMtp || currentStorageFolder?.Item is ZipStorageFolder; + + if (enumFromStorageFolder) + { + // The handle from the open above is unused on the storage-folder path. + hFile?.Dispose(); + + var basicProps = await rootFolder?.GetBasicPropertiesAsync(); + var currentFolder = library ?? new ListedItem(rootFolder?.FolderRelativeId ?? string.Empty) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemPropertiesInitialized = true, + ItemNameRaw = rootFolder?.DisplayName ?? string.Empty, + ItemDateModifiedReal = basicProps.DateModified, + ItemType = rootFolder?.DisplayType ?? string.Empty, + FileImage = null, + LoadFileIcon = false, + ItemPath = string.IsNullOrEmpty(rootFolder?.Path) ? currentStorageFolder?.Path ?? string.Empty : rootFolder.Path, + FileSize = null, + FileSizeBytes = 0, + }; + + if (library is null) + currentFolder.ItemDateCreatedReal = rootFolder?.DateCreated ?? DateTimeOffset.Now; + + CurrentFolder = currentFolder; + await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); + + // Workaround for #7428 + return isBoxFolder ? 2 : 1; + } + else + { + var itemModifiedDate = DateTime.Now; + var itemCreatedDate = DateTime.Now; + + try + { + FileTimeToSystemTime(in findData.ftLastWriteTime, out var systemModifiedTimeOutput); + itemModifiedDate = systemModifiedTimeOutput.ToDateTime(); + + FileTimeToSystemTime(in findData.ftCreationTime, out SYSTEMTIME systemCreatedTimeOutput); + itemCreatedDate = systemCreatedTimeOutput.ToDateTime(); + } + catch (ArgumentException) + { + } + + var isHidden = (((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden); + var opacity = isHidden ? Constants.UI.DimItemOpacity : 1d; + + var currentFolder = library ?? new ListedItem(null) + { + PrimaryItemAttribute = StorageItemTypes.Folder, + ItemPropertiesInitialized = true, + ItemNameRaw = rootFolder?.DisplayName ?? Path.GetFileName(path.TrimEnd('\\')), + ItemDateModifiedReal = itemModifiedDate, + ItemDateCreatedReal = itemCreatedDate, + ItemType = folderTypeTextLocalized, + FileImage = null, + IsHiddenItem = isHidden, + Opacity = opacity, + LoadFileIcon = false, + ItemPath = path, + FileSize = null, + FileSizeBytes = 0, + }; + + CurrentFolder = currentFolder; + + if (hFile is null) + { + ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); + + return -1; + } + else if (hFile.IsInvalid) + { + hFile.Dispose(); + await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); + + // errorCode == ERROR_ACCESS_DENIED + if (filesAndFolders.Count == 0 && errorCode == 0x5) + { + ShowLocationInaccessibleOrMissing(path); + + return -1; + } + + return 1; + } + else + { + await Task.Run(async () => + { + List fileList = await Win32StorageEnumerator.ListEntries(path, hFile, findData, cancellationToken, -1, GetPreloadIconSize(), intermediateAction: async (intermediateList) => + { + filesAndFolders.AddRange(intermediateList); + + // The stable sort lands new items in final position without reordering visible ones; capped for huge listings + if (filesAndFolders.Count <= 10_000) + await OrderFilesAndFoldersAsync(); + + await ApplyFilesAndFoldersChangesAsync(); + }); + + filesAndFolders.AddRange(fileList); + + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + // Not awaited here: with Low priority these don't run until the UI thread goes idle + // after the final list update, which would delay load completion and watcher setup. + // The desktop.ini task is awaited before applying the adaptive layout, which reads DesktopIni. + _ = dispatcherQueue.EnqueueOrInvokeAsync(CheckForSolutionFile, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); + desktopIniUpdateTask = dispatcherQueue.EnqueueOrInvokeAsync(() => + { + GetDesktopIniFileData(); + CheckForBackgroundImage(); + }, + Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); + }); + + // Cache the resolved folder so the post-enum switch reuses it. + currentStorageFolder ??= await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path)); + rootFolder ??= currentStorageFolder?.Item; + if (rootFolder is not null) + { + if (rootFolder.DisplayName is not null) + currentFolder.ItemNameRaw = rootFolder.DisplayName; + + if (!string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) + { + enumeratedCloudSyncStatus = await CheckCloudDriveSyncStatusAsync(rootFolder); + currentFolder.SyncStatusUI = CloudDriveSyncStatusUI.FromCloudDriveSyncStatus(enumeratedCloudSyncStatus.Value); + } + } + + return 0; + } + } + } + + private async Task EnumFromStorageFolderAsync(string path, BaseStorageFolder? rootFolder, StorageFolderWithPath? currentStorageFolder, CancellationToken cancellationToken) + { + if (rootFolder is null) + return; + + // Null when a concurrent navigation or dispose cleared the context; this enumeration is stale + if (currentStorageFolder is null) + return; + + if (rootFolder is IPasswordProtectedItem ppis) + ppis.PasswordRequestedCallback = async (item) => + { + await dispatcherQueue.EnqueueOrInvokeAsync(() => ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired)); + + return await UIFilesystemHelpers.RequestPassword(item); + }; + + try + { + await Task.Run(async () => + { + List finalList = await UniversalStorageEnumerator.ListEntries( + rootFolder, + currentStorageFolder, + cancellationToken, + -1, + GetPreloadIconSize(), + async (intermediateList) => + { + filesAndFolders.AddRange(intermediateList); + + // The stable sort lands new items in final position without reordering visible ones; capped for huge listings + if (filesAndFolders.Count <= 10_000) + await OrderFilesAndFoldersAsync(); + + await ApplyFilesAndFoldersChangesAsync(); + }); + + filesAndFolders.AddRange(finalList); + + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + }, cancellationToken); + + IsLocationUnavailable = false; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) // Password dialog dismissed + { + ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); + } + + if (rootFolder is IPasswordProtectedItem ppiu) + ppiu.PasswordRequestedCallback = null; + } + + private async Task WatchForStorageFolderChangesAsync(BaseStorageFolder? rootFolder) + { + if (rootFolder is null) + return; + + await Task.Run(() => + { + var options = new QueryOptions() + { + FolderDepth = FolderDepth.Shallow, + IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties + }; + + options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); + options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); + + if (rootFolder.AreQueryOptionsSupported(options)) + { + var itemQueryResult = rootFolder.CreateItemQueryWithOptions(options).ToStorageItemQueryResult() + ?? throw new InvalidOperationException("The folder query could not be converted to a storage query."); + + itemQueryResult.ContentsChanged += ItemQueryResult_ContentsChanged; + + // Just get one item to start getting notifications + var watchedItemsOperation = itemQueryResult.GetItemsAsync(0, 1); + + watcherCTS.Token.Register(() => + { + itemQueryResult.ContentsChanged -= ItemQueryResult_ContentsChanged; + watchedItemsOperation?.Cancel(); + }); + } + }); + } + + private void WatchForWin32FolderChanges(string? folderPath) + { + if (!Directory.Exists(folderPath)) + return; + + // NOTE: Suppressed NullReferenceException caused by EnableRaisingEvents + SafetyExtensions.IgnoreExceptions(() => + { + watcher = new FileSystemWatcher + { + Path = folderPath, + Filter = "*.*", + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName + }; + + watcher.Created += DirectoryWatcher_Changed; + watcher.Deleted += DirectoryWatcher_Changed; + watcher.Renamed += DirectoryWatcher_Changed; + watcher.EnableRaisingEvents = true; + }, App.Logger); + } + + private async void DirectoryWatcher_Changed(object sender, FileSystemEventArgs e) + { + Debug.WriteLine($"Directory watcher event: {e.ChangeType}, {e.FullPath}"); + + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + RefreshItems(null); + }); + } + + private async void ItemQueryResult_ContentsChanged(IStorageQueryResultBase sender, object args) + { + // Query options have to be reapplied otherwise old results are returned + var options = new QueryOptions() + { + FolderDepth = FolderDepth.Shallow, + IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties + }; + + options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); + options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); + + sender.ApplyNewQueryOptions(options); + + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + RefreshItems(null); + }); + } + + private void WatchForDirectoryChanges(string path, CloudDriveSyncStatus syncStatus) + { + // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. + if (isDisposed) + return; + + Debug.WriteLine($"WatchForDirectoryChanges: {path}"); + var hWatchDir = Win32PInvoke.CreateFileFromApp(path, 1, 1 | 2 | 4, + IntPtr.Zero, 3, (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, IntPtr.Zero); + if (hWatchDir.ToInt64() == -1) + return; + + var hasSyncStatus = syncStatus != CloudDriveSyncStatus.NotSynced && syncStatus != CloudDriveSyncStatus.Unknown; + + aProcessQueueAction ??= Task.Run(() => ProcessOperationQueueAsync(watcherCTS.Token, hasSyncStatus)); + + var aWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => + { + var buff = new byte[4096]; + var rand = Guid.NewGuid(); + var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE; + + if (hasSyncStatus) + notifyFilters |= FILE_NOTIFY_CHANGE_ATTRIBUTES; + + var overlapped = new NativeOverlapped(); + using var eventHandle = PInvoke.CreateEvent(null, false, false, null); + overlapped.EventHandle = eventHandle.DangerousGetHandle(); + const uint INFINITE = 0xFFFFFFFF; + + while (x.Status != AsyncStatus.Canceled) + { + unsafe + { + fixed (byte* pBuff = buff) + { + ref var notifyInformation = ref Unsafe.As(ref buff[0]); + if (x.Status != AsyncStatus.Canceled) + { + PInvoke.ReadDirectoryChanges( + new Windows.Win32.Foundation.HANDLE(hWatchDir), + pBuff, + 4096, + false, + (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, + null, + &overlapped, + null); + } + else + { + break; + } + + Debug.WriteLine("waiting: {0}", rand); + if (x.Status == AsyncStatus.Canceled) + break; + + var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); + Debug.WriteLine("wait done: {0}", rand); + + uint offset = 0; + ref var notifyInfo = ref Unsafe.As(ref buff[offset]); + if (x.Status == AsyncStatus.Canceled) + break; + + do + { + notifyInfo = ref Unsafe.As(ref buff[offset]); + string? FileName = null; + unsafe + { + fixed (char* name = notifyInfo.FileName) + { + FileName = Path.Combine(path, new string(name, 0, (int)notifyInfo.FileNameLength / 2)); + } + } + + uint action = notifyInfo.Action; + + Debug.WriteLine("action: {0}", action); + + operationQueue.Enqueue((action, FileName)); + + offset += notifyInfo.NextEntryOffset; + } + while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); + + operationEvent.Set(); + + //ResetEvent(overlapped.hEvent); + Debug.WriteLine("Task running..."); + } + } + } + + operationQueue.Clear(); + + Debug.WriteLine("aWatcherAction done: {0}", rand); + }); + + watcherCTS.Token.Register(() => + { + if (aWatcherAction is not null) + { + aWatcherAction?.Cancel(); + + // Prevent duplicate execution of this block + aWatcherAction = null; + + Debug.WriteLine("watcher canceled"); + } + + CancelIoEx(hWatchDir, IntPtr.Zero); + CloseHandle(hWatchDir); + }); + } + + private void WatchForGitChanges() + { + // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. + if (isDisposed) + return; + + var hWatchDir = Win32PInvoke.CreateFileFromApp( + GitDirectory!, + 1, + 1 | 2 | 4, + IntPtr.Zero, + 3, + (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, + IntPtr.Zero); + + if (hWatchDir.ToInt64() == -1) + return; + + gitProcessQueueAction ??= Task.Run(() => ProcessGitChangesQueueAsync(watcherCTS.Token)); + + var gitWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => + { + var buff = new byte[4096]; + var rand = Guid.NewGuid(); + var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_CREATION; + + var overlapped = new NativeOverlapped(); + using var eventHandle = PInvoke.CreateEvent(null, false, false, null); + overlapped.EventHandle = eventHandle.DangerousGetHandle(); + const uint INFINITE = 0xFFFFFFFF; + + while (x.Status != AsyncStatus.Canceled) + { + unsafe + { + fixed (byte* pBuff = buff) + { + ref var notifyInformation = ref Unsafe.As(ref buff[0]); + if (x.Status == AsyncStatus.Canceled) + break; + + PInvoke.ReadDirectoryChanges( + new Windows.Win32.Foundation.HANDLE(hWatchDir), + pBuff, + 4096, + true, + (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, + null, + &overlapped, + null); + + if (x.Status == AsyncStatus.Canceled) + break; + + var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); + + uint offset = 0; + ref var notifyInfo = ref Unsafe.As(ref buff[offset]); + if (x.Status == AsyncStatus.Canceled) + break; + + do + { + notifyInfo = ref Unsafe.As(ref buff[offset]); + + uint action = notifyInfo.Action; + + gitChangesQueue.Enqueue(action); + + offset += notifyInfo.NextEntryOffset; + } + while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); + + gitChangedEvent.Set(); + } + } + } + + gitChangesQueue.Clear(); + }); + + watcherCTS.Token.Register(() => + { + if (gitWatcherAction is not null) + { + gitWatcherAction?.Cancel(); + + // Prevent duplicate execution of this block + gitWatcherAction = null; + } + + CancelIoEx(hWatchDir, IntPtr.Zero); + CloseHandle(hWatchDir); + }); + } + + private async Task ProcessGitChangesQueueAsync(CancellationToken cancellationToken) + { + const int DELAY = 200; + var sampler = new IntervalSampler(100); + int changes = 0; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (await gitChangedEvent.WaitAsync(DELAY, cancellationToken)) + { + gitChangedEvent.Reset(); + while (gitChangesQueue.TryDequeue(out var _)) + ++changes; + + if (changes != 0 && sampler.CheckNow()) + { + await dispatcherQueue.EnqueueOrInvokeAsync(() => GitDirectoryUpdated?.Invoke(null, null!)); + changes = 0; + } + } + } + } + catch { } + } + + private async Task ProcessOperationQueueAsync(CancellationToken cancellationToken, bool hasSyncStatus) + { + const uint FILE_ACTION_ADDED = 0x00000001; + const uint FILE_ACTION_REMOVED = 0x00000002; + const uint FILE_ACTION_MODIFIED = 0x00000003; + const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004; + const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005; + + const int UPDATE_BATCH_SIZE = 32; + var sampler = new IntervalSampler(200); + var updateQueue = new Queue(); + + var anyEdits = false; + ListedItem? lastItemAdded = null; + var rand = Guid.NewGuid(); + + // Call when any edits have occurred + async Task HandleChangesOccurredAsync() + { + await OrderFilesAndFoldersAsync(); + await ApplyFilesAndFoldersChangesAsync(); + + if (lastItemAdded is not null) + { + await RequestSelectionAsync([lastItemAdded]); + lastItemAdded = null; + } + + anyEdits = false; + } + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (await operationEvent.WaitAsync(200, cancellationToken)) + { + operationEvent.Reset(); + + while (operationQueue.TryDequeue(out var operation)) + { + if (cancellationToken.IsCancellationRequested) + break; + + try + { + switch (operation.Action) + { + case FILE_ACTION_ADDED: + case FILE_ACTION_RENAMED_NEW_NAME: + lastItemAdded = await AddFileOrFolderAsync(operation.FileName); + if (lastItemAdded is not null) + anyEdits = true; + break; + + case FILE_ACTION_MODIFIED: + if (!updateQueue.Contains(operation.FileName)) + updateQueue.Enqueue(operation.FileName); + break; + + case FILE_ACTION_REMOVED: + var itemRemoved = await RemoveFileOrFolderAsync(operation.FileName); + if (itemRemoved is not null) + anyEdits = true; + break; + + case FILE_ACTION_RENAMED_OLD_NAME: + // Pair OLD_NAME with the following NEW_NAME so the item can be updated + // in place and stay at its current position instead of jumping to a new + // sorted slot after a rename (issue #4214). Leaving anyEdits false skips + // OrderFilesAndFoldersAsync; PropertyChanged keeps the visible name in sync. + if (operationQueue.TryPeek(out var nextOp) && nextOp.Action == FILE_ACTION_RENAMED_NEW_NAME && + filesAndFolders.ToList().FirstOrDefault(x => x.GetRequiredPath().Equals(operation.FileName, StringComparison.OrdinalIgnoreCase)) is { } renamed) + { + operationQueue.TryDequeue(out _); + var newPath = nextOp.FileName; + await dispatcherQueue.EnqueueOrInvokeAsync(() => + { + renamed.ItemPath = newPath; + renamed.ItemNameRaw = Path.GetFileName(newPath); + if (renamed.PrimaryItemAttribute == StorageItemTypes.File) + renamed.FileExtension = Path.GetExtension(newPath); + }); + } + else + { + var itemRenamedOld = await RemoveFileOrFolderAsync(operation.FileName); + if (itemRenamedOld is not null) + anyEdits = true; + } + break; + } + } + catch (Exception ex) + { + App.Logger.LogWarning(ex, ex.Message); + } + + if (anyEdits && sampler.CheckNow()) + await HandleChangesOccurredAsync(); + } + + var itemsToUpdate = new List(); + for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) + itemsToUpdate.Add(updateQueue.Dequeue()); + + await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); + } + + if (updateQueue.Count > 0) + { + var itemsToUpdate = new List(); + for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) + itemsToUpdate.Add(updateQueue.Dequeue()); + + await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); + } + + if (anyEdits && sampler.CheckNow()) + await HandleChangesOccurredAsync(); + } + } + catch + { + // Prevent disposed cancellation token + } + + Debug.WriteLine("aProcessQueueAction done: {0}", rand); + } + + public Task AddFileOrFolderFromShellFile(ShellFileItem item) + { + return + item.IsFolder ? + UniversalStorageEnumerator.AddFolderAsync(ShellStorageFolder.FromShellItem(item), currentStorageFolder, addFilesCTS.Token) : + UniversalStorageEnumerator.AddFileAsync(ShellStorageFile.FromShellItem(item), currentStorageFolder, addFilesCTS.Token); + } + + partial void AddAlternateStreamItems(ListedItem item) + { + if (UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) + { + // New file added, enumerate ADS + foreach (var ads in Win32Helper.GetAlternateStreams(item.GetRequiredPath())) + { + var adsItem = Win32StorageEnumerator.GetAlternateStream(ads, item); + filesAndFolders.Add(adsItem); + } + } + } + + private async Task AddFileOrFolderAsync(string fileOrFolderPath) + { + FINDEX_INFO_LEVELS findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoBasic; + var additionalFlags = FIND_FIRST_EX_CASE_SENSITIVE; + + IntPtr hFile = FindFirstFileExFromApp(fileOrFolderPath, findInfoLevel, out WIN32_FIND_DATA findData, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, + additionalFlags); + if (hFile.ToInt64() == -1) + { + // If we cannot find the file (probably since it doesn't exist anymore) simply exit without adding it + return null; + } + + FindClose(hFile); + + var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; + var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; + var startWithDot = findData.cFileName.StartsWith('.'); + if ((isHidden && + (!UserSettingsService.FoldersSettingsService.ShowHiddenItems || + (isSystem && !UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) || + (startWithDot && !UserSettingsService.FoldersSettingsService.ShowDotFiles)) + { + // Do not add to file list if hidden/system attribute is set and system/hidden file are not to be shown + return null; + } + + var parentPath = Directory.GetParent(fileOrFolderPath)?.FullName + ?? throw new InvalidOperationException("The added item does not have a parent directory."); + + ListedItem? listedItem; + + // FILE_ATTRIBUTE_DIRECTORY + if ((findData.dwFileAttributes & 0x10) > 0) + listedItem = await Win32StorageEnumerator.GetFolder(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); + else + listedItem = await Win32StorageEnumerator.GetFile(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); + + if (listedItem is null) + return null; + + await AddFileOrFolderAsync(listedItem); + + return listedItem; + } + } +} diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index 8748a7a65f7d..0cc4af04d4ed 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -18,9 +18,6 @@ using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Search; -using Windows.Win32; -using Windows.Win32.System.SystemServices; -using static Files.App.Helpers.Win32PInvoke; using ByteSize = ByteSizeLib.ByteSize; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; using FileAttributes = System.IO.FileAttributes; @@ -758,9 +755,11 @@ public ShellViewModel(LayoutPreferencesManager folderSettingsViewModel) fileTagsSettingsService.OnTagsUpdated += FileTagsSettingsService_OnSettingUpdated; folderSizeProvider.SizeChanged += FolderSizeProvider_SizeChanged; folderSettings.LayoutModeChangeRequested += LayoutModeChangeRequested; +#if WINDOWS StorageTrashBinService.Watcher.ItemAdded += RecycleBinItemCreatedAsync; StorageTrashBinService.Watcher.ItemDeleted += RecycleBinItemDeletedAsync; StorageTrashBinService.Watcher.RefreshRequested += RecycleBinRefreshRequestedAsync; +#endif } private async void LayoutModeChangeRequested(object? sender, LayoutModeEventArgs e) @@ -768,50 +767,6 @@ private async void LayoutModeChangeRequested(object? sender, LayoutModeEventArgs await dispatcherQueue.EnqueueOrInvokeAsync(CheckForBackgroundImage, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); } - private async void RecycleBinRefreshRequestedAsync(object? sender, FileSystemEventArgs e) - { - if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) - return; - - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - RefreshItems(null); - }); - } - - private async void RecycleBinItemDeletedAsync(object? sender, FileSystemEventArgs e) - { - if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) - return; - - var removedItem = await RemoveFileOrFolderAsync(e.FullPath); - - if (removedItem is not null) - await ApplyFilesAndFoldersChangesAsync(); - } - - private async void RecycleBinItemCreatedAsync(object? sender, FileSystemEventArgs e) - { - if (!Constants.UserEnvironmentPaths.RecycleBinPath.Equals(CurrentFolder?.ItemPath, StringComparison.OrdinalIgnoreCase)) - return; - - using var folderItem = SafetyExtensions.IgnoreExceptions(() => new ShellItem(e.FullPath)); - if (folderItem is null) - return; - - var shellFileItem = ShellFolderExtensions.GetShellFileItem(folderItem); - if (shellFileItem is null) - throw new InvalidDataException("The recycle-bin item could not be converted to a shell item."); - - var newListedItem = await AddFileOrFolderFromShellFile(shellFileItem); - if (newListedItem is null) - return; - - await AddFileOrFolderAsync(newListedItem); - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - } - private async void FolderSizeProvider_SizeChanged(object? sender, Services.SizeProvider.SizeChangedEventArgs e) { try @@ -1145,6 +1100,17 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => pendingSearchResults.Clear(); +#if !WINDOWS + // A few watcher changes must not rebuild (and visibly blank) the whole list + if (TryApplyIncrementalDisplayChanges(displayedFilesAndFolders)) + { + UpdateEmptyTextType(); + UpdateNetworkAvailabilityInfoBar(); + DirectoryInfoUpdated?.Invoke(this, EventArgs.Empty); + return; + } +#endif + FilesAndFolders.BeginBulkOperation(); try { @@ -1821,21 +1787,7 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => var properties = extraProperties.Result ?? throw new InvalidOperationException("A folder-property lookup did not return properties."); - // Drive Storage Details - if (properties["System.SFGAOFlags"] is uint attributesRaw && - properties["System.Capacity"] is ulong capacityRaw && - properties["System.FreeSpace"] is ulong freeSpaceRaw && - ((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_REMOVABLE) && - !((SFGAO_FLAGS)attributesRaw).HasFlag(SFGAO_FLAGS.SFGAO_FILESYSTEM)) - { - var maxSpace = ByteSize.FromBytes(capacityRaw); - var freeSpace = ByteSize.FromBytes(freeSpaceRaw); - - item.MaxSpace = maxSpace; - item.SpaceUsed = maxSpace - freeSpace; - item.FileSize = string.Format(Strings.DriveFreeSpaceAndCapacity.GetLocalizedResource(), freeSpace.ToSizeString(), maxSpace.ToSizeString()); - item.ShowDriveStorageDetails = true; - } + ApplyDriveStorageDetails(item, properties); } else @@ -2201,6 +2153,7 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib switch (enumerated) { +#if WINDOWS // Enumerated with FindFirstFileExFromApp // Is folder synced to cloud storage? case 0: @@ -2233,6 +2186,7 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib if (!HasNoWatcher) WatchForWin32FolderChanges(path); break; +#endif // Enumerated through IFileSystemEnumerator (Linux) case 3: @@ -2250,6 +2204,11 @@ private async Task RapidAddItemsToCollectionAsync(string? path, LibraryItem? lib WatchForLinuxTrashChanges(); break; + // Archive listed through IStorableResolver (Linux); read-only, so no watcher + case 5: + PageTypeUpdated?.Invoke(this, new PageTypeUpdatedEventArgs() { IsTypeCloudDrive = false }); + break; + // Enumeration failed case -1: default: @@ -2283,332 +2242,15 @@ public void CloseWatcher() watcherCTS = new CancellationTokenSource(); } - private async Task PromptToUnlockBitlockerIfLockedAsync(string path, string pathRoot) - { - try - { - var rootFolder = await FilesystemTasks.WrapNullable(() => StorageFileExtensions.DangerousGetFolderFromPathAsync(path)); - if (await FolderHelpers.CheckBitlockerStatusAsync(rootFolder, path)) - await ContextMenu.InvokeVerb("unlock-bde", pathRoot); - } - catch (Exception ex) - { - // Runs detached, so swallow: the property probe or the unlock-bde shell verb can throw (e.g. COMException) - App.Logger.LogWarning(ex, ex.Message); - } - } - private async Task EnumerateItemsFromStandardFolderAsync(string path, CancellationToken cancellationToken, LibraryItem? library = null) { enumeratedCloudSyncStatus = null; -#if !WINDOWS +#if WINDOWS + return await EnumerateWindowsFolderAsync(path, cancellationToken, library); +#else return await EnumerateLinuxFolderAsync(path, cancellationToken, library); #endif - - // Flag to use FindFirstFileExFromApp or StorageFolder enumeration - Use storage folder for Box Drive (#4629) - var isBoxFolder = CloudDrivesManager.Drives.FirstOrDefault(x => x.Text == "Box")?.Path?.TrimEnd('\\') is string boxFolder && path.StartsWith(boxFolder); - bool isWslDistro = path.StartsWith(@"\\wsl$\", StringComparison.OrdinalIgnoreCase) || path.StartsWith(@"\\wsl.localhost\", StringComparison.OrdinalIgnoreCase) - || path.Equals(@"\\wsl$", StringComparison.OrdinalIgnoreCase) || path.Equals(@"\\wsl.localhost", StringComparison.OrdinalIgnoreCase); - bool isMtp = path.StartsWith(@"\\?\", StringComparison.Ordinal); - bool isShellFolder = path.StartsWith(@"\\SHELL\", StringComparison.Ordinal); - bool isNetwork = path.StartsWith(@"\\", StringComparison.Ordinal) && - !isMtp && - !isShellFolder && - !isWslDistro; - bool isNetdisk = false; - - try - { - // Special handling for network drives - if (!isNetwork) - isNetdisk = await Task.Run(() => new DriveInfo(path).DriveType == System.IO.DriveType.Network); - } - catch { } - - bool isFtp = FtpHelpers.IsFtpPath(path); - bool enumFromStorageFolder = isBoxFolder || isFtp; - - BaseStorageFolder? rootFolder = null; - - if (isNetwork || isNetdisk) - { - var auth = await NetworkService.AuthenticateNetworkShare(path, cancellationToken); - if (!auth) - return -1; - } - - // Off the UI thread: FindFirstFileEx blocks until the SMB timeout on an unreachable share. - Win32PInvoke.SafeFindHandle? hFile = null; - WIN32_FIND_DATA findData = default; - int errorCode = 0; - if (!enumFromStorageFolder) - { - (hFile, findData, errorCode) = await Task.Run(() => - { - var hFileTsk = FindFirstFileExFromAppSafe( - path + "\\*.*", - FINDEX_INFO_LEVELS.FindExInfoBasic, - out WIN32_FIND_DATA findDataTsk, - FINDEX_SEARCH_OPS.FindExSearchNameMatch, - IntPtr.Zero, - FIND_FIRST_EX_LARGE_FETCH); - - return (hFileTsk, findDataTsk, hFileTsk.IsInvalid ? Marshal.GetLastWin32Error() : 0); - }) - .WithTimeoutAsync(TimeSpan.FromSeconds(5)); - } - - if (!enumFromStorageFolder && hFile is not null && !hFile.IsInvalid) - { - // Enumerate with the handle opened above; rootFolder only used for Bitlocker - currentStorageFolder = null; - } - else if (workingRoot is not null) - { - var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); - if (!res) - return -1; - - var storageFolder = res.Result - ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); - - currentStorageFolder = storageFolder; - rootFolder = storageFolder.Item; - enumFromStorageFolder = true; - } - else - { - var res = await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path, workingRoot, currentStorageFolder)); - if (res) - { - var storageFolder = res.Result - ?? throw new InvalidOperationException("A successful folder lookup did not return a storage folder."); - currentStorageFolder = storageFolder; - rootFolder = storageFolder.Item; - } - else if (res == FileSystemStatusCode.Unauthorized) - { - ShowLocationInaccessibleOrMissing(path); - - return -1; - } - else if (res == FileSystemStatusCode.NotFound) - { - ShowLocationInaccessibleOrMissing(path); - - return -1; - } - else - { - ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged, res.ErrorCode.ToString()); - - return -1; - } - } - - var pathRoot = Path.GetPathRoot(path); - if (Path.IsPathRooted(path) && pathRoot == path) - { - // Off the critical path: a locked drive fails enumeration anyway, so don't block the listing on the BitLocker probe. - _ = PromptToUnlockBitlockerIfLockedAsync(path, pathRoot); - } - - HasNoWatcher = isFtp || isWslDistro || isMtp || currentStorageFolder?.Item is ZipStorageFolder; - - if (enumFromStorageFolder) - { - // The handle from the open above is unused on the storage-folder path. - hFile?.Dispose(); - - var basicProps = await rootFolder?.GetBasicPropertiesAsync(); - var currentFolder = library ?? new ListedItem(rootFolder?.FolderRelativeId ?? string.Empty) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemPropertiesInitialized = true, - ItemNameRaw = rootFolder?.DisplayName ?? string.Empty, - ItemDateModifiedReal = basicProps.DateModified, - ItemType = rootFolder?.DisplayType ?? string.Empty, - FileImage = null, - LoadFileIcon = false, - ItemPath = string.IsNullOrEmpty(rootFolder?.Path) ? currentStorageFolder?.Path ?? string.Empty : rootFolder.Path, - FileSize = null, - FileSizeBytes = 0, - }; - - if (library is null) - currentFolder.ItemDateCreatedReal = rootFolder?.DateCreated ?? DateTimeOffset.Now; - - CurrentFolder = currentFolder; - await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); - - // Workaround for #7428 - return isBoxFolder ? 2 : 1; - } - else - { - var itemModifiedDate = DateTime.Now; - var itemCreatedDate = DateTime.Now; - - try - { - FileTimeToSystemTime(in findData.ftLastWriteTime, out var systemModifiedTimeOutput); - itemModifiedDate = systemModifiedTimeOutput.ToDateTime(); - - FileTimeToSystemTime(in findData.ftCreationTime, out SYSTEMTIME systemCreatedTimeOutput); - itemCreatedDate = systemCreatedTimeOutput.ToDateTime(); - } - catch (ArgumentException) - { - } - - var isHidden = (((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden); - var opacity = isHidden ? Constants.UI.DimItemOpacity : 1d; - - var currentFolder = library ?? new ListedItem(null) - { - PrimaryItemAttribute = StorageItemTypes.Folder, - ItemPropertiesInitialized = true, - ItemNameRaw = rootFolder?.DisplayName ?? Path.GetFileName(path.TrimEnd('\\')), - ItemDateModifiedReal = itemModifiedDate, - ItemDateCreatedReal = itemCreatedDate, - ItemType = folderTypeTextLocalized, - FileImage = null, - IsHiddenItem = isHidden, - Opacity = opacity, - LoadFileIcon = false, - ItemPath = path, - FileSize = null, - FileSizeBytes = 0, - }; - - CurrentFolder = currentFolder; - - if (hFile is null) - { - ShowLocationUnavailable(LocationUnavailableKind.DriveUnplugged); - - return -1; - } - else if (hFile.IsInvalid) - { - hFile.Dispose(); - await EnumFromStorageFolderAsync(path, rootFolder, currentStorageFolder, cancellationToken); - - // errorCode == ERROR_ACCESS_DENIED - if (filesAndFolders.Count == 0 && errorCode == 0x5) - { - ShowLocationInaccessibleOrMissing(path); - - return -1; - } - - return 1; - } - else - { - await Task.Run(async () => - { - List fileList = await Win32StorageEnumerator.ListEntries(path, hFile, findData, cancellationToken, -1, GetPreloadIconSize(), intermediateAction: async (intermediateList) => - { - filesAndFolders.AddRange(intermediateList); - - // The stable sort lands new items in final position without reordering visible ones; capped for huge listings - if (filesAndFolders.Count <= 10_000) - await OrderFilesAndFoldersAsync(); - - await ApplyFilesAndFoldersChangesAsync(); - }); - - filesAndFolders.AddRange(fileList); - - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - // Not awaited here: with Low priority these don't run until the UI thread goes idle - // after the final list update, which would delay load completion and watcher setup. - // The desktop.ini task is awaited before applying the adaptive layout, which reads DesktopIni. - _ = dispatcherQueue.EnqueueOrInvokeAsync(CheckForSolutionFile, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); - desktopIniUpdateTask = dispatcherQueue.EnqueueOrInvokeAsync(() => - { - GetDesktopIniFileData(); - CheckForBackgroundImage(); - }, - Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); - }); - - // Cache the resolved folder so the post-enum switch reuses it. - currentStorageFolder ??= await FilesystemTasks.Wrap(() => StorageFileExtensions.DangerousGetFolderWithPathFromPathAsync(path)); - rootFolder ??= currentStorageFolder?.Item; - if (rootFolder is not null) - { - if (rootFolder.DisplayName is not null) - currentFolder.ItemNameRaw = rootFolder.DisplayName; - - if (!string.Equals(path, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) - { - enumeratedCloudSyncStatus = await CheckCloudDriveSyncStatusAsync(rootFolder); - currentFolder.SyncStatusUI = CloudDriveSyncStatusUI.FromCloudDriveSyncStatus(enumeratedCloudSyncStatus.Value); - } - } - - return 0; - } - } - } - - private async Task EnumFromStorageFolderAsync(string path, BaseStorageFolder? rootFolder, StorageFolderWithPath? currentStorageFolder, CancellationToken cancellationToken) - { - if (rootFolder is null) - return; - - // Null when a concurrent navigation or dispose cleared the context; this enumeration is stale - if (currentStorageFolder is null) - return; - - if (rootFolder is IPasswordProtectedItem ppis) - ppis.PasswordRequestedCallback = async (item) => - { - await dispatcherQueue.EnqueueOrInvokeAsync(() => ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired)); - - return await UIFilesystemHelpers.RequestPassword(item); - }; - - try - { - await Task.Run(async () => - { - List finalList = await UniversalStorageEnumerator.ListEntries( - rootFolder, - currentStorageFolder, - cancellationToken, - -1, - GetPreloadIconSize(), - async (intermediateList) => - { - filesAndFolders.AddRange(intermediateList); - - // The stable sort lands new items in final position without reordering visible ones; capped for huge listings - if (filesAndFolders.Count <= 10_000) - await OrderFilesAndFoldersAsync(); - - await ApplyFilesAndFoldersChangesAsync(); - }); - - filesAndFolders.AddRange(finalList); - - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - }, cancellationToken); - - IsLocationUnavailable = false; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) // Password dialog dismissed - { - ShowLocationUnavailable(LocationUnavailableKind.PasswordRequired); - } - - if (rootFolder is IPasswordProtectedItem ppiu) - ppiu.PasswordRequestedCallback = null; } private void CheckForSolutionFile() @@ -2696,6 +2338,7 @@ public void CheckForBackgroundImage() public async Task CheckCloudDriveSyncStatusAsync(IStorageItem? item) { + // LINUX-TODO(props): placeholder status comes from the Windows property system, which Uno doesn't back; Linux gets Unknown. int? syncStatus = null; if (item is BaseStorageFile file && file.Properties is not null) { @@ -2727,8 +2370,13 @@ public async Task CheckCloudDriveSyncStatusAsync(IStorageI return (CloudDriveSyncStatus)syncStatus; } + // Fills the drive capacity columns of removable shell folders (SFGAO flags); Windows only + partial void ApplyDriveStorageDetails(ListedItem item, IDictionary properties); + private async Task>?> GetExtraProperties(IStorageItem matchingStorageItem) { + // LINUX-TODO(props): Uno has no Windows property system behind RetrievePropertiesAsync, so dimensions, duration, version and + // drive capacity stay empty on Linux. Read them from a metadata service when those columns are ported. if (matchingStorageItem is BaseStorageFile file && file.Properties != null) return await FilesystemTasks.Wrap(() => file.Properties.RetrievePropertiesAsync((string[])["System.Image.Dimensions", "System.Media.Duration", "System.FileVersion"]).AsTask()); @@ -2738,471 +2386,8 @@ public async Task CheckCloudDriveSyncStatusAsync(IStorageI return null; } - private async Task WatchForStorageFolderChangesAsync(BaseStorageFolder? rootFolder) - { - if (rootFolder is null) - return; - - await Task.Run(() => - { - var options = new QueryOptions() - { - FolderDepth = FolderDepth.Shallow, - IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties - }; - - options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); - options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); - - if (rootFolder.AreQueryOptionsSupported(options)) - { - var itemQueryResult = rootFolder.CreateItemQueryWithOptions(options).ToStorageItemQueryResult() - ?? throw new InvalidOperationException("The folder query could not be converted to a storage query."); - - itemQueryResult.ContentsChanged += ItemQueryResult_ContentsChanged; - - // Just get one item to start getting notifications - var watchedItemsOperation = itemQueryResult.GetItemsAsync(0, 1); - - watcherCTS.Token.Register(() => - { - itemQueryResult.ContentsChanged -= ItemQueryResult_ContentsChanged; - watchedItemsOperation?.Cancel(); - }); - } - }); - } - - private void WatchForWin32FolderChanges(string? folderPath) - { - if (!Directory.Exists(folderPath)) - return; - - // NOTE: Suppressed NullReferenceException caused by EnableRaisingEvents - SafetyExtensions.IgnoreExceptions(() => - { - watcher = new FileSystemWatcher - { - Path = folderPath, - Filter = "*.*", - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName - }; - - watcher.Created += DirectoryWatcher_Changed; - watcher.Deleted += DirectoryWatcher_Changed; - watcher.Renamed += DirectoryWatcher_Changed; - watcher.EnableRaisingEvents = true; - }, App.Logger); - } - - private async void DirectoryWatcher_Changed(object sender, FileSystemEventArgs e) - { - Debug.WriteLine($"Directory watcher event: {e.ChangeType}, {e.FullPath}"); - - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - RefreshItems(null); - }); - } - - private async void ItemQueryResult_ContentsChanged(IStorageQueryResultBase sender, object args) - { - // Query options have to be reapplied otherwise old results are returned - var options = new QueryOptions() - { - FolderDepth = FolderDepth.Shallow, - IndexerOption = IndexerOption.OnlyUseIndexerAndOptimizeForIndexedProperties - }; - - options.SetPropertyPrefetch(PropertyPrefetchOptions.None, null); - options.SetThumbnailPrefetch(ThumbnailMode.ListView, 0, ThumbnailOptions.ReturnOnlyIfCached); - - sender.ApplyNewQueryOptions(options); - - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - RefreshItems(null); - }); - } - - private void WatchForDirectoryChanges(string path, CloudDriveSyncStatus syncStatus) - { - // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. - if (isDisposed) - return; - - Debug.WriteLine($"WatchForDirectoryChanges: {path}"); - var hWatchDir = Win32PInvoke.CreateFileFromApp(path, 1, 1 | 2 | 4, - IntPtr.Zero, 3, (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, IntPtr.Zero); - if (hWatchDir.ToInt64() == -1) - return; - - var hasSyncStatus = syncStatus != CloudDriveSyncStatus.NotSynced && syncStatus != CloudDriveSyncStatus.Unknown; - - aProcessQueueAction ??= Task.Run(() => ProcessOperationQueueAsync(watcherCTS.Token, hasSyncStatus)); - - var aWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => - { - var buff = new byte[4096]; - var rand = Guid.NewGuid(); - var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE; - - if (hasSyncStatus) - notifyFilters |= FILE_NOTIFY_CHANGE_ATTRIBUTES; - - var overlapped = new NativeOverlapped(); - using var eventHandle = PInvoke.CreateEvent(null, false, false, null); - overlapped.EventHandle = eventHandle.DangerousGetHandle(); - const uint INFINITE = 0xFFFFFFFF; - - while (x.Status != AsyncStatus.Canceled) - { - unsafe - { - fixed (byte* pBuff = buff) - { - ref var notifyInformation = ref Unsafe.As(ref buff[0]); - if (x.Status != AsyncStatus.Canceled) - { - PInvoke.ReadDirectoryChanges( - new Windows.Win32.Foundation.HANDLE(hWatchDir), - pBuff, - 4096, - false, - (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, - null, - &overlapped, - null); - } - else - { - break; - } - - Debug.WriteLine("waiting: {0}", rand); - if (x.Status == AsyncStatus.Canceled) - break; - - var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); - Debug.WriteLine("wait done: {0}", rand); - - uint offset = 0; - ref var notifyInfo = ref Unsafe.As(ref buff[offset]); - if (x.Status == AsyncStatus.Canceled) - break; - - do - { - notifyInfo = ref Unsafe.As(ref buff[offset]); - string? FileName = null; - unsafe - { - fixed (char* name = notifyInfo.FileName) - { - FileName = Path.Combine(path, new string(name, 0, (int)notifyInfo.FileNameLength / 2)); - } - } - - uint action = notifyInfo.Action; - - Debug.WriteLine("action: {0}", action); - - operationQueue.Enqueue((action, FileName)); - - offset += notifyInfo.NextEntryOffset; - } - while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); - - operationEvent.Set(); - - //ResetEvent(overlapped.hEvent); - Debug.WriteLine("Task running..."); - } - } - } - - operationQueue.Clear(); - - Debug.WriteLine("aWatcherAction done: {0}", rand); - }); - - watcherCTS.Token.Register(() => - { - if (aWatcherAction is not null) - { - aWatcherAction?.Cancel(); - - // Prevent duplicate execution of this block - aWatcherAction = null; - - Debug.WriteLine("watcher canceled"); - } - - CancelIoEx(hWatchDir, IntPtr.Zero); - CloseHandle(hWatchDir); - }); - } - - private void WatchForGitChanges() - { - // Enumeration is fire-and-forget; don't set up a watcher on a disposed view model. - if (isDisposed) - return; - - var hWatchDir = Win32PInvoke.CreateFileFromApp( - GitDirectory!, - 1, - 1 | 2 | 4, - IntPtr.Zero, - 3, - (uint)Win32PInvoke.File_Attributes.BackupSemantics | (uint)Win32PInvoke.File_Attributes.Overlapped, - IntPtr.Zero); - - if (hWatchDir.ToInt64() == -1) - return; - - gitProcessQueueAction ??= Task.Run(() => ProcessGitChangesQueueAsync(watcherCTS.Token)); - - var gitWatcherAction = Windows.System.Threading.ThreadPool.RunAsync((x) => - { - var buff = new byte[4096]; - var rand = Guid.NewGuid(); - var notifyFilters = FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_CREATION; - - var overlapped = new NativeOverlapped(); - using var eventHandle = PInvoke.CreateEvent(null, false, false, null); - overlapped.EventHandle = eventHandle.DangerousGetHandle(); - const uint INFINITE = 0xFFFFFFFF; - - while (x.Status != AsyncStatus.Canceled) - { - unsafe - { - fixed (byte* pBuff = buff) - { - ref var notifyInformation = ref Unsafe.As(ref buff[0]); - if (x.Status == AsyncStatus.Canceled) - break; - - PInvoke.ReadDirectoryChanges( - new Windows.Win32.Foundation.HANDLE(hWatchDir), - pBuff, - 4096, - true, - (Windows.Win32.Storage.FileSystem.FILE_NOTIFY_CHANGE)notifyFilters, - null, - &overlapped, - null); - - if (x.Status == AsyncStatus.Canceled) - break; - - var rc = WaitForSingleObjectEx(overlapped.EventHandle, INFINITE, true); - - uint offset = 0; - ref var notifyInfo = ref Unsafe.As(ref buff[offset]); - if (x.Status == AsyncStatus.Canceled) - break; - - do - { - notifyInfo = ref Unsafe.As(ref buff[offset]); - - uint action = notifyInfo.Action; - - gitChangesQueue.Enqueue(action); - - offset += notifyInfo.NextEntryOffset; - } - while (notifyInfo.NextEntryOffset != 0 && x.Status != AsyncStatus.Canceled); - - gitChangedEvent.Set(); - } - } - } - - gitChangesQueue.Clear(); - }); - - watcherCTS.Token.Register(() => - { - if (gitWatcherAction is not null) - { - gitWatcherAction?.Cancel(); - - // Prevent duplicate execution of this block - gitWatcherAction = null; - } - - CancelIoEx(hWatchDir, IntPtr.Zero); - CloseHandle(hWatchDir); - }); - } - - private async Task ProcessGitChangesQueueAsync(CancellationToken cancellationToken) - { - const int DELAY = 200; - var sampler = new IntervalSampler(100); - int changes = 0; - - try - { - while (!cancellationToken.IsCancellationRequested) - { - if (await gitChangedEvent.WaitAsync(DELAY, cancellationToken)) - { - gitChangedEvent.Reset(); - while (gitChangesQueue.TryDequeue(out var _)) - ++changes; - - if (changes != 0 && sampler.CheckNow()) - { - await dispatcherQueue.EnqueueOrInvokeAsync(() => GitDirectoryUpdated?.Invoke(null, null!)); - changes = 0; - } - } - } - } - catch { } - } - - private async Task ProcessOperationQueueAsync(CancellationToken cancellationToken, bool hasSyncStatus) - { - const uint FILE_ACTION_ADDED = 0x00000001; - const uint FILE_ACTION_REMOVED = 0x00000002; - const uint FILE_ACTION_MODIFIED = 0x00000003; - const uint FILE_ACTION_RENAMED_OLD_NAME = 0x00000004; - const uint FILE_ACTION_RENAMED_NEW_NAME = 0x00000005; - - const int UPDATE_BATCH_SIZE = 32; - var sampler = new IntervalSampler(200); - var updateQueue = new Queue(); - - var anyEdits = false; - ListedItem? lastItemAdded = null; - var rand = Guid.NewGuid(); - - // Call when any edits have occurred - async Task HandleChangesOccurredAsync() - { - await OrderFilesAndFoldersAsync(); - await ApplyFilesAndFoldersChangesAsync(); - - if (lastItemAdded is not null) - { - await RequestSelectionAsync([lastItemAdded]); - lastItemAdded = null; - } - - anyEdits = false; - } - - try - { - while (!cancellationToken.IsCancellationRequested) - { - if (await operationEvent.WaitAsync(200, cancellationToken)) - { - operationEvent.Reset(); - - while (operationQueue.TryDequeue(out var operation)) - { - if (cancellationToken.IsCancellationRequested) - break; - - try - { - switch (operation.Action) - { - case FILE_ACTION_ADDED: - case FILE_ACTION_RENAMED_NEW_NAME: - lastItemAdded = await AddFileOrFolderAsync(operation.FileName); - if (lastItemAdded is not null) - anyEdits = true; - break; - - case FILE_ACTION_MODIFIED: - if (!updateQueue.Contains(operation.FileName)) - updateQueue.Enqueue(operation.FileName); - break; - - case FILE_ACTION_REMOVED: - var itemRemoved = await RemoveFileOrFolderAsync(operation.FileName); - if (itemRemoved is not null) - anyEdits = true; - break; - - case FILE_ACTION_RENAMED_OLD_NAME: - // Pair OLD_NAME with the following NEW_NAME so the item can be updated - // in place and stay at its current position instead of jumping to a new - // sorted slot after a rename (issue #4214). Leaving anyEdits false skips - // OrderFilesAndFoldersAsync; PropertyChanged keeps the visible name in sync. - if (operationQueue.TryPeek(out var nextOp) && nextOp.Action == FILE_ACTION_RENAMED_NEW_NAME && - filesAndFolders.ToList().FirstOrDefault(x => x.GetRequiredPath().Equals(operation.FileName, StringComparison.OrdinalIgnoreCase)) is { } renamed) - { - operationQueue.TryDequeue(out _); - var newPath = nextOp.FileName; - await dispatcherQueue.EnqueueOrInvokeAsync(() => - { - renamed.ItemPath = newPath; - renamed.ItemNameRaw = Path.GetFileName(newPath); - if (renamed.PrimaryItemAttribute == StorageItemTypes.File) - renamed.FileExtension = Path.GetExtension(newPath); - }); - } - else - { - var itemRenamedOld = await RemoveFileOrFolderAsync(operation.FileName); - if (itemRenamedOld is not null) - anyEdits = true; - } - break; - } - } - catch (Exception ex) - { - App.Logger.LogWarning(ex, ex.Message); - } - - if (anyEdits && sampler.CheckNow()) - await HandleChangesOccurredAsync(); - } - - var itemsToUpdate = new List(); - for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) - itemsToUpdate.Add(updateQueue.Dequeue()); - - await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); - } - - if (updateQueue.Count > 0) - { - var itemsToUpdate = new List(); - for (var i = 0; i < UPDATE_BATCH_SIZE && updateQueue.Count > 0; i++) - itemsToUpdate.Add(updateQueue.Dequeue()); - - await UpdateFilesOrFoldersAsync(itemsToUpdate, hasSyncStatus); - } - - if (anyEdits && sampler.CheckNow()) - await HandleChangesOccurredAsync(); - } - } - catch - { - // Prevent disposed cancellation token - } - - Debug.WriteLine("aProcessQueueAction done: {0}", rand); - } - - public Task AddFileOrFolderFromShellFile(ShellFileItem item) - { - return - item.IsFolder ? - UniversalStorageEnumerator.AddFolderAsync(ShellStorageFolder.FromShellItem(item), currentStorageFolder, addFilesCTS.Token) : - UniversalStorageEnumerator.AddFileAsync(ShellStorageFile.FromShellItem(item), currentStorageFolder, addFilesCTS.Token); - } + // Lists the alternate data streams of a newly added item when they're shown; NTFS only + partial void AddAlternateStreamItems(ListedItem item); private async Task AddFileOrFolderAsync(ListedItem? item) { @@ -3222,66 +2407,12 @@ private async Task AddFileOrFolderAsync(ListedItem? item) { filesAndFolders.Add(item); - if (UserSettingsService.FoldersSettingsService.AreAlternateStreamsVisible) - { - // New file added, enumerate ADS - foreach (var ads in Win32Helper.GetAlternateStreams(item.GetRequiredPath())) - { - var adsItem = Win32StorageEnumerator.GetAlternateStream(ads, item); - filesAndFolders.Add(adsItem); - } - } + AddAlternateStreamItems(item); } enumFolderSemaphore.Release(); } - private async Task AddFileOrFolderAsync(string fileOrFolderPath) - { - FINDEX_INFO_LEVELS findInfoLevel = FINDEX_INFO_LEVELS.FindExInfoBasic; - var additionalFlags = FIND_FIRST_EX_CASE_SENSITIVE; - - IntPtr hFile = FindFirstFileExFromApp(fileOrFolderPath, findInfoLevel, out WIN32_FIND_DATA findData, FINDEX_SEARCH_OPS.FindExSearchNameMatch, IntPtr.Zero, - additionalFlags); - if (hFile.ToInt64() == -1) - { - // If we cannot find the file (probably since it doesn't exist anymore) simply exit without adding it - return null; - } - - FindClose(hFile); - - var isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) == FileAttributes.System; - var isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) == FileAttributes.Hidden; - var startWithDot = findData.cFileName.StartsWith('.'); - if ((isHidden && - (!UserSettingsService.FoldersSettingsService.ShowHiddenItems || - (isSystem && !UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles))) || - (startWithDot && !UserSettingsService.FoldersSettingsService.ShowDotFiles)) - { - // Do not add to file list if hidden/system attribute is set and system/hidden file are not to be shown - return null; - } - - var parentPath = Directory.GetParent(fileOrFolderPath)?.FullName - ?? throw new InvalidOperationException("The added item does not have a parent directory."); - - ListedItem? listedItem; - - // FILE_ATTRIBUTE_DIRECTORY - if ((findData.dwFileAttributes & 0x10) > 0) - listedItem = await Win32StorageEnumerator.GetFolder(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); - else - listedItem = await Win32StorageEnumerator.GetFile(findData, parentPath, IsValidGitDirectory, addFilesCTS.Token); - - if (listedItem is null) - return null; - - await AddFileOrFolderAsync(listedItem); - - return listedItem; - } - private async Task<(ListedItem Item, CloudDriveSyncStatus? SyncStatus, long? Size, DateTimeOffset Created, DateTimeOffset Modified)?> GetFileOrFolderUpdateInfoAsync(ListedItem item, bool hasSyncStatus) { IStorageItem? storageItem = null; @@ -3616,9 +2747,11 @@ public void Dispose() updateTagGroupCTS?.Cancel(); App.Logger.LogInformation($"ShellViewModel.Dispose: CurrentFolder={LogPathHelper.RedactPath(CurrentFolder?.ItemPath)}"); +#if WINDOWS StorageTrashBinService.Watcher.ItemAdded -= RecycleBinItemCreatedAsync; StorageTrashBinService.Watcher.ItemDeleted -= RecycleBinItemDeletedAsync; StorageTrashBinService.Watcher.RefreshRequested -= RecycleBinRefreshRequestedAsync; +#endif UserSettingsService.OnSettingChangedEvent -= UserSettingsService_OnSettingChangedEvent; fileTagsSettingsService.OnSettingImportedEvent -= FileTagsSettingsService_OnSettingUpdated; fileTagsSettingsService.OnTagsUpdated -= FileTagsSettingsService_OnSettingUpdated; diff --git a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.Windows.cs b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.Windows.cs new file mode 100644 index 000000000000..7e6304367e39 --- /dev/null +++ b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.Windows.cs @@ -0,0 +1,63 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Shared.Helpers; +using System.IO; +using Windows.Win32; +using Windows.Win32.Storage.FileSystem; + +namespace Files.App.ViewModels.UserControls +{ + public sealed partial class NavigationToolbarViewModel + { + /// + /// Enumerates subfolders using Win32 API, including hidden folders based on user settings. + /// Returns null if the path cannot be enumerated with Win32. + /// + private unsafe List<(string Name, string Path, bool IsHidden)>? GetSubfolders(string parentPath) + { + WIN32_FIND_DATAW findData = default; + using FindCloseSafeHandle hFile = PInvoke.FindFirstFileEx( + $"{parentPath}{Path.DirectorySeparatorChar}*.*", + FINDEX_INFO_LEVELS.FindExInfoBasic, + &findData, + FINDEX_SEARCH_OPS.FindExSearchNameMatch, + FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); + + if (hFile.IsInvalid) + return null; + + var showHidden = UserSettingsService.FoldersSettingsService.ShowHiddenItems; + var showSystem = UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles; + var showDot = UserSettingsService.FoldersSettingsService.ShowDotFiles; + var folders = new List<(string Name, string Path, bool IsHidden)>(); + + do + { + if (((FileAttributes)findData.dwFileAttributes & FileAttributes.Directory) == 0) + continue; + + string fileName = findData.cFileName.ToString(); + if (fileName is "." or "..") + continue; + + bool isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) != 0; + bool isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) != 0; + + if (isHidden && (!showHidden || (isSystem && !showSystem))) + continue; + + if (fileName.StartsWith('.') && !showDot) + continue; + + folders.Add((fileName, Path.Combine(parentPath, fileName), isHidden)); + } + while (PInvoke.FindNextFile(hFile, out findData)); + + var naturalComparer = NaturalStringComparer.GetForProcessor(); + folders.Sort((a, b) => naturalComparer.Compare(a.Name, b.Name)); + + return folders; + } + } +} diff --git a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs index 0117efa57cbc..eac3edf97ca3 100644 --- a/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/NavigationToolbarViewModel.cs @@ -14,8 +14,6 @@ using System.Windows.Input; using Windows.ApplicationModel.DataTransfer; using WinRT; -using Windows.Win32; -using Windows.Win32.Storage.FileSystem; namespace Files.App.ViewModels.UserControls { @@ -493,9 +491,7 @@ public async Task PathBoxItem_DragOver(object sender, DragEventArgs e) } // Copy be default when dragging from zip - else if (storageItems.Any(x => - x.Item is ZipStorageFile || - x.Item is ZipStorageFolder) || + else if (storageItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(pathBoxItem.Path)) { e.DragUIOverride.Caption = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), pathBoxItem.Title); @@ -712,7 +708,11 @@ public async Task SetPathBoxDropDownFlyoutAsync(MenuFlyout flyout, PathBoxItem p var path = pathItem.Path ?? throw new InvalidOperationException("The path box item does not have a path."); +#if WINDOWS var childFolders = OperatingSystem.IsWindows() ? GetSubfolders(path) : await GetSubfoldersAsync(path); +#else + var childFolders = await GetSubfoldersAsync(path); +#endif // Fall back to StorageFolder API for non-filesystem paths (e.g. FTP) if (childFolders is null) @@ -827,56 +827,6 @@ public async Task SetPathBoxDropDownFlyoutAsync(MenuFlyout flyout, PathBoxItem p } } - /// - /// Enumerates subfolders using Win32 API, including hidden folders based on user settings. - /// Returns null if the path cannot be enumerated with Win32. - /// - private unsafe List<(string Name, string Path, bool IsHidden)>? GetSubfolders(string parentPath) - { - WIN32_FIND_DATAW findData = default; - using FindCloseSafeHandle hFile = PInvoke.FindFirstFileEx( - $"{parentPath}{Path.DirectorySeparatorChar}*.*", - FINDEX_INFO_LEVELS.FindExInfoBasic, - &findData, - FINDEX_SEARCH_OPS.FindExSearchNameMatch, - FIND_FIRST_EX_FLAGS.FIND_FIRST_EX_LARGE_FETCH); - - if (hFile.IsInvalid) - return null; - - var showHidden = UserSettingsService.FoldersSettingsService.ShowHiddenItems; - var showSystem = UserSettingsService.FoldersSettingsService.ShowProtectedSystemFiles; - var showDot = UserSettingsService.FoldersSettingsService.ShowDotFiles; - var folders = new List<(string Name, string Path, bool IsHidden)>(); - - do - { - if (((FileAttributes)findData.dwFileAttributes & FileAttributes.Directory) == 0) - continue; - - string fileName = findData.cFileName.ToString(); - if (fileName is "." or "..") - continue; - - bool isHidden = ((FileAttributes)findData.dwFileAttributes & FileAttributes.Hidden) != 0; - bool isSystem = ((FileAttributes)findData.dwFileAttributes & FileAttributes.System) != 0; - - if (isHidden && (!showHidden || (isSystem && !showSystem))) - continue; - - if (fileName.StartsWith('.') && !showDot) - continue; - - folders.Add((fileName, Path.Combine(parentPath, fileName), isHidden)); - } - while (PInvoke.FindNextFile(hFile, out findData)); - - var naturalComparer = NaturalStringComparer.GetForProcessor(); - folders.Sort((a, b) => naturalComparer.Compare(a.Name, b.Name)); - - return folders; - } - private async Task LoadFlyoutItemIconAsync(MenuFlyoutItem flyoutItem, string path) { var imageSource = await NavigationHelpers.GetIconForPathAsync(path); @@ -1056,10 +1006,15 @@ public void SaveSearchQueryToList(string searchQuery) private static async Task LaunchApplicationFromPath(string currentInput, string workingDir) { +#if !WINDOWS + // LINUX-TODO(launch): running commands typed into the address bar; the input is reported as an invalid path + return await Task.FromResult(false); +#else var args = CommandLineParser.SplitArguments(currentInput); return await LaunchHelper.LaunchAppAsync( args.FirstOrDefault("").Trim('"'), string.Join(' ', args.Skip(1)), workingDir ); +#endif } public async Task PopulateOmnibarSuggestionsForPathMode() diff --git a/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs new file mode 100644 index 000000000000..1b105810bba6 --- /dev/null +++ b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.Windows.cs @@ -0,0 +1,70 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.ViewModels.Properties; +using SevenZip; +using System.IO; + +namespace Files.App.ViewModels.Previews +{ + public sealed partial class ArchivePreviewViewModel : BasePreviewModel + { + public ArchivePreviewViewModel(ListedItem item) + : base(item) + { + } + + public override async Task> LoadPreviewAndDetailsAsync() + { + var details = new List(); + + var zipResult = await FilesystemTasks.WrapNullable(async () => + { + var arch = new SevenZipExtractor(await PreviewFile.OpenStreamForReadAsync()); + + // Force load archive (1665013614u) + if (arch.ArchiveFileData is null) + { + arch.Dispose(); + return null; + } + + return arch; + }); + using var zipFile = zipResult.Result; + + if (zipFile is null) + { + // Loads the thumbnail preview + _ = await base.LoadPreviewAndDetailsAsync(); + + return details; + } + + //zipFile.IsStreamOwner = true; + + var folderCount = 0; + var fileCount = 0; + ulong totalSize = 0; + + foreach (ArchiveFileInfo entry in zipFile.ArchiveFileData) + { + if (!entry.IsDirectory) + { + ++fileCount; + totalSize += entry.Size; + } + } + + folderCount = (int)zipFile.FilesCount - fileCount; + + string propertyItemCount = Strings.DetailsArchiveItems.GetLocalizedFormatResource(zipFile.FilesCount, fileCount, folderCount); + details.Add(GetFileProperty("PropertyItemCount", propertyItemCount)); + details.Add(GetFileProperty("PropertyUncompressedSize", totalSize.ToLongSizeString())); + + // Loads the thumbnail preview + _ = await base.LoadPreviewAndDetailsAsync(); + return details; + } + } +} diff --git a/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs index 1b105810bba6..c234a6ddf7ee 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/ArchivePreviewViewModel.cs @@ -1,70 +1,27 @@ // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using Files.App.ViewModels.Properties; -using SevenZip; -using System.IO; +using Files.Platform.Abstractions.Archives; namespace Files.App.ViewModels.Previews { - public sealed partial class ArchivePreviewViewModel : BasePreviewModel + public sealed partial class ArchivePreviewViewModel(ListedItem item) : BasePreviewModel(item) { - public ArchivePreviewViewModel(ListedItem item) - : base(item) - { - } - public override async Task> LoadPreviewAndDetailsAsync() { - var details = new List(); - - var zipResult = await FilesystemTasks.WrapNullable(async () => - { - var arch = new SevenZipExtractor(await PreviewFile.OpenStreamForReadAsync()); - - // Force load archive (1665013614u) - if (arch.ArchiveFileData is null) - { - arch.Dispose(); - return null; - } - - return arch; - }); - using var zipFile = zipResult.Result; - - if (zipFile is null) + var listing = await Ioc.Default.GetRequiredService().ListPreviewAsync(item.ItemPath!); + var files = listing.Entries.Count(entry => !entry.IsDirectory); + var total = listing.Entries.Aggregate(0UL, (sum, entry) => checked(sum + (ulong)entry.Size)); + var details = new List { - // Loads the thumbnail preview - _ = await base.LoadPreviewAndDetailsAsync(); - - return details; - } - - //zipFile.IsStreamOwner = true; - - var folderCount = 0; - var fileCount = 0; - ulong totalSize = 0; - - foreach (ArchiveFileInfo entry in zipFile.ArchiveFileData) - { - if (!entry.IsDirectory) - { - ++fileCount; - totalSize += entry.Size; - } - } - - folderCount = (int)zipFile.FilesCount - fileCount; - - string propertyItemCount = Strings.DetailsArchiveItems.GetLocalizedFormatResource(zipFile.FilesCount, fileCount, folderCount); - details.Add(GetFileProperty("PropertyItemCount", propertyItemCount)); - details.Add(GetFileProperty("PropertyUncompressedSize", totalSize.ToLongSizeString())); - - // Loads the thumbnail preview + GetFileProperty("PropertyItemCount", Strings.DetailsArchiveItems.GetLocalizedFormatResource(listing.Entries.Count, files, listing.Entries.Count - files)), + GetFileProperty("PropertyUncompressedSize", total.ToLongSizeString()) + }; _ = await base.LoadPreviewAndDetailsAsync(); return details; } } } +#endif diff --git a/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.Windows.cs b/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.Windows.cs new file mode 100644 index 000000000000..67d4fd4c7776 --- /dev/null +++ b/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.Windows.cs @@ -0,0 +1,41 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.ViewModels.Properties; +using System.IO; + +namespace Files.App.ViewModels.Previews +{ + public abstract partial class BasePreviewModel + { + private async Task?> GetSystemFilePropertiesAsync() + { + if (Item.IsShortcut) + return null; + if (Item.ItemFile is null) + throw new InvalidOperationException("The preview item could not be opened as a file."); + + var list = await FileProperty.RetrieveAndInitializePropertiesAsync(Item.ItemFile, + Constants.ResourceFilePaths.PreviewPaneDetailsPropertiesJsonPath); + + var address = list.Find(x => x.ID is "address") + ?? throw new InvalidDataException("The preview property definition is missing the address field."); + var latitude = list.Find(x => x.Property is "System.GPS.LatitudeDecimal") + ?? throw new InvalidDataException("The preview property definition is missing the latitude field."); + var longitude = list.Find(x => x.Property is "System.GPS.LongitudeDecimal") + ?? throw new InvalidDataException("The preview property definition is missing the longitude field."); + address.Value = await LocationHelpers.GetAddressFromCoordinatesAsync( + (double?)latitude.Value, + (double?)longitude.Value); + + // Adds the value for the file tag + var fileTag = list.FirstOrDefault(x => x.ID is "filetag") + ?? throw new InvalidDataException("The preview property definition is missing the file tag field."); + fileTag.Value = Item.FileTagsUI is not null + ? string.Join(',', Item.FileTagsUI.Select(x => x.Name)) + : null; + + return list.Where(i => i.ValueText is not null).ToList(); + } + } +} diff --git a/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.cs index cdacce3ff691..668fb65a300d 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/BasePreviewModel.cs @@ -2,6 +2,10 @@ // Licensed under the MIT License. using System.IO; +using Files.Core.Storage.Contracts; +#if !WINDOWS +using OwlCore.Storage.System.IO; +#endif using Files.App.ViewModels.Properties; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media.Imaging; @@ -14,7 +18,11 @@ public abstract partial class BasePreviewModel : ObservableObject public ListedItem Item { get; } +#if WINDOWS protected BaseStorageFile PreviewFile +#else + protected IFile PreviewFile +#endif => Item.ItemFile ?? throw new InvalidOperationException("The preview file has not been loaded."); private BitmapImage? fileImage; @@ -42,8 +50,37 @@ public static Task LoadDetailsOnlyAsync(ListedItem item, List? det return temp.LoadAsync(); } +#if WINDOWS public static Task ReadFileAsTextAsync(BaseStorageFile file, int maxLength = 10 * 1024 * 1024) => file.ReadTextAsync(maxLength); +#else + protected static async Task ResolvePreviewFileAsync(ListedItem item, CancellationToken cancellationToken) + { + if (item.ItemFile is null) + { + var result = await Ioc.Default.GetRequiredService().TryGetAsync(item.GetRequiredPath(), cancellationToken); + item.ItemFile = result.Item as IFile; + } + + return item.ItemFile; + } + + protected static Task OpenPreviewReadAsync(IFile file, CancellationToken cancellationToken) + { + // Local streams must pin and stat the inode before opening it, including symlink targets. + if (file is SystemFile) + return Task.FromResult(Files.Platform.Linux.Previews.PreviewFile.OpenRead(file.Id, cancellationToken)); + + return file.OpenStreamAsync(FileAccess.Read, cancellationToken); + } + + protected async Task OpenPreviewReadAsync(CancellationToken cancellationToken) + { + var file = await ResolvePreviewFileAsync(Item, cancellationToken) + ?? throw new IOException("The preview file could not be resolved."); + return await OpenPreviewReadAsync(file, cancellationToken); + } +#endif /// /// Call this function when you are ready to load the preview and details. @@ -56,9 +93,13 @@ public virtual async Task LoadAsync() if (Item.ItemFile is null) { +#if WINDOWS var itemPath = Item.ItemPath!; var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(itemPath)); Item.ItemFile = await StorageFileExtensions.DangerousGetFileFromPathAsync(itemPath, rootItem.Result); +#else + await ResolvePreviewFileAsync(Item, LoadCancelledTokenSource.Token); +#endif } await Task.Run(async () => @@ -117,35 +158,7 @@ public virtual void PreviewControlBase_Unloaded(object? sender, RoutedEventArgs protected static FileProperty GetFileProperty(string nameResource, object? value) => new() { NameResource = nameResource, Value = value }; - private async Task?> GetSystemFilePropertiesAsync() - { - if (Item.IsShortcut) - return null; - if (Item.ItemFile is null) - throw new InvalidOperationException("The preview item could not be opened as a file."); - - var list = await FileProperty.RetrieveAndInitializePropertiesAsync(Item.ItemFile, - Constants.ResourceFilePaths.PreviewPaneDetailsPropertiesJsonPath); - - var address = list.Find(x => x.ID is "address") - ?? throw new InvalidDataException("The preview property definition is missing the address field."); - var latitude = list.Find(x => x.Property is "System.GPS.LatitudeDecimal") - ?? throw new InvalidDataException("The preview property definition is missing the latitude field."); - var longitude = list.Find(x => x.Property is "System.GPS.LongitudeDecimal") - ?? throw new InvalidDataException("The preview property definition is missing the longitude field."); - address.Value = await LocationHelpers.GetAddressFromCoordinatesAsync( - (double?)latitude.Value, - (double?)longitude.Value); - - // Adds the value for the file tag - var fileTag = list.FirstOrDefault(x => x.ID is "filetag") - ?? throw new InvalidDataException("The preview property definition is missing the file tag field."); - fileTag.Value = Item.FileTagsUI is not null - ? string.Join(',', Item.FileTagsUI.Select(x => x.Name)) - : null; - - return list.Where(i => i.ValueText is not null).ToList(); - } + #if DESKTOP private List GetBasicFileDetails() diff --git a/src/Files.App/ViewModels/UserControls/Previews/CodePreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/CodePreviewViewModel.Windows.cs similarity index 100% rename from src/Files.App/ViewModels/UserControls/Previews/CodePreviewViewModel.cs rename to src/Files.App/ViewModels/UserControls/Previews/CodePreviewViewModel.Windows.cs diff --git a/src/Files.App/ViewModels/UserControls/Previews/FolderPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/FolderPreviewViewModel.cs index f3823f216523..97555232dbed 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/FolderPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/FolderPreviewViewModel.cs @@ -4,6 +4,8 @@ using Files.App.ViewModels.Properties; using Microsoft.UI.Xaml.Media.Imaging; using System.IO; +using Files.Core.Storage.Contracts; +using OwlCore.Storage.System.IO; namespace Files.App.ViewModels.Previews { @@ -14,8 +16,6 @@ public sealed class FolderPreviewViewModel public BitmapImage? Thumbnail { get; set; } = new(); - private BaseStorageFolder? Folder { get; set; } - public FolderPreviewViewModel(ListedItem item) => Item = item; @@ -25,11 +25,15 @@ public Task LoadAsync() private async Task LoadPreviewAndDetailsAsync() { var itemPath = Item.ItemPath!; +#if WINDOWS var rootItem = await FilesystemTasks.WrapNullable(() => DriveHelpers.GetRootFromPathAsync(itemPath)); var folder = await StorageFileExtensions.DangerousGetFolderFromPathAsync(itemPath, rootItem.Result) ?? throw new InvalidOperationException("The preview folder could not be opened."); - - Folder = folder; +#else + var resolved = await Ioc.Default.GetRequiredService().TryGetAsync(itemPath); + if (resolved.Item is not IFolder) + throw new IOException("The preview folder could not be resolved."); +#endif var result = await FileThumbnailHelper.GetIconAsync( Item.ItemPath, @@ -46,20 +50,28 @@ private async Task LoadPreviewAndDetailsAsync() if (Item.IsDriveRoot || infoPaneViewModel?.SelectedDriveItem is not null) return; +#if WINDOWS var info = await folder.GetBasicPropertiesAsync(); + var dateModified = info.DateModified; + var dateCreated = info.DateCreated; +#else + var localInfo = resolved.Item is SystemFolder ? new DirectoryInfo(itemPath) : null; + var dateModified = localInfo is null ? Item.ItemDateModifiedReal : new DateTimeOffset(localInfo.LastWriteTime); + var dateCreated = localInfo is null ? Item.ItemDateCreatedReal : new DateTimeOffset(localInfo.CreationTime); +#endif Item.FileDetails = [ GetFileProperty("PropertyItemCount", infoPaneViewModel?.DirectoryItemCount), - GetFileProperty("PropertyDateModified", info.DateModified), - GetFileProperty("PropertyDateCreated", info.DateCreated), - GetFileProperty("PropertyParsingPath", folder.Path), + GetFileProperty("PropertyDateModified", dateModified), + GetFileProperty("PropertyDateCreated", dateCreated), + GetFileProperty("PropertyParsingPath", itemPath), ]; if (GitHelpers.IsRepositoryEx(Item.ItemPath, out var repoPath) && !string.IsNullOrEmpty(repoPath)) { - var gitDirectory = GitHelpers.GetGitRepositoryPath(folder.Path, Path.GetPathRoot(folder.Path)); + var gitDirectory = GitHelpers.GetGitRepositoryPath(itemPath, Path.GetPathRoot(itemPath)); var headName = (await GitHelpers.GetRepositoryHead(gitDirectory))?.Name ?? string.Empty; var repositoryName = GitHelpers.GetOriginRepositoryName(gitDirectory); diff --git a/src/Files.App/ViewModels/UserControls/Previews/ImagePreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/ImagePreviewViewModel.cs index ee94aa41ceeb..782cff2aaa3c 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/ImagePreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/ImagePreviewViewModel.cs @@ -36,7 +36,7 @@ public override async Task> LoadPreviewAndDetailsAsync() try { var path = Item.ItemPath!; - using var source = Files.Platform.Linux.Previews.PreviewFile.OpenRead(path, LoadCancelledTokenSource.Token); + using var source = await OpenPreviewReadAsync(LoadCancelledTokenSource.Token); if (string.Equals(Item.FileExtension, ".pdf", StringComparison.OrdinalIgnoreCase)) { // LINUX-TODO(preview): PDF shows the first page; multipage navigation needs a document renderer. diff --git a/src/Files.App/ViewModels/UserControls/Previews/MarkdownPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/MarkdownPreviewViewModel.Windows.cs similarity index 100% rename from src/Files.App/ViewModels/UserControls/Previews/MarkdownPreviewViewModel.cs rename to src/Files.App/ViewModels/UserControls/Previews/MarkdownPreviewViewModel.Windows.cs diff --git a/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs index 10f797be23e8..3a165cd26127 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs @@ -31,7 +31,7 @@ public override async Task> LoadPreviewAndDetailsAsync() { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(LoadCancelledTokenSource.Token); timeout.CancelAfter(TimeSpan.FromSeconds(5)); - using var source = Files.Platform.Linux.Previews.PreviewFile.OpenRead(Item.ItemPath!, timeout.Token); + using var source = await OpenPreviewReadAsync(timeout.Token); using var snapshot = await MediaPreviewInput.ReadAsync(source, Item.FileExtension?.ToLowerInvariant(), timeout.Token); using var input = new PreviewReadStream(snapshot, MediaPreviewInput.MaxBytes, timeout.Token); using var file = OpenMedia(new ReadOnlyMediaFile(Item.ItemPath!, input), Item.FileExtension?.ToLowerInvariant()); diff --git a/src/Files.App/ViewModels/UserControls/Previews/MediaPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/MediaPreviewViewModel.cs index 49ec032accf7..65886767dd1c 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/MediaPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/MediaPreviewViewModel.cs @@ -25,9 +25,14 @@ public void TogglePlayback() public override Task> LoadPreviewAndDetailsAsync() { +#if WINDOWS var itemFile = Item.ItemFile ?? throw new InvalidOperationException("The media preview item does not have a storage file."); Source = MediaSource.CreateFromStorageFile(itemFile); +#else + // LINUX-TODO(media): desktop shows metadata only (MediaMetadataPreviewViewModel); playback needs libvlc + throw new NotSupportedException(); +#endif return Task.FromResult(new List()); } diff --git a/src/Files.App/ViewModels/UserControls/Previews/PDFPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/PDFPreviewViewModel.cs index d92d1e8efced..7e7476874efe 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/PDFPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/PDFPreviewViewModel.cs @@ -35,6 +35,11 @@ public PDFPreviewViewModel(ListedItem item) public async override Task> LoadPreviewAndDetailsAsync() { +#if !WINDOWS + // LINUX-TODO(preview): desktop renders PDFs through ImagePreviewViewModel + await Task.CompletedTask; + throw new NotSupportedException(); +#else var fileStream = await PreviewFile.OpenReadAsync(); var pdf = await PdfDocument.LoadFromStreamAsync(fileStream); _ = TryLoadPagesAsync(pdf, fileStream); @@ -46,6 +51,7 @@ public async override Task> LoadPreviewAndDetailsAsync() }; return details; +#endif } public async Task TryLoadPagesAsync(PdfDocument pdf, IRandomAccessStream fileStream) diff --git a/src/Files.App/ViewModels/UserControls/Previews/RichTextPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/RichTextPreviewViewModel.cs index f3905b176d74..dbc207d826d2 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/RichTextPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/RichTextPreviewViewModel.cs @@ -14,9 +14,15 @@ public RichTextPreviewViewModel(ListedItem item) : base(item) { } public async override Task> LoadPreviewAndDetailsAsync() { +#if WINDOWS Stream = await PreviewFile.OpenReadAsync(); return []; +#else + // LINUX-TODO(preview): no RTF renderer on desktop + await Task.CompletedTask; + throw new NotSupportedException(); +#endif } } } diff --git a/src/Files.App/ViewModels/UserControls/Previews/ShellPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/ShellPreviewViewModel.Windows.cs similarity index 100% rename from src/Files.App/ViewModels/UserControls/Previews/ShellPreviewViewModel.cs rename to src/Files.App/ViewModels/UserControls/Previews/ShellPreviewViewModel.Windows.cs diff --git a/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.Windows.cs b/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.Windows.cs new file mode 100644 index 000000000000..4b1047c88d01 --- /dev/null +++ b/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.Windows.cs @@ -0,0 +1,77 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.UserControls.FilePreviews; +using Files.App.ViewModels.Properties; + +namespace Files.App.ViewModels.Previews +{ + public sealed partial class TextPreviewViewModel : BasePreviewModel + { + private string? textValue; + public string? TextValue + { + get => textValue; + private set => SetProperty(ref textValue, value); + } + + public TextPreviewViewModel(ListedItem item) + : base(item) + { + } + + public async override Task> LoadPreviewAndDetailsAsync() + { + var details = new List(); + + try + { + var text = TextValue ?? await ReadFileAsTextAsync(PreviewFile); + + details.Add(GetFileProperty("PropertyLineCount", text.Split('\n').Length)); + details.Add(GetFileProperty("PropertyWordCount", text.Split(new[] { ' ', '\n' }, StringSplitOptions.RemoveEmptyEntries).Length)); + + TextValue = text.Left(Constants.PreviewPane.TextCharacterLimit); + } + catch (Exception e) + { + Debug.WriteLine(e); + } + + return details; + } + + public static async Task TryLoadAsTextAsync(ListedItem item) + { + string? extension = item.FileExtension?.ToLowerInvariant(); + if (ExcludedExtensions(extension) || item.FileSizeBytes is 0 or > Constants.PreviewPane.TryLoadAsTextSizeLimit) + return null; + + try + { + item.ItemFile = await StorageFileExtensions.DangerousGetFileFromPathAsync(item.ItemPath!); + if (item.ItemFile is not { } itemFile) + return null; + + var text = await ReadFileAsTextAsync(itemFile); + bool isBinaryFile = text.Contains("\0\0\0\0", StringComparison.Ordinal); + + if (isBinaryFile) + return null; + + var model = new TextPreviewViewModel(item) { TextValue = text }; + await model.LoadAsync(); + + return new TextPreview(model); + } + catch + { + return null; + } + } + + private static bool ExcludedExtensions(string? extension) + => extension is ".iso"; + } +} + diff --git a/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.cs index f1389dedd806..c1b626fc69ba 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/TextPreviewViewModel.cs @@ -1,85 +1,7 @@ -#if WINDOWS -// Copyright (c) Files Community -// Licensed under the MIT License. - -using Files.App.UserControls.FilePreviews; -using Files.App.ViewModels.Properties; - -namespace Files.App.ViewModels.Previews -{ - public sealed partial class TextPreviewViewModel : BasePreviewModel - { - private string? textValue; - public string? TextValue - { - get => textValue; - private set => SetProperty(ref textValue, value); - } - - public TextPreviewViewModel(ListedItem item) - : base(item) - { - } - - public async override Task> LoadPreviewAndDetailsAsync() - { - var details = new List(); - - try - { - var text = TextValue ?? await ReadFileAsTextAsync(PreviewFile); - - details.Add(GetFileProperty("PropertyLineCount", text.Split('\n').Length)); - details.Add(GetFileProperty("PropertyWordCount", text.Split(new[] { ' ', '\n' }, StringSplitOptions.RemoveEmptyEntries).Length)); - - TextValue = text.Left(Constants.PreviewPane.TextCharacterLimit); - } - catch (Exception e) - { - Debug.WriteLine(e); - } - - return details; - } - - public static async Task TryLoadAsTextAsync(ListedItem item) - { - string? extension = item.FileExtension?.ToLowerInvariant(); - if (ExcludedExtensions(extension) || item.FileSizeBytes is 0 or > Constants.PreviewPane.TryLoadAsTextSizeLimit) - return null; - - try - { - item.ItemFile = await StorageFileExtensions.DangerousGetFileFromPathAsync(item.ItemPath!); - if (item.ItemFile is not { } itemFile) - return null; - - var text = await ReadFileAsTextAsync(itemFile); - bool isBinaryFile = text.Contains("\0\0\0\0", StringComparison.Ordinal); - - if (isBinaryFile) - return null; - - var model = new TextPreviewViewModel(item) { TextValue = text }; - await model.LoadAsync(); - - return new TextPreview(model); - } - catch - { - return null; - } - } - - private static bool ExcludedExtensions(string? extension) - => extension is ".iso"; - } -} - -#else // Copyright (c) Files Community // Licensed under the MIT License. +#if !WINDOWS using TextPreview = Files.App.UserControls.FilePreviews.DesktopTextPreview; using ColorCode; using Files.App.UserControls.FilePreviews; @@ -136,7 +58,7 @@ public async override Task> LoadPreviewAndDetailsAsync() { if (Item.FileSizeBytes is 0) return details; - using var stream = Files.Platform.Linux.Previews.PreviewFile.OpenRead(Item.ItemPath!, LoadCancelledTokenSource.Token); + using var stream = await OpenPreviewReadAsync(LoadCancelledTokenSource.Token); if (stream.Length == 0) return details; Apply(await PreviewTextReader.ReadAsync(stream, LoadCancelledTokenSource.Token)); @@ -201,11 +123,10 @@ public static async Task CreateFromTextAsync(ListedItem item, strin try { cancellationToken.ThrowIfCancellationRequested(); - item.ItemFile ??= await StorageFileExtensions.DangerousGetFileFromPathAsync(item.ItemPath!); - if (item.ItemFile is not { } itemFile) + if (await ResolvePreviewFileAsync(item, cancellationToken) is not { } itemFile) return null; - using var stream = Files.Platform.Linux.Previews.PreviewFile.OpenRead(item.ItemPath!, cancellationToken); + using var stream = await OpenPreviewReadAsync(itemFile, cancellationToken); if (stream.Length == 0) return null; var result = await PreviewTextReader.ReadAsync(stream, cancellationToken); @@ -240,5 +161,4 @@ private static bool ExcludedExtensions(string? extension) => extension is ".iso" or ".pdf"; } } - #endif diff --git a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs index 7eed10430462..3e6be428c906 100644 --- a/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/SidebarViewModel.cs @@ -1338,7 +1338,7 @@ private async Task HandleLocationItemDragOverAsync(LocationItem locationItem, It // Some applications such as Edge can't raise the drop event by the Move flag (#14008), so we set the Copy flag as well. operationType = DataPackageOperation.Move | DataPackageOperation.Copy; } - else if (storageItems.Any(x => x.Item is ZipStorageFile || x.Item is ZipStorageFolder) + else if (storageItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(locationItem.Path)) { captionText = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), locationItem.Text); @@ -1494,7 +1494,11 @@ private async Task HandleTagItemDroppedAsync(FileTagItem fileTagItem, ItemDroppe if (!filesTags.Contains(fileTag.Uid)) { filesTags = [.. filesTags, fileTag.Uid]; +#if WINDOWS var fileFRN = await FileTagsHelper.GetFileFRN(item.Item); +#else + var fileFRN = FileTagsHelper.GetFileFRN(path); +#endif dbInstance.SetTags(path, fileFRN, filesTags); await FileTagsHelper.WriteFileTagAsync(path, filesTags); pathToTags[path] = filesTags; diff --git a/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs index b794b05ea41c..1ebb46f187c9 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/NetworkLocationsWidgetViewModel.cs @@ -266,7 +266,11 @@ private Task ExecuteMapNetworkDriveCommand() private void ExecuteFormatDriveCommand(WidgetDriveCardItem? item) { +#if !WINDOWS + _ = DriveHelpers.OpenFormatDialogAsync(item?.Path); +#else Win32Helper.OpenFormatDriveDialog(item?.Path ?? string.Empty); +#endif } private void ExecuteOpenPropertiesCommand(WidgetDriveCardItem? item) diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.Windows.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.Windows.cs new file mode 100644 index 000000000000..69a744665ec2 --- /dev/null +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.Windows.cs @@ -0,0 +1,131 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.System.WinRT; +using Windows.Win32.UI.Shell; + +namespace Files.App.ViewModels.UserControls.Widgets +{ + public sealed partial class QuickAccessWidgetViewModel + { + private sealed record FolderSnapshot(IAgileReference ShellItem, string Text, string Path, bool IsPinned, string Tooltip); + + public Task RefreshWidgetAsync() + { + return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => + { + if (isDisposed) + return; + + var refreshVersion = ++_refreshVersion; + var homeFolder = HomePageContext.HomeFolder; + var folders = await STATask.RunPooled(() => + { + List result = []; + // The Shell enumerator and its metadata reads stay on this persistent STA. + foreach (IWindowsStorable folder in homeFolder.GetQuickAccessFolderAsync().ToBlockingEnumerable()) + { + using (folder) + { + folder.GetPropertyValue("System.Home.IsPinned", out var isPinned); + folder.TryGetShellTooltip(out var tooltip); + var text = folder.GetDisplayName(SIGDN.SIGDN_PARENTRELATIVEFORUI); + var path = folder.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); + var hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, folder.ThisPtr, out IAgileReference shellItem); + if (hr.ThrowIfFailedOnDebug().Failed) + continue; + + result.Add(new(shellItem, text, path, isPinned, tooltip ?? string.Empty)); + } + } + return result; + }, App.Logger); + + if (isDisposed || refreshVersion != _refreshVersion || folders is null) + return; + + foreach (var item in Items) + item.Dispose(); + + Items.Clear(); + + foreach (var folder in folders) + { + if (folder.ShellItem.Resolve(out IShellItem shellItem).ThrowIfFailedOnDebug().Failed) + continue; + + Items.Add(new WidgetFolderCardItem(new WindowsFolder(shellItem), folder.Text, folder.Path, folder.IsPinned, folder.Tooltip)); + } + }); + } + + + public override async Task ExecutePinToSidebarCommand(WidgetCardItem? item) + { + if (item is not WidgetFolderCardItem folderCardItem || folderCardItem.Path is null) + return; + + + + var lastPinnedItemIndex = Items.LastOrDefault(x => x.IsPinned) is { } lastPinnedItem ? Items.IndexOf(lastPinnedItem) : 0; + var currentPinnedItemIndex = Items.IndexOf(folderCardItem); + + if (currentPinnedItemIndex is -1) + return; + + HRESULT hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, ((IWindowsStorable)folderCardItem.Item).ThisPtr, out IAgileReference pAgileReference); + if (hr.ThrowIfFailedOnDebug().Failed) + return; + + // Pin to Quick Access on Windows + hr = await STATask.Run(() => + { + hr = pAgileReference.Resolve(out IShellItem pShellItem); + if (hr.ThrowIfFailedOnDebug().Failed) + return hr; + + using var windowsFile = new WindowsFile(pShellItem); + // NOTE: "pintohome" is an undocumented verb, which calls an undocumented COM class, windows.storage.dll!CPinToFrequentExecute : public IExecuteCommand, ... + return windowsFile.TryInvokeContextMenuVerb("pintohome"); + }, App.Logger); + + // The file watcher will update the collection automatically + } + + + public override async Task ExecuteUnpinFromSidebarCommand(WidgetCardItem? item) + { + if (item is not WidgetFolderCardItem folderCardItem || folderCardItem.Path is null) + return; + + + + HRESULT hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, ((IWindowsStorable)folderCardItem.Item).ThisPtr, out IAgileReference pAgileReference); + if (hr.ThrowIfFailedOnDebug().Failed) + return; + + // Unpin from Quick Access on Windows + hr = await STATask.Run(() => + { + hr = pAgileReference.Resolve(out IShellItem pShellItem); + if (hr.ThrowIfFailedOnDebug().Failed) + return hr; + + using var windowsFile = new WindowsFile(pShellItem); + + // NOTE: "unpinfromhome" is an undocumented verb, which calls an undocumented COM class, windows.storage.dll!CRemoveFromFrequentPlacesExecute : public IExecuteCommand, ... + // NOTE: "remove" is for some shell folders where the "unpinfromhome" may not work + return windowsFile.TryInvokeContextMenuVerbs(["unpinfromhome", "remove"], true); + }, App.Logger); + + if (hr.ThrowIfFailedOnDebug().Failed) + return; + + // The file watcher will update the collection automatically + } + + } +} diff --git a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs index 130cb242d225..bc8575e4a9fe 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/QuickAccessWidgetViewModel.cs @@ -7,11 +7,6 @@ using Windows.Storage; using Windows.System; using Windows.UI.Core; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.System.Com; -using Windows.Win32.System.WinRT; -using Windows.Win32.UI.Shell; namespace Files.App.ViewModels.UserControls.Widgets { @@ -33,12 +28,12 @@ public sealed partial class QuickAccessWidgetViewModel : BaseWidgetViewModel, IW // Fields - // TODO: Replace with IMutableFolder.GetWatcherAsync() once it gets implemented in IWindowsStorable + // TODO: Use a storable watcher when available. private readonly SystemIO.FileSystemWatcher? _quickAccessFolderWatcher; private bool isDisposed; private int _refreshVersion; - private sealed record FolderSnapshot(IAgileReference ShellItem, string Text, string Path, bool IsPinned, string Tooltip); + // Constructor @@ -54,6 +49,7 @@ public QuickAccessWidgetViewModel() App.QuickAccessManager.UpdateQuickAccessWidget += QuickAccessChanged; #endif +#if WINDOWS var automaticDestinationsPath = SystemIO.Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "Recent", "AutomaticDestinations"); if (!SystemIO.Directory.Exists(automaticDestinationsPath)) return; @@ -68,6 +64,7 @@ public QuickAccessWidgetViewModel() _quickAccessFolderWatcher.Changed += QuickAccessFolderWatcher_Changed; _quickAccessFolderWatcher.EnableRaisingEvents = true; +#endif } // Methods @@ -85,67 +82,27 @@ private async void QuickAccessChanged(object? sender, ModifyQuickAccessEventArgs await RefreshWidgetAsync(); } - public Task RefreshWidgetAsync() - { - return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => - { - Items.Clear(); - - foreach (var folder in await QuickAccessService.GetPinnedFoldersAsync()) - { - if (string.IsNullOrEmpty(folder.FilePath)) - continue; - - Items.Add(new WidgetFolderCardItem(folder.FilePath, folder.FileName ?? folder.FilePath, true, folder.FilePath)); - } - }); - } -#else public Task RefreshWidgetAsync() { return MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(async () => { if (isDisposed) return; - var refreshVersion = ++_refreshVersion; - var homeFolder = HomePageContext.HomeFolder; - var folders = await STATask.RunPooled(() => - { - List result = []; - // The Shell enumerator and its metadata reads stay on this persistent STA. - foreach (IWindowsStorable folder in homeFolder.GetQuickAccessFolderAsync().ToBlockingEnumerable()) - { - using (folder) - { - folder.GetPropertyValue("System.Home.IsPinned", out var isPinned); - folder.TryGetShellTooltip(out var tooltip); - var text = folder.GetDisplayName(SIGDN.SIGDN_PARENTRELATIVEFORUI); - var path = folder.GetDisplayName(SIGDN.SIGDN_DESKTOPABSOLUTEPARSING); - var hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, folder.ThisPtr, out IAgileReference shellItem); - if (hr.ThrowIfFailedOnDebug().Failed) - continue; - - result.Add(new(shellItem, text, path, isPinned, tooltip ?? string.Empty)); - } - } - return result; - }, App.Logger); - - if (isDisposed || refreshVersion != _refreshVersion || folders is null) + var folders = await QuickAccessService.GetPinnedFoldersAsync(); + if (isDisposed || refreshVersion != _refreshVersion) return; foreach (var item in Items) item.Dispose(); - Items.Clear(); foreach (var folder in folders) { - if (folder.ShellItem.Resolve(out IShellItem shellItem).ThrowIfFailedOnDebug().Failed) + if (string.IsNullOrEmpty(folder.FilePath)) continue; - Items.Add(new WidgetFolderCardItem(new WindowsFolder(shellItem), folder.Text, folder.Path, folder.IsPinned, folder.Tooltip)); + Items.Add(new WidgetFolderCardItem(folder.FilePath, folder.FileName ?? folder.FilePath, true, folder.FilePath)); } }); } @@ -279,74 +236,25 @@ private async void Items_CollectionChanged(object? sender, NotifyCollectionChang // Command methods +#if !WINDOWS public override async Task ExecutePinToSidebarCommand(WidgetCardItem? item) { if (item is not WidgetFolderCardItem folderCardItem || folderCardItem.Path is null) return; -#if !WINDOWS await QuickAccessService.PinToSidebarAsync(folderCardItem.Path); - return; -#endif - - var lastPinnedItemIndex = Items.LastOrDefault(x => x.IsPinned) is { } lastPinnedItem ? Items.IndexOf(lastPinnedItem) : 0; - var currentPinnedItemIndex = Items.IndexOf(folderCardItem); - - if (currentPinnedItemIndex is -1) - return; - - HRESULT hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, folderCardItem.Item.ThisPtr, out IAgileReference pAgileReference); - if (hr.ThrowIfFailedOnDebug().Failed) - return; - - // Pin to Quick Access on Windows - hr = await STATask.Run(() => - { - hr = pAgileReference.Resolve(out IShellItem pShellItem); - if (hr.ThrowIfFailedOnDebug().Failed) - return hr; - - using var windowsFile = new WindowsFile(pShellItem); - // NOTE: "pintohome" is an undocumented verb, which calls an undocumented COM class, windows.storage.dll!CPinToFrequentExecute : public IExecuteCommand, ... - return windowsFile.TryInvokeContextMenuVerb("pintohome"); - }, App.Logger); - - // The file watcher will update the collection automatically } +#endif +#if !WINDOWS public override async Task ExecuteUnpinFromSidebarCommand(WidgetCardItem? item) { if (item is not WidgetFolderCardItem folderCardItem || folderCardItem.Path is null) return; -#if !WINDOWS await QuickAccessService.UnpinFromSidebarAsync(folderCardItem.Path); - return; -#endif - - HRESULT hr = PInvoke.RoGetAgileReference(AgileReferenceOptions.AGILEREFERENCE_DEFAULT, typeof(IShellItem).GUID, folderCardItem.Item.ThisPtr, out IAgileReference pAgileReference); - if (hr.ThrowIfFailedOnDebug().Failed) - return; - - // Unpin from Quick Access on Windows - hr = await STATask.Run(() => - { - hr = pAgileReference.Resolve(out IShellItem pShellItem); - if (hr.ThrowIfFailedOnDebug().Failed) - return hr; - - using var windowsFile = new WindowsFile(pShellItem); - - // NOTE: "unpinfromhome" is an undocumented verb, which calls an undocumented COM class, windows.storage.dll!CRemoveFromFrequentPlacesExecute : public IExecuteCommand, ... - // NOTE: "remove" is for some shell folders where the "unpinfromhome" may not work - return windowsFile.TryInvokeContextMenuVerbs(["unpinfromhome", "remove"], true); - }, App.Logger); - - if (hr.ThrowIfFailedOnDebug().Failed) - return; - - // The file watcher will update the collection automatically } +#endif private void ExecuteOpenPropertiesCommand(WidgetFolderCardItem? item) { @@ -373,6 +281,7 @@ async void FlyoutClosed(object? sender, object args) ItemType = Strings.Folder.GetLocalizedResource(), }; +#if WINDOWS if (!string.Equals(itemPath, Constants.UserEnvironmentPaths.RecycleBinPath, StringComparison.OrdinalIgnoreCase)) { BaseStorageFolder? matchingStorageFolder = (await shellViewModel.GetFolderFromPathAsync(itemPath)).Result; @@ -382,6 +291,7 @@ async void FlyoutClosed(object? sender, object args) listedItem.SyncStatusUI = CloudDriveSyncStatusUI.FromCloudDriveSyncStatus(syncStatus); } } +#endif FilePropertiesHelpers.OpenPropertiesWindow(listedItem, shellPage); } diff --git a/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs b/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs index 8e5331799f1d..931d90408085 100644 --- a/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Widgets/RecentFilesWidgetViewModel.cs @@ -364,10 +364,12 @@ async void FlyoutClosed(object? sender, object args) } else { +#if WINDOWS var listedItem = await UniversalStorageEnumerator.AddFileAsync(file, null, default); var shellPage = ContentPageContext.ShellPage ?? throw new InvalidOperationException("The shell page is not available for opening properties."); FilePropertiesHelpers.OpenPropertiesWindow(listedItem!, shellPage); +#endif } } diff --git a/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs b/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs index f4ed7057f461..403258b90fd0 100644 --- a/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs @@ -421,6 +421,66 @@ protected virtual async void RenameTextBox_LostFocus(object sender, RoutedEventA // Methods #if DESKTOP + /// + /// Arrow keys follow the rows and columns of the virtualizing wrap grid (Uno's list controls would step by one item). + /// + protected bool TryHandleWrapGridArrowKey(KeyRoutedEventArgs e) + { + if (e.Key is not (VirtualKey.Up or VirtualKey.Down or VirtualKey.Left or VirtualKey.Right) || + ListViewBase.ItemsPanelRoot is not Files.App.UnoVirtualization.VirtualizingWrapGrid panel || + ListViewBase.Items.Count == 0 || + FocusManager.GetFocusedElement(MainWindow.Instance.Content.XamlRoot) is TextBox || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down)) + return false; + + var perLine = panel.ItemsPerRow; + var withinLine = panel.PanelScrollOrientation == Orientation.Vertical ? 1 : perLine; + var acrossLines = panel.PanelScrollOrientation == Orientation.Vertical ? perLine : 1; + var step = e.Key switch + { + VirtualKey.Left => -withinLine, + VirtualKey.Right => withinLine, + VirtualKey.Up => -acrossLines, + _ => acrossLines, + }; + + // Horizontal-scroll layouts fill columns top to bottom, so Up/Down move within a column and Left/Right across columns + if (panel.PanelScrollOrientation == Orientation.Horizontal) + { + step = e.Key switch + { + VirtualKey.Up => -1, + VirtualKey.Down => 1, + VirtualKey.Left => -perLine, + _ => perLine, + }; + } + + var count = ListViewBase.Items.Count; + var current = ListViewBase.SelectedIndex; + if (current < 0) + current = 0; + else + current += step; + + if (current < 0 || current >= count) + { + e.Handled = true; + return true; + } + + if (ListViewBase.Items[current] is ListedItem item) + { + ItemManipulationModel.SetSelectedItem(item); + ItemManipulationModel.ScrollIntoView(item); + ItemManipulationModel.FocusSelectedItems(); + } + + e.Handled = true; + return true; + } + /// /// Home, End, Page Up and Page Down don't move the selection in Uno's list controls. /// diff --git a/src/Files.App/Views/Layouts/BaseLayoutPage.cs b/src/Files.App/Views/Layouts/BaseLayoutPage.cs index 7b9e768cb2b4..96f7b4c68ef9 100644 --- a/src/Files.App/Views/Layouts/BaseLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseLayoutPage.cs @@ -181,9 +181,15 @@ public CollectionViewSource CollectionViewSource if (collectionViewSource.View is not null) collectionViewSource.View.VectorChanged += View_VectorChanged; + + OnCollectionViewSourceChanged(); } } + protected virtual void OnCollectionViewSourceChanged() + { + } + private bool isItemSelected = false; public bool IsItemSelected { @@ -1071,6 +1077,7 @@ protected virtual void FileList_DragItemsStarting(object sender, DragItemsStarti var sortedItems = SortingHelper.OrderFileList(itemList, folderSettings.DirectorySortOption, folderSettings.DirectorySortDirection, folderSettings.SortDirectoriesAlongsideFiles, folderSettings.SortFilesFirst).ToList(); var orderedItems = sortedItems.SkipWhile(x => x != firstItem).Concat(sortedItems.TakeWhile(x => x != firstItem)).ToList(); +#if WINDOWS var shellItemList = SafetyExtensions.IgnoreExceptions(() => orderedItems.Select(item => new ShellItem(item.GetRequiredPath())).ToArray()); try { @@ -1086,8 +1093,10 @@ protected virtual void FileList_DragItemsStarting(object sender, DragItemsStarti else { // Only support IStorageItem capable paths +#if WINDOWS var storageItemList = orderedItems.Where(x => !(x.IsHiddenItem && x.IsLinkItem && x.IsRecycleBinItem && x.IsShortcut)).Select(x => VirtualStorageItem.FromListedItem(x)).ToArray(); e.Data.SetStorageItems(storageItemList, false); +#endif } } finally @@ -1098,6 +1107,7 @@ protected virtual void FileList_DragItemsStarting(object sender, DragItemsStarti item.Dispose(); } } +#endif // Set can window to front (#13255) MainWindow.Instance.SetCanWindowToFront(false); @@ -1185,7 +1195,7 @@ private async void Item_DragOver(object sender, DragEventArgs e) // Some applications such as Edge can't raise the drop event by the Move flag (#14008), so we set the Copy flag as well. e.AcceptedOperation = DataPackageOperation.Move | DataPackageOperation.Copy; } - else if (draggedItems.Any(x => x.Item is ZipStorageFile || x.Item is ZipStorageFolder) + else if (draggedItems.Any(x => x.IsArchiveMember()) || ZipStorageFolder.IsZipPath(item.ItemPath!)) { e.DragUIOverride.Caption = string.Format(Strings.CopyToFolderCaptionText.GetLocalizedResource(), item.Name); @@ -1474,8 +1484,10 @@ private void FileListItem_Loaded(object sender, RoutedEventArgs e) protected internal void FileListItem_PointerPressed(object sender, PointerRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif if (sender is not SelectorItem selectorItem) return; @@ -1570,23 +1582,29 @@ protected internal void FileListItem_PointerExited(object sender, PointerRoutedE protected void FileListItem_Tapped(object sender, TappedRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif } protected void FileListItem_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif } [DynamicWindowsRuntimeCast(typeof(SelectorItem))] protected void FileListItem_RightTapped(object sender, RightTappedRoutedEventArgs e) { // Set can window to front and bring the window to the front if necessary (#13255) +#if WINDOWS if ((!itemDragging) && MainWindow.Instance.SetCanWindowToFront(true)) Win32Helper.BringToForegroundEx(new(MainWindow.Instance.WindowHandle)); +#endif var rightClickedItem = GetItemFromElement(sender); @@ -1760,7 +1778,11 @@ public void CheckRenameDoubleClick(object? clickedItem) tapDebounceTimer?.Stop(); } }, +#if WINDOWS TimeSpan.FromMilliseconds(OperatingSystem.IsWindows() ? PInvoke.GetDoubleClickTime() : 500)); +#else + TimeSpan.FromMilliseconds(500)); +#endif } else { diff --git a/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs b/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs index 83a951ed6062..ed105643158d 100644 --- a/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs +++ b/src/Files.App/Views/Layouts/GridLayoutPage.xaml.cs @@ -195,6 +195,21 @@ public GridLayoutPage() : base() protected override void ItemManipulationModel_ScrollIntoViewInvoked(object? sender, ListedItem e) { FileList.ScrollIntoView(e); +#if !WINDOWS + // The virtualizing panel only knows a virtual strip per item; make sure the whole cell is in view + if (FileList.ItemsPanelRoot is Files.App.UnoVirtualization.VirtualizingWrapGrid panel) + { + var index = FileList.Items.IndexOf(e); + var request = ++_ensureVisibleRequest; + + // Only the latest request may scroll; earlier ones would drag the view back after a faster jump (e.g. End after Page Down) + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + if (request == _ensureVisibleRequest) + panel.EnsureItemVisible(index); + }); + } +#endif } protected override void ItemManipulationModel_ScrollToTopInvoked(object? sender, EventArgs e) @@ -342,6 +357,57 @@ private void FolderSettings_LayoutModeChangeRequested(object? sender, LayoutMode } } +#if !WINDOWS + private (bool Virtualized, FolderLayoutModes? Mode)? _appliedPanelKey; + private int _ensureVisibleRequest; + private static bool s_loggedPanelFallback; + + /// + /// Uses the virtualizing wrap panel (Uno Skia has no ItemsWrapGrid, and the style's WrapPanel would realize every item) unless + /// the list is grouped (the panel has no group headers) or Uno's internals no longer match what the panel was built against. + /// + protected override void OnCollectionViewSourceChanged() => UpdateItemsPanel(); + + private void UpdateItemsPanel() + { + if (FolderSettings is not { } folderSettings) + return; + + // The folder setting decides, not the CollectionViewSource: that is still a placeholder when this first runs, and swapping the panel after + // the items are assigned would first realize every item in the style's non-virtualizing panel + var virtualize = folderSettings.DirectoryGroupOption == GroupOption.None; + if (virtualize && !Files.App.UnoVirtualization.VirtualizingWrapGrid.IsSupported(out var reason)) + { + virtualize = false; + if (!s_loggedPanelFallback) + { + s_loggedPanelFallback = true; + Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(App.Logger, "Falling back to the non-virtualizing wrap panel for Grid and Cards layouts: {Reason}", reason); + } + } + + var key = (virtualize, (FolderLayoutModes?)folderSettings.LayoutMode); + if (_appliedPanelKey == key) + return; + + _appliedPanelKey = key; + if (!virtualize) + { + FileList.ClearValue(ItemsControl.ItemsPanelProperty); + return; + } + + var wrapOrientation = folderSettings.LayoutMode == FolderLayoutModes.ListView ? Orientation.Vertical : Orientation.Horizontal; + FileList.ItemsPanel = new ItemsPanelTemplate(() => new Files.App.UnoVirtualization.VirtualizingWrapGrid + { + Orientation = wrapOrientation, + ProvisionalCellSize = folderSettings.LayoutMode == FolderLayoutModes.GridView + ? new Windows.Foundation.Size(ItemWidthGridView, ItemWidthGridView + 48) + : new Windows.Foundation.Size(240, 96), + }); + } +#endif + [DynamicWindowsRuntimeCast(typeof(Style))] private void SetItemTemplate() { @@ -367,6 +433,11 @@ private void SetItemTemplate() var oldSource = FileList.ItemsSource; FileList.ItemsSource = null; FileList.Style = newFileListStyle; +#if !WINDOWS + _appliedPanelKey = null; + FileList.ClearValue(ItemsControl.ItemsPanelProperty); + UpdateItemsPanel(); +#endif FileList.ItemsSource = oldSource; } @@ -610,7 +681,7 @@ protected override async void FileList_PreviewKeyDown(object sender, KeyRoutedEv return; #if DESKTOP - if (TryHandleListJumpKey(e)) + if (TryHandleListJumpKey(e) || TryHandleWrapGridArrowKey(e)) return; #endif diff --git a/src/Files.App/Views/MainPage.xaml.cs b/src/Files.App/Views/MainPage.xaml.cs index a00fdcbb1c75..8c6c791dea9e 100644 --- a/src/Files.App/Views/MainPage.xaml.cs +++ b/src/Files.App/Views/MainPage.xaml.cs @@ -93,10 +93,12 @@ public MainPage() ApplySidebarWidthState(); +#if WINDOWS // Realize the navigation controls before the first measure pass. FindName(nameof(InnerNavigationToolbar)); FindName(nameof(TabControl)); FindName(nameof(NavToolbar)); +#endif } private void NumberedTabKeyboardAccelerator_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs e) @@ -359,6 +361,12 @@ protected override void OnLostFocus(RoutedEventArgs e) private void Page_Loaded(object sender, RoutedEventArgs e) { ViewModel.OnPageLoaded(); +#if !WINDOWS + // LINUX-TODO(toolbar): realizing x:Load controls in the constructor leaves their x:Bind bindings uninitialized on Uno (toolbar row never shows), so defer to Loaded. + FindName(nameof(InnerNavigationToolbar)); + FindName(nameof(TabControl)); + FindName(nameof(NavToolbar)); +#endif MainWindow.Instance.AppWindow.Changed += (_, _) => MainWindow.Instance.RaiseSetTitleBarDragRegion(SetTitleBarDragRegion); diff --git a/src/Files.App/Views/Properties/ShortcutPage.xaml.cs b/src/Files.App/Views/Properties/ShortcutPage.xaml.cs index 3dbcea02214b..b7abd72b569c 100644 --- a/src/Files.App/Views/Properties/ShortcutPage.xaml.cs +++ b/src/Files.App/Views/Properties/ShortcutPage.xaml.cs @@ -40,7 +40,7 @@ await MainWindow.Instance.DispatcherQueue.EnqueueOrInvokeAsync(() => ViewModel.ShortcutItemArguments, ViewModel.ShortcutItemWorkingDir, ViewModel.RunAsAdmin, - (Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD)(int)ViewModel.ShowWindowCommand) + ViewModel.ShowWindowCommand) ); return true; diff --git a/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs b/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs index a57ef4033a70..4d7b1497f53a 100644 --- a/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs +++ b/src/Files.Platform.Abstractions/Archives/ArchiveTypes.cs @@ -77,7 +77,8 @@ public sealed record ArchiveEntryInfo( /// The entries. /// Whether at least one entry is encrypted. /// Whether the archive is solid. - public sealed record ArchiveListing(IReadOnlyList Entries, bool IsEncrypted, bool IsSolid); + /// Whether listing stopped at an entry, name, byte or time cap, so is partial (too large to list). + public sealed record ArchiveListing(IReadOnlyList Entries, bool IsEncrypted, bool IsSolid, bool IsTruncated = false); /// /// Zip bomb guards. A value of zero or less disables that guard. @@ -110,7 +111,7 @@ public sealed record ArchiveLimitViolation(string Limit, double Actual, double A /// /// Describes an item of the extraction that already exists at the destination. /// - /// The entry path relative to the destination. + /// The entry path relative to the destination, escaped for display. Use DestinationPath for file operations. /// The existing path. /// Whether the incoming item is a folder. public sealed record ArchiveConflict(string EntryPath, string DestinationPath, bool IsDirectory); diff --git a/src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs b/src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs new file mode 100644 index 000000000000..9d8b3bde9fc6 --- /dev/null +++ b/src/Files.Platform.Abstractions/Archives/IArchivePasswordPrompt.cs @@ -0,0 +1,15 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Abstractions.Archives +{ + /// Requests an archive password on demand. Null cancels the operation; passwords are never persisted. + public interface IArchivePasswordPrompt + { + /// Requests a password, indicating whether a previous attempt failed. + Task RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default); + } +} diff --git a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs index 369d4a8d56e5..4adf2766f308 100644 --- a/src/Files.Platform.Abstractions/Archives/IArchiveService.cs +++ b/src/Files.Platform.Abstractions/Archives/IArchiveService.cs @@ -1,8 +1,11 @@ // Copyright (c) Files Community // Licensed under the MIT License. +using System; using System.Collections.Generic; +using System.Linq; using System.Text; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -39,6 +42,25 @@ public interface IArchiveService /// Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default); + /// Whether the archive has more than one top-level entry, reading only the first headers. Archives too large to tell count as having several. + async Task HasMultipleTopLevelEntriesAsync(string archivePath, string? password = null, CancellationToken cancellationToken = default) + { + var listing = await ListAsync(archivePath, password, null, cancellationToken).ConfigureAwait(false); + return listing.IsTruncated || listing.Entries + .Select(e => e.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(s => s is not ".")) + .Where(s => s is not null).Distinct().Skip(1).Any(); + } + + /// Lists headers for in-app browsing, applying the fixed browsing size, ratio and entry limits. Use for encryption and structure checks. + Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + => ListAsync(archivePath, password, fileNameEncoding, cancellationToken); + + /// Opens one safe entry as a caller-owned, read-only stream with fixed browsing size and ratio limits. + Task OpenEntryAsync(string archivePath, string entryPath, string? password = null, CancellationToken cancellationToken = default); + + /// Whether existing archives can be modified through this service. + bool CanWriteEntries => false; + /// Lists untrusted preview headers with bounded input, expanded bytes and entry count, without extracting files. Task ListPreviewAsync(string archivePath, CancellationToken cancellationToken = default); diff --git a/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs b/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs index 0fb02f3cba4b..8ba0d12a06a4 100644 --- a/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs +++ b/src/Files.Platform.Linux/Archives/ArchivePathValidator.cs @@ -7,6 +7,7 @@ using System.Text; using Files.Platform.Abstractions.Archives; using Files.Platform.Linux.FileOperations; +using Files.Platform.Linux.Previews; namespace Files.Platform.Linux.Archives { @@ -27,6 +28,9 @@ public static string NormalizeEntryName(string? entryName) if (string.IsNullOrEmpty(entryName)) return string.Empty; + if (Encoding.UTF8.GetByteCount(entryName) > 4096) + throw new ArchiveSecurityException("Archive entry path is too long."); + if (entryName.Contains('\0')) throw new ArchiveSecurityException($"Archive entry name contains a NUL character: '{Printable(entryName)}'."); @@ -51,6 +55,9 @@ public static string NormalizeEntryName(string? entryName) if (Encoding.UTF8.GetByteCount(segment) > MaxSegmentBytes) throw new ArchiveSecurityException($"Archive entry has a name that is too long: '{Printable(entryName)}'."); + if (segments.Count >= 128) + throw new ArchiveSecurityException("Archive entry path is too deep."); + segments.Add(segment); } @@ -122,13 +129,6 @@ internal static void EnsureNoLinkInPath(string root, string fullPath) } } - private static string Printable(string value) - { - var builder = new StringBuilder(Math.Min(value.Length, 200)); - foreach (var c in value.Length > 200 ? value[..200] : value) - builder.Append(char.IsControl(c) ? '?' : c); - - return builder.ToString(); - } + internal static string Printable(string value) => PreviewEntryName.Sanitize(value, 200); } } diff --git a/src/Files.Platform.Linux/Archives/ArchiveSource.cs b/src/Files.Platform.Linux/Archives/ArchiveSource.cs index 530590813d43..e02a0c636c4a 100644 --- a/src/Files.Platform.Linux/Archives/ArchiveSource.cs +++ b/src/Files.Platform.Linux/Archives/ArchiveSource.cs @@ -4,8 +4,11 @@ using System; using System.Collections.Generic; using System.IO; +using System.Formats.Tar; using System.IO.Compression; using System.Text; +using System.Threading; +using Files.Platform.Linux.Previews; using Files.Platform.Abstractions.Archives; using SharpCompress.Archives; using SharpCompress.Common; @@ -29,13 +32,13 @@ internal abstract class ArchiveSource : IDisposable public abstract bool IsSolid { get; } /// Enumerates the entries. Each call starts over. The stream factory is only valid while the enumerator is on that entry. - public abstract IEnumerable<(EntryData Entry, Func Open)> Entries(); + public abstract IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false); public virtual void Dispose() { } - public static ArchiveSource Open(string path, string? password, Encoding? encoding) + public static ArchiveSource Open(string path, string? password, Encoding? encoding, long? maxBytes = null, CancellationToken cancellationToken = default) { var options = new ReaderOptions { Password = string.IsNullOrEmpty(password) ? null : password }; if (encoding is not null) @@ -44,13 +47,21 @@ public static ArchiveSource Open(string path, string? password, Encoding? encodi var name = Path.GetFileName(path).ToLowerInvariant(); var codec = TarCodec(name); if (codec is not null) - return new TarSource(path, codec.Value, options); + return new TarSource(path, codec.Value, options, maxBytes, cancellationToken); var single = SingleFileCodec(name); if (single is not null) - return new SingleFileSource(path, single.Value); + return new SingleFileSource(path, single.Value, maxBytes, cancellationToken); - return new ZipLikeSource(ArchiveFactory.Open(path, options)); + var input = OpenInput(path, maxBytes, cancellationToken); + try { return new ZipLikeSource(ArchiveFactory.Open(input, options), input); } + catch { input.Dispose(); throw; } + } + + private static Stream OpenInput(string path, long? maxBytes, CancellationToken cancellationToken) + { + var file = PreviewFile.OpenRead(path, cancellationToken); + return maxBytes is { } limit ? new PreviewReadStream(file, limit, cancellationToken) : file; } internal static Codec? TarCodec(string lowerName) @@ -69,7 +80,7 @@ public static ArchiveSource Open(string path, string? password, Encoding? encodi return null; } - private static Codec? SingleFileCodec(string lowerName) + internal static Codec? SingleFileCodec(string lowerName) { if (lowerName.EndsWith(".gz", StringComparison.Ordinal)) return Codec.GZip; @@ -105,17 +116,19 @@ internal enum Codec private sealed class ZipLikeSource : ArchiveSource { private readonly IArchive archive; + private readonly Stream input; - public ZipLikeSource(IArchive archive) + public ZipLikeSource(IArchive archive, Stream input) { this.archive = archive; + this.input = input; } public override bool IsSolid => archive.IsSolid || archive.Type == ArchiveType.SevenZip; - public override IEnumerable<(EntryData Entry, Func Open)> Entries() + public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { - if (IsSolid) + if (IsSolid && !headersOnly) { using var reader = archive.ExtractAllEntries(); while (reader.MoveToNextEntry()) @@ -134,33 +147,56 @@ public ZipLikeSource(IArchive archive) } } - public override void Dispose() => archive.Dispose(); + public override void Dispose() + { + try { archive.Dispose(); } + finally { input.Dispose(); } + } } private sealed class TarSource : ArchiveSource { + private const int MaxSkippedTarEntries = 10_000; + private readonly string path; private readonly Codec codec; private readonly ReaderOptions options; + private readonly long? maxBytes; + private readonly CancellationToken cancellationToken; - public TarSource(string path, Codec codec, ReaderOptions options) + public TarSource(string path, Codec codec, ReaderOptions options, long? maxBytes, CancellationToken cancellationToken) { this.path = path; this.codec = codec; this.options = options; + this.maxBytes = maxBytes; + this.cancellationToken = cancellationToken; } public override bool IsSolid => true; - public override IEnumerable<(EntryData Entry, Func Open)> Entries() + public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { - using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - using var decompressed = Decompress(file, codec); - using var reader = ReaderFactory.Open(decompressed, options); - while (reader.MoveToNextEntry()) + using var file = OpenInput(path, maxBytes, cancellationToken); + using var codecStream = Decompress(file, codec); + using var decompressed = maxBytes is { } limit ? new PreviewReadStream(codecStream, limit, cancellationToken) : codecStream; + using var reader = new TarReader(decompressed); + TarEntry? entry; + var skipped = 0; + while ((entry = reader.GetNextEntry()) is not null) { - var entry = reader.Entry; - yield return (Map(entry, true), () => reader.OpenEntryStream()); + cancellationToken.ThrowIfCancellationRequested(); + var current = entry; + var link = entry.EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink ? entry.LinkName : null; + if (entry.EntryType is not (TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.Directory or TarEntryType.SymbolicLink or TarEntryType.HardLink)) + { + // Pax global/extended headers carry metadata only; fifos, devices and the like are never created. Skip them, bounded. + if (++skipped > MaxSkippedTarEntries) + throw new ArchiveSecurityException("The tar contains too many unsupported entries."); + continue; + } + yield return (new EntryData(entry.Name, entry.EntryType == TarEntryType.Directory, entry.Length, 0, + entry.ModificationTime.UtcDateTime, false, link, 0, (int)entry.Mode, true), () => current.DataStream ?? Stream.Null); } } } @@ -170,19 +206,24 @@ private sealed class SingleFileSource : ArchiveSource private readonly string path; private readonly Codec codec; - public SingleFileSource(string path, Codec codec) + private readonly long? maxBytes; + private readonly CancellationToken cancellationToken; + + public SingleFileSource(string path, Codec codec, long? maxBytes, CancellationToken cancellationToken) { this.path = path; this.codec = codec; + this.maxBytes = maxBytes; + this.cancellationToken = cancellationToken; } public override bool IsSolid => false; - public override IEnumerable<(EntryData Entry, Func Open)> Entries() + public override IEnumerable<(EntryData Entry, Func Open)> Entries(bool headersOnly = false) { var name = Path.GetFileNameWithoutExtension(path); var info = new FileInfo(path); - using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var file = OpenInput(path, maxBytes, cancellationToken); using var decompressed = Decompress(file, codec); yield return (new EntryData(name, false, 0, info.Length, info.LastWriteTime, false, null, 0, null, false), () => decompressed); } diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs index 2173250ca3f4..5446747714a8 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.Extract.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Files.Platform.Abstractions.Archives; @@ -48,7 +49,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE { ct.ThrowIfCancellationRequested(); entryCount++; - declaredBytes += Math.Max(entry.Size, 0); + declaredBytes = checked(declaredBytes + Math.Max(entry.Size, 0)); if (extract) ArchivePathValidator.NormalizeEntryName(entry.Key); @@ -57,6 +58,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE throw new ArchivePasswordException("The archive is encrypted and the password is missing."); guard.CheckEntryCount(entryCount); + guard.CheckDeclared(declaredBytes); } guard.CheckDeclared(declaredBytes); @@ -90,7 +92,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (entry.IsEncrypted && string.IsNullOrEmpty(options.Password)) throw new ArchivePasswordException("The archive is encrypted and the password is missing."); - options.Progress?.Report(new ArchiveProgress(processed, entryCount, bytesDone, declaredBytes, relative)); + options.Progress?.Report(new ArchiveProgress(processed, entryCount, bytesDone, declaredBytes, ArchivePathValidator.Printable(relative))); if (!extract) { @@ -122,8 +124,8 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (entry.LinkTarget is not null) { - // Only relative links that stay inside the extracted tree are created - if (!ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || FileSystemEntry.GetKind(full) != EntryKind.None) + // Parent segments could escape through another archive link; accept only downward relative targets. + if (!ArchivePathValidator.IsSafeLinkTarget(relative, entry.LinkTarget) || entry.LinkTarget.Split('/').Contains("..", StringComparer.Ordinal) || FileSystemEntry.GetKind(full) != EntryKind.None) { skipped++; continue; @@ -178,7 +180,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE if (!extract) return new ArchiveResult(true, false, processed, skipped); - var state = new MergeState(options, ct); + var state = new MergeState(options, ct, destination!); Merge(staging!, destination!, string.Empty, state); skipped += state.Skipped; @@ -195,18 +197,18 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE } catch (ArchiveSecurityException ex) { - return new ArchiveResult(false, false, processed, skipped, ex.Message); + return new ArchiveResult(false, false, processed, skipped, ArchivePathValidator.Printable(ex.Message)); } catch (Exception ex) when (ex is CryptographicException or System.Security.Cryptography.CryptographicException) { throw new ArchivePasswordException("The password is wrong.", ex); } - catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException or ArchiveException or InvalidDataException or NotSupportedException or ArgumentException) + catch (Exception ex) when (ex is IOException or InvalidOperationException or UnauthorizedAccessException or ArchiveException or InvalidDataException or NotSupportedException or ArgumentException or OverflowException) { if (!string.IsNullOrEmpty(options.Password) && ex is not IOException and not UnauthorizedAccessException) throw new ArchivePasswordException("The archive could not be read, the password may be wrong.", ex); - return new ArchiveResult(false, false, processed, skipped, ex.Message); + return new ArchiveResult(false, false, processed, skipped, ArchivePathValidator.Printable(ex.Message)); } finally { @@ -225,7 +227,7 @@ private ArchiveResult Run(string archivePath, string destinationFolder, ArchiveE private static void VerifyCrc(EntryData entry, uint actual) { if (!entry.IsTar && entry.Crc != 0 && (uint)entry.Crc != actual) - throw new InvalidDataException($"CRC mismatch, the archive is corrupt or the password is wrong: '{entry.Key}'."); + throw new InvalidDataException($"CRC mismatch, the archive is corrupt or the password is wrong: '{ArchivePathValidator.Printable(entry.Key ?? string.Empty)}'."); } private static void ApplyMode(string path, EntryData entry) @@ -256,14 +258,17 @@ private sealed class MergeState private readonly ArchiveExtractOptions options; private ConflictResolution? sticky; - public MergeState(ArchiveExtractOptions options, CancellationToken cancellationToken) + public MergeState(ArchiveExtractOptions options, CancellationToken cancellationToken, string root) { this.options = options; CancellationToken = cancellationToken; + Root = root; } public CancellationToken CancellationToken { get; } + public string Root { get; } + public long Skipped { get; set; } public ConflictAction Resolve(ArchiveConflict conflict) @@ -296,6 +301,22 @@ private static void Merge(string source, string destination, string relative, Me var targetKind = FileSystemEntry.GetKind(target); var isDirectory = sourceKind == EntryKind.Directory; + if (sourceKind == EntryKind.Symlink) + { + try + { + var linkTarget = new FileInfo(child).LinkTarget!; + var resolved = Path.GetFullPath(Path.Combine(destination, linkTarget)); + ArchivePathValidator.ResolveInside(state.Root, Path.GetRelativePath(state.Root, resolved)); + ArchivePathValidator.EnsureNoLinkInPath(state.Root, resolved); + } + catch (ArchiveSecurityException) + { + state.Skipped++; + continue; + } + } + if (targetKind == EntryKind.None) { MoveItem(child, target, isDirectory); @@ -308,7 +329,7 @@ private static void Merge(string source, string destination, string relative, Me continue; } - switch (state.Resolve(new ArchiveConflict(relativePath, target, isDirectory))) + switch (state.Resolve(new ArchiveConflict(ArchivePathValidator.Printable(relativePath), target, isDirectory))) { case ConflictAction.Cancel: throw new OperationCanceledException(); diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs new file mode 100644 index 000000000000..f6ecf4b5face --- /dev/null +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.OpenEntry.cs @@ -0,0 +1,88 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Abstractions.Archives; +using SharpCompress.Common; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Linux.Archives +{ + public sealed partial class LinuxArchiveService + { + private static readonly ArchiveLimits BrowsingLimits = new(64L * 1024 * 1024, 10000, 100, 1024 * 1024); + + /// + public Task OpenEntryAsync(string archivePath, string entryPath, string? password = null, CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + var wanted = ArchivePathValidator.NormalizeEntryName(entryPath); + if (wanted.Length == 0) + throw new FileNotFoundException(); + try + { + using var archive = OpenArchive(archivePath, password, null, BrowsingLimits.MaxTotalBytes, cancellationToken); + var guard = new ExtractionGuard(BrowsingLimits, null, new FileInfo(archivePath).Length, cancellationToken); + var buffer = new byte[81920]; + long count = 0; + foreach (var (entry, open) in archive.Entries()) + { + cancellationToken.ThrowIfCancellationRequested(); + guard.CheckEntryCount(++count); + var name = ArchivePathValidator.NormalizeEntryName(entry.Key ?? GetDefaultExtractFolderName(archivePath)); + var selected = name == wanted; + if (entry.IsDirectory || IsLink(entry)) + { + if (selected) + throw new ArchiveSecurityException("Only regular archive files can be opened."); + continue; + } + if (!selected && !archive.IsSolid) + continue; + if (entry.IsEncrypted && string.IsNullOrEmpty(password)) + throw new ArchivePasswordException("The archive requires a password."); + guard.CheckDeclared(Math.Max(entry.Size, 0)); + var memberGuard = selected && entry.CompressedSize > 0 + ? new ExtractionGuard(BrowsingLimits, null, entry.CompressedSize, cancellationToken) : null; + memberGuard?.CheckDeclared(Math.Max(entry.Size, 0)); + using var input = open(); + using var output = new MemoryStream(); + uint crc = 0; + int read; + while ((read = input.Read(buffer, 0, buffer.Length)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + guard.AddBytes(read); + memberGuard?.AddBytes(read); + if (selected) + { + output.Write(buffer, 0, read); + crc = Crc32.Update(crc, buffer, read); + } + } + if (selected) + { + try { VerifyCrc(entry, crc); } + catch (InvalidDataException ex) when (entry.IsEncrypted) + { + throw new ArchivePasswordException("The archive password is wrong.", ex); + } + return new MemoryStream(output.GetBuffer(), 0, (int)output.Length, writable: false); + } + } + throw new FileNotFoundException("The archive entry was not found."); + } + catch (Exception ex) when (ex is CryptographicException or System.Security.Cryptography.CryptographicException) + { + throw new ArchivePasswordException("The archive password is missing or wrong.", ex); + } + }, cancellationToken); + } + + private static bool IsLink(EntryData entry) + => entry.LinkTarget is not null || (entry.Attrib is { } attributes && ((attributes >> 16) & 0xF000) == 0xA000); + } +} diff --git a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs index 0b51bdfdb5a6..2be0f6dd2e58 100644 --- a/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs +++ b/src/Files.Platform.Linux/Archives/LinuxArchiveService.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Files.Platform.Abstractions.Archives; +using Files.Platform.Linux.Previews; using SharpCompress.Common; using SharpCompress.Readers; @@ -24,9 +25,15 @@ public sealed partial class LinuxArchiveService : IArchiveService [ ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar.lz", ".tgz", ".tbz2", ".tbz", ".txz", ".tzst", - ".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".lz", + ".zip", ".jar", ".mrpack", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".lz", ]; + private sealed record ScanLimits(int Entries, long NameBytes, long HeaderBytes, long StreamBytes, TimeSpan Time); + + // Header bytes bound zip/7z/rar directories; stream bytes bound the decompressed scan of tar and single-file formats + private static readonly ScanLimits ListScan = new(100_000, 16L * 1024 * 1024, 128L * 1024 * 1024, 16L * 1024 * 1024 * 1024, TimeSpan.FromSeconds(60)); + private static readonly ScanLimits QuickScan = new(10_000, 2L * 1024 * 1024, 32L * 1024 * 1024, 1L * 1024 * 1024 * 1024, TimeSpan.FromSeconds(10)); + private readonly ISevenZipRunner sevenZip; /// @@ -82,39 +89,82 @@ public string GetDefaultExtractFolderName(string archivePath) } /// - public Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + public async Task ListAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) { - return Task.Run(() => + var fallback = GetDefaultExtractFolderName(archivePath); + var entries = new List(); + var encrypted = false; + var (truncated, solid) = await ScanHeadersAsync(archivePath, password, fileNameEncoding, entry => { - using var archive = OpenArchive(archivePath, password, fileNameEncoding); - var fallback = GetDefaultExtractFolderName(archivePath); - var entries = new List(); - var encrypted = false; + encrypted |= entry.IsEncrypted; + entries.Add(ToInfo(entry, fallback)); + return true; + }, ListScan, cancellationToken).ConfigureAwait(false); - foreach (var (entry, _) in archive.Entries()) + return new ArchiveListing(entries, encrypted, solid, truncated); + } + + /// + public async Task HasMultipleTopLevelEntriesAsync(string archivePath, string? password = null, CancellationToken cancellationToken = default) + { + string? first = null; + var multiple = false; + try + { + var (truncated, _) = await ScanHeadersAsync(archivePath, password, null, entry => { - cancellationToken.ThrowIfCancellationRequested(); - encrypted |= entry.IsEncrypted; - entries.Add(new ArchiveEntryInfo( - (entry.Key ?? fallback).Replace('\\', '/'), - entry.IsDirectory, - Math.Max(entry.Size, 0), - Math.Max(entry.CompressedSize, 0), - entry.Modified, - entry.IsEncrypted, - entry.LinkTarget)); - } + var segment = (entry.Key ?? string.Empty).Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(s => s is not "."); + if (segment is null) + return true; + if (first is null) + first = segment; + else if (segment != first) + multiple = true; + return !multiple; + }, QuickScan, cancellationToken).ConfigureAwait(false); + + // Too large to tell: extracting into a child folder is the safe choice + return multiple || truncated; + } + catch (ArchivePasswordException) + { + return true; + } + } - return new ArchiveListing(entries, encrypted, archive.IsSolid); - }, cancellationToken); + /// + public async Task ListForBrowsingAsync(string archivePath, string? password = null, Encoding? fileNameEncoding = null, CancellationToken cancellationToken = default) + { + // Browsing only needs the header caps; the decompressed-size and ratio guards apply per entry in OpenEntryAsync + var listing = await ListAsync(archivePath, password, fileNameEncoding, cancellationToken).ConfigureAwait(false); + if (listing.IsTruncated) + throw new ArchiveSecurityException("The archive is too large to browse."); + + return listing; } + private static ArchiveEntryInfo ToInfo(EntryData entry, string fallback) => new( + (entry.Key ?? fallback).Replace('\\', '/'), + entry.IsDirectory, + Math.Max(entry.Size, 0), + Math.Max(entry.CompressedSize, 0), + entry.Modified, + entry.IsEncrypted, + IsLink(entry) ? entry.LinkTarget ?? string.Empty : null); + /// public async Task IsEncryptedAsync(string archivePath, CancellationToken cancellationToken = default) { + // Tar and single-file compression formats have no encryption + var name = Path.GetFileName(archivePath).ToLowerInvariant(); + if (ArchiveSource.TarCodec(name) is not null || ArchiveSource.SingleFileCodec(name) is not null) + return false; + + var found = false; try { - return (await ListAsync(archivePath, null, null, cancellationToken).ConfigureAwait(false)).IsEncrypted; + await ScanHeadersAsync(archivePath, null, null, entry => !(found = entry.IsEncrypted), QuickScan, cancellationToken).ConfigureAwait(false); + return found; } catch (ArchivePasswordException) { @@ -123,6 +173,46 @@ public async Task IsEncryptedAsync(string archivePath, CancellationToken c } } + /// Streams entry headers without buffering content. Hitting a cap ends the scan and reports it as truncated instead of failing. + private Task<(bool Truncated, bool Solid)> ScanHeadersAsync(string archivePath, string? password, Encoding? encoding, Func visit, ScanLimits limits, CancellationToken cancellationToken) + { + return Task.Run(() => + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(limits.Time); + var solid = false; + try + { + var name = Path.GetFileName(archivePath).ToLowerInvariant(); + var inputCap = ArchiveSource.TarCodec(name) is not null || ArchiveSource.SingleFileCodec(name) is not null ? limits.StreamBytes : limits.HeaderBytes; + using var archive = OpenArchive(archivePath, password, encoding, inputCap, timeout.Token); + solid = archive.IsSolid; + var count = 0; + long nameBytes = 0; + foreach (var (entry, _) in archive.Entries(headersOnly: true)) + { + timeout.Token.ThrowIfCancellationRequested(); + nameBytes += Encoding.UTF8.GetByteCount(entry.Key ?? string.Empty); + if (count >= limits.Entries || nameBytes > limits.NameBytes) + return (true, solid); + count++; + if (!visit(entry)) + break; + } + + return (false, solid); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (true, solid); + } + catch (InvalidDataException ex) when (ex.Message == PreviewReadStream.LimitMessage) + { + return (true, solid); + } + }, cancellationToken); + } + private static string? FindExtension(string path) { var name = Path.GetFileName(path); @@ -135,11 +225,11 @@ public async Task IsEncryptedAsync(string archivePath, CancellationToken c return null; } - private static ArchiveSource OpenArchive(string path, string? password, Encoding? encoding) + private static ArchiveSource OpenArchive(string path, string? password, Encoding? encoding, long? maxBytes = null, CancellationToken cancellationToken = default) { try { - return ArchiveSource.Open(path, password, encoding); + return ArchiveSource.Open(path, password, encoding, maxBytes, cancellationToken); } catch (Exception ex) when (ex is CryptographicException or System.Security.Cryptography.CryptographicException) { diff --git a/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs b/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs new file mode 100644 index 000000000000..e9854b2e5d57 --- /dev/null +++ b/src/Files.Platform.Linux/Icons/CachingTypefaceProvider.cs @@ -0,0 +1,85 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using SkiaSharp; +using Svg.Skia.TypefaceProviders; +using System; +using System.Collections.Generic; + +namespace Files.Platform.Linux.Icons +{ + /// + /// Memoizes typeface lookups for SVG rendering. Svg.Skia resolves a typeface for every paint it converts, and the default + /// font-manager provider enumerates fontconfig style sets each time, which is very slow with thousands of installed fonts. + /// The cache is a small LRU; each key is created once even when requested concurrently. + /// + internal sealed class CachingTypefaceProvider : ITypefaceProvider + { + internal const int Capacity = 64; + + private static readonly object _gate = new(); + private static readonly Dictionary<(string Family, SKFontStyleWeight Weight, SKFontStyleWidth Width, SKFontStyleSlant Slant), LinkedListNode> _cache = new(); + private static readonly LinkedList _order = new(); + + private readonly IReadOnlyList _providers; + + private sealed record Entry((string Family, SKFontStyleWeight Weight, SKFontStyleWidth Width, SKFontStyleSlant Slant) Key, SKTypeface? Typeface); + + public CachingTypefaceProvider(IEnumerable? providers) + { + _providers = [.. providers ?? [new FontManagerTypefaceProvider(), new DefaultTypefaceProvider()]]; + } + + /// Gets the number of cached lookups. + internal static int Count + { + get + { + lock (_gate) + return _cache.Count; + } + } + + public SKTypeface? FromFamilyName(string fontFamily, SKFontStyleWeight fontWeight, SKFontStyleWidth fontWidth, SKFontStyleSlant fontStyle) + { + var key = (fontFamily ?? string.Empty, fontWeight, fontWidth, fontStyle); + + // Creating under the lock keeps a key from being resolved twice; lookups are cheap after the first + lock (_gate) + { + if (_cache.TryGetValue(key, out var node)) + { + // A typeface disposed elsewhere must not be handed out again + if (node.Value.Typeface is null || node.Value.Typeface.Handle != IntPtr.Zero) + { + _order.Remove(node); + _order.AddFirst(node); + return node.Value.Typeface; + } + + _order.Remove(node); + _cache.Remove(key); + } + + SKTypeface? typeface = null; + foreach (var provider in _providers) + { + if (provider.FromFamilyName(key.Item1, fontWeight, fontWidth, fontStyle) is { } found) + { + typeface = found; + break; + } + } + + _cache[key] = _order.AddFirst(new Entry(key, typeface)); + while (_cache.Count > Capacity) + { + _cache.Remove(_order.Last!.Value.Key); + _order.RemoveLast(); + } + + return typeface; + } + } + } +} diff --git a/src/Files.Platform.Linux/Icons/SvgRasterizer.cs b/src/Files.Platform.Linux/Icons/SvgRasterizer.cs index 73399e243341..0f71725fadc7 100644 --- a/src/Files.Platform.Linux/Icons/SvgRasterizer.cs +++ b/src/Files.Platform.Linux/Icons/SvgRasterizer.cs @@ -62,6 +62,7 @@ public static class SvgRasterizer try { using var svg = new SKSvg(); + svg.Settings.TypefaceProviders = [new CachingTypefaceProvider(svg.Settings.TypefaceProviders)]; if (load(svg) is not { } picture) return null; 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/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs index 8084d3f38107..3602de50fc61 100644 --- a/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs +++ b/src/Files.Platform.Linux/Mime/LinuxMimeTypeService.cs @@ -3,6 +3,7 @@ using Files.Platform.Abstractions.Mime; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -30,9 +31,94 @@ public sealed class LinuxMimeTypeService : IMimeTypeService private readonly XdgDirectories xdg; private readonly CultureInfo culture; - private readonly Lazy globs; + private readonly Lazy globs; + private readonly ConcurrentDictionary mimeXmlCache = new(StringComparer.Ordinal); - private sealed record GlobEntry(int Weight, string MimeType, string Pattern, bool CaseSensitive, bool IsLiteral); + + private sealed record MimeXmlEntry(XElement? Root, string? File, DateTime LastWriteUtc, long CheckedAt); + + private sealed record GlobEntry(int Order, int Weight, string MimeType, string Pattern, string LowerPattern, bool CaseSensitive, bool IsLiteral) + { + /// Gets whether this entry outranks : higher weight, then longer pattern, then earlier in the database. + public bool Beats(GlobEntry? other) => + other is null || Weight > other.Weight || + (Weight == other.Weight && (Pattern.Length > other.Pattern.Length || (Pattern.Length == other.Pattern.Length && Order < other.Order))); + } + + /// + /// Globs grouped so a lookup touches only plausible entries: exact names and plain "*suffix" patterns are hashed, the rest are scanned. + /// + private sealed class GlobIndex + { + private readonly Dictionary> literalsCs = new(StringComparer.Ordinal); + private readonly Dictionary> literalsCi = new(StringComparer.Ordinal); + private readonly Dictionary> suffixesCs = new(StringComparer.Ordinal); + private readonly Dictionary> suffixesCi = new(StringComparer.Ordinal); + private readonly List complex = []; + + public GlobIndex(IEnumerable entries) + { + foreach (var entry in entries) + { + if (entry.IsLiteral) + { + Add(entry.CaseSensitive ? literalsCs : literalsCi, entry.CaseSensitive ? entry.Pattern : entry.LowerPattern, entry); + } + else if (entry.Pattern.Length > 1 && entry.Pattern[0] == '*' && entry.Pattern.AsSpan(1).IndexOfAny("*?[\\") < 0) + { + Add(entry.CaseSensitive ? suffixesCs : suffixesCi, entry.CaseSensitive ? entry.Pattern[1..] : entry.LowerPattern[1..], entry); + } + else + { + complex.Add(entry); + } + } + } + + private static void Add(Dictionary> map, string key, GlobEntry entry) + { + if (!map.TryGetValue(key, out var list)) + map[key] = list = []; + list.Add(entry); + } + + public string? Match(string fileName, string lower) + { + GlobEntry? best = null; + + void Consider(Dictionary> map, ReadOnlySpan key) + { + if (map.GetAlternateLookup>().TryGetValue(key, out var list)) + { + foreach (var entry in list) + { + if (entry.Beats(best)) + best = entry; + } + } + } + + Consider(literalsCs, fileName); + Consider(literalsCi, lower); + for (var i = 0; i < fileName.Length; i++) + { + Consider(suffixesCs, fileName.AsSpan(i)); + Consider(suffixesCi, lower.AsSpan(i)); + } + + foreach (var glob in complex) + { + if (!glob.Beats(best)) + continue; + + var matches = glob.CaseSensitive ? GlobMatch(glob.Pattern, fileName) : GlobMatch(glob.LowerPattern, lower); + if (matches) + best = glob; + } + + return best?.MimeType; + } + } /// /// Creates the service for the process environment and current UI culture. @@ -48,9 +134,14 @@ public LinuxMimeTypeService(XdgDirectories xdg, CultureInfo culture) { this.xdg = xdg; this.culture = culture; - globs = new(LoadGlobs); + globs = new(() => new GlobIndex(LoadGlobs())); } + /// + /// Gets how long a cached MIME XML (or a miss) is trusted before the file is checked again, so newly installed types appear without a restart. + /// + public TimeSpan RecheckInterval { get; init; } = TimeSpan.FromSeconds(5); + /// public async Task GetMimeTypeAsync(string path, CancellationToken cancellationToken = default) { @@ -124,26 +215,7 @@ public Task GetGenericIconNameAsync(string mimeType, CancellationToken c if (fileName.Length == 0) return null; - var lower = fileName.ToLowerInvariant(); - GlobEntry? best = null; - - foreach (var glob in globs.Value) - { - var name = glob.CaseSensitive ? fileName : lower; - var pattern = glob.CaseSensitive ? glob.Pattern : glob.Pattern.ToLowerInvariant(); - var matches = glob.IsLiteral ? name == pattern : GlobMatch(pattern, name); - if (!matches) - continue; - - if (best is null - || glob.Weight > best.Weight - || (glob.Weight == best.Weight && glob.Pattern.Length > best.Pattern.Length)) - { - best = glob; - } - } - - return best?.MimeType; + return globs.Value.Match(fileName, fileName.ToLowerInvariant()); } /// @@ -256,7 +328,7 @@ private GlobEntry[] LoadGlobs() continue; var flags = parts.Length > 3 ? parts[3].Split(',') : []; - entries.Add(MakeEntry(weight, parts[1], parts[2], flags.Contains("cs"))); + entries.Add(MakeEntry(entries.Count, weight, parts[1], parts[2], flags.Contains("cs"))); } } else if (TryReadLines(Path.Combine(dir, "mime", "globs"), out lines)) @@ -268,7 +340,7 @@ private GlobEntry[] LoadGlobs() var parts = line.Split(':', 2); if (parts.Length == 2) - entries.Add(MakeEntry(50, parts[0], parts[1], false)); + entries.Add(MakeEntry(entries.Count, 50, parts[0], parts[1], false)); } } } @@ -276,8 +348,8 @@ private GlobEntry[] LoadGlobs() return [.. entries]; } - private static GlobEntry MakeEntry(int weight, string mime, string pattern, bool caseSensitive) => - new(weight, mime, pattern, caseSensitive, pattern.IndexOfAny(['*', '?', '[']) < 0); + private static GlobEntry MakeEntry(int order, int weight, string mime, string pattern, bool caseSensitive) => + new(order, weight, mime, pattern, pattern.ToLowerInvariant(), caseSensitive, pattern.IndexOfAny(['*', '?', '[']) < 0); private static bool TryReadLines(string path, out string[] lines) { @@ -294,10 +366,61 @@ private static bool TryReadLines(string path, out string[] lines) } private XElement? LoadMimeXml(string mimeType) + { + var now = Environment.TickCount64 * TimeSpan.TicksPerMillisecond; + if (mimeXmlCache.TryGetValue(mimeType, out var entry)) + { + if (now - entry.CheckedAt < RecheckInterval.Ticks) + return entry.Root; + + // Keep the entry while the highest-priority file is the same and unchanged (a new override in an earlier directory, or a type + // installed after a miss, changes which file that is) + var current = FindMimeXmlPath(mimeType); + if (current == entry.File && (current is null || TryGetWriteTime(current) == entry.LastWriteUtc)) + { + mimeXmlCache[mimeType] = entry with { CheckedAt = now }; + return entry.Root; + } + } + + var (root, file) = ReadMimeXml(mimeType); + file = FindMimeXmlPath(mimeType) ?? file; + mimeXmlCache[mimeType] = new MimeXmlEntry(root, file, file is null ? default : TryGetWriteTime(file), now); + return root; + } + + private static DateTime TryGetWriteTime(string file) + { + try + { + return File.GetLastWriteTimeUtc(file); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return default; + } + } + + private string? FindMimeXmlPath(string mimeType) { if (mimeType.Contains("..", StringComparison.Ordinal) || mimeType.Split('/').Length != 2) return null; + foreach (var dir in xdg.AllDataDirs) + { + var file = Path.Combine(dir, "mime", mimeType + ".xml"); + if (File.Exists(file)) + return file; + } + + return null; + } + + private (XElement? Root, string? File) ReadMimeXml(string mimeType) + { + if (mimeType.Contains("..", StringComparison.Ordinal) || mimeType.Split('/').Length != 2) + return (null, null); + foreach (var dir in xdg.AllDataDirs) { var file = Path.Combine(dir, "mime", mimeType + ".xml"); @@ -306,14 +429,14 @@ private static bool TryReadLines(string path, out string[] lines) try { - return XDocument.Load(file).Root; + return (XDocument.Load(file).Root, file); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) { } } - return null; + return (null, null); } private static bool IsDanglingSymbolicLink(string path) diff --git a/src/Files.Platform.Linux/Previews/PreviewReadStream.cs b/src/Files.Platform.Linux/Previews/PreviewReadStream.cs index e1509ffb6af9..2f21450f0501 100644 --- a/src/Files.Platform.Linux/Previews/PreviewReadStream.cs +++ b/src/Files.Platform.Linux/Previews/PreviewReadStream.cs @@ -11,6 +11,8 @@ namespace Files.Platform.Linux.Previews /// Limits cumulative reads, including rereads after seeking, from untrusted preview input. public sealed class PreviewReadStream : Stream { + public const string LimitMessage = "The preview read limit was exceeded."; + private readonly Stream source; private readonly CancellationToken cancellationToken; private long remaining; @@ -33,7 +35,7 @@ private int Limit(int count) { cancellationToken.ThrowIfCancellationRequested(); if (count > 0 && remaining == 0 && !(source.CanSeek && source.Position >= source.Length)) - throw new InvalidDataException("The preview read limit was exceeded."); + throw new InvalidDataException(LimitMessage); return (int)Math.Min(count, remaining); } diff --git a/src/Files.Shared/Helpers/FileExtensionHelpers.cs b/src/Files.Shared/Helpers/FileExtensionHelpers.cs index 3c3119ebe909..da9296592ba0 100644 --- a/src/Files.Shared/Helpers/FileExtensionHelpers.cs +++ b/src/Files.Shared/Helpers/FileExtensionHelpers.cs @@ -7,6 +7,8 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using System.Globalization; +using System.Text; namespace Files.Shared.Helpers { @@ -133,29 +135,58 @@ public static bool IsZipFile(string? fileExtensionToCheck) public static bool IsBrowsableZipFile(string? filePath, [NotNullWhen(true)] out string? ext) { - // LINUX-TODO(archives): browsing archives as folders (ZipStorageFolder) is built on 7z.dll and '\\' paths; on Linux archives are - // plain files that the Extract actions (IArchiveService) handle. - if (!OperatingSystem.IsWindows()) + if (string.IsNullOrWhiteSpace(filePath)) { ext = null; return false; } - if (string.IsNullOrWhiteSpace(filePath)) + if (OperatingSystem.IsWindows()) { - ext = null; + // Windows keeps the original substring matching + ext = new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".lzh", ".mrpack", ".jar" } + .FirstOrDefault(x => filePath.Contains(x, StringComparison.OrdinalIgnoreCase)); - return false; + return ext is not null; } - // Only extensions we want to browse - ext = new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".lzh", ".mrpack", ".jar" } - .FirstOrDefault(x => filePath.Contains(x, StringComparison.OrdinalIgnoreCase)); - + ext = null; + foreach (var component in filePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries)) + { + var candidate = Path.GetExtension(component); + if (new[] { ".zip", ".7z", ".rar", ".tar", ".gz", ".bz2", ".xz", ".zst", ".tgz", ".tbz2", ".txz", ".tzst", ".mrpack", ".jar" }.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + ext = candidate; + break; + } + } return ext is not null; } + /// Finds a browsable archive component, skipping real directories with archive extensions. + public static string? GetArchiveContainerPath(string path) + { + for (var end = 1; end <= path.Length; end++) + { + if (end < path.Length && path[end] is not ('/' or '\\')) + continue; + var candidate = path[..end]; + var name = candidate[(Math.Max(candidate.LastIndexOf('/'), candidate.LastIndexOf('\\')) + 1)..]; + if (IsBrowsableZipFile(name, out _) && !Directory.Exists(candidate)) + return candidate; + } + return null; + } + + /// Checks for an archive root or a member path using either separator. + public static bool IsZipPath([NotNullWhen(true)] string? path, bool includeRoot = true) + { + if (string.IsNullOrEmpty(path) || GetArchiveContainerPath(path) is not { } container) + return false; + return container.Length == path.TrimEnd('/', '\\').Length ? includeRoot : !Path.Exists(path); + } + /// /// Check if the file extension is a driver inf file. /// @@ -389,4 +420,21 @@ public static bool IsImagePreviewFile(string? fileExtensionToCheck) return HasExtension(fileExtensionToCheck, ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".ico", ".webp", ".jxr"); } } + + /// Escapes untrusted archive names without changing the identifier used to open an entry. + public static class ArchiveDisplayName + { + public static string Escape(string value) + { + var result = new StringBuilder(value.Length); + foreach (var character in value) + { + if (char.IsControl(character) || char.GetUnicodeCategory(character) is UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator || character is '\\' or '\u034F' or '\u180B' or '\u180C' or '\u180D') + result.Append("\\u").Append(((int)character).ToString("X4", CultureInfo.InvariantCulture)); + else + result.Append(character); + } + return result.ToString(); + } + } } diff --git a/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs new file mode 100644 index 000000000000..1f13e6bc5d79 --- /dev/null +++ b/tests/Files.Platform.Tests/Archives/ArchiveBrowsingTests.cs @@ -0,0 +1,373 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.App.Storage.Archives; +using Files.App.Storage.Storables; +using Files.Core.Storage.Enums; +using Files.Platform.Abstractions.Archives; +using Files.Platform.Linux.Archives; +using Files.Shared.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OwlCore.Storage; +using System; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Runtime.Versioning; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.Archives +{ + [TestClass] + [SupportedOSPlatform("linux")] + public sealed class ArchiveBrowsingTests + { + private readonly LinuxArchiveService service = new(); + private string root = null!; + + [TestInitialize] + public void Setup() => root = Directory.CreateTempSubdirectory("files-archive-browsing-").FullName; + + [TestCleanup] + public void Cleanup() => Directory.Delete(root, true); + + private string Zip(params (string Name, string Text)[] entries) + { + var path = Path.Combine(root, "test.zip"); + using var zip = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var (name, text) in entries) + { + using var writer = new StreamWriter(zip.CreateEntry(name).Open()); + writer.Write(text); + } + return path; + } + + [TestMethod] + public async Task ListingStopsAtEntryCapForHugeZip() + { + var path = Path.Combine(root, "many.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + { + for (var i = 0; i < 120_000; i++) + zip.CreateEntry("f" + i); + } + var listing = await service.ListAsync(path); + Assert.IsTrue(listing.IsTruncated); + Assert.AreEqual(100_000, listing.Entries.Count); + Assert.IsTrue(await service.HasMultipleTopLevelEntriesAsync(path)); + Assert.IsFalse(await service.IsEncryptedAsync(path)); + } + + [TestMethod] + public async Task BigTarGzListsWithinCapsAndForBrowsing() + { + var path = Path.Combine(root, "big.tar.gz"); + var tarPath = Path.Combine(root, "big.tar"); + using (var tarFile = File.Create(tarPath)) + using (var tar = new TarWriter(tarFile)) + { + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "dir/big.bin") { DataStream = new ZeroStream(96L * 1024 * 1024) }); + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "dir/small.txt") { DataStream = new MemoryStream([1]) }); + } + using (var input = File.OpenRead(tarPath)) + using (var output = File.Create(path)) + using (var gzip = new GZipStream(output, CompressionLevel.Fastest)) + input.CopyTo(gzip); + var listing = await service.ListAsync(path); + Assert.IsFalse(listing.IsTruncated); + Assert.AreEqual(2, listing.Entries.Count); + Assert.IsFalse(await service.IsEncryptedAsync(path)); + Assert.IsFalse(await service.HasMultipleTopLevelEntriesAsync(path)); + Assert.AreEqual(2, (await service.ListForBrowsingAsync(path)).Entries.Count); + } + + [TestMethod] + public async Task GitArchiveStyleTarWithGlobalHeaderLists() + { + var path = Path.Combine(root, "src.tar.gz"); + using (var file = File.Create(path)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + using (var tar = new TarWriter(gzip, TarEntryFormat.Pax)) + { + tar.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary { ["comment"] = "0123abcd" })); + tar.WriteEntry(new PaxTarEntry(TarEntryType.Directory, "proj/")); + tar.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "proj/a.txt") { DataStream = new MemoryStream([1, 2]) }); + } + var listing = await service.ListForBrowsingAsync(path); + CollectionAssert.AreEquivalent(new[] { "proj/", "proj/a.txt" }, listing.Entries.Select(e => e.Path.TrimEnd('/') + (e.IsDirectory ? "/" : "")).ToArray()); + Assert.IsFalse(await service.HasMultipleTopLevelEntriesAsync(path)); + } + + [TestMethod] + public async Task LargeDeclaredSizeZipIsBrowsable() + { + var path = Path.Combine(root, "large.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + { + using var stream = zip.CreateEntry("zeros.bin", CompressionLevel.SmallestSize).Open(); + var block = new byte[1024 * 1024]; + for (var i = 0; i < 80; i++) + stream.Write(block); + } + Assert.AreEqual(1, (await service.ListForBrowsingAsync(path)).Entries.Count); + } + + private sealed class ZeroStream : Stream + { + private readonly long length; + private long remaining; + public ZeroStream(long length) { this.length = length; remaining = length; } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => length; + public override long Position { get => length - remaining; set => throw new NotSupportedException(); } + public override int Read(byte[] buffer, int offset, int count) + { + var n = (int)Math.Min(count, remaining); + Array.Clear(buffer, offset, n); + remaining -= n; + return n; + } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + [TestMethod] + public async Task RealArchiveNamedDirectoriesFallThrough() + { + var folder = Directory.CreateDirectory(Path.Combine(root, "a.zip")); + var child = Path.Combine(folder.FullName, "real.txt"); + File.WriteAllText(child, "real"); + var route = new ArchiveStorableRoute(service); + Assert.AreEqual(StorableStatus.NotMine, (await route.TryGetAsync(folder.FullName)).Status); + Assert.AreEqual(StorableStatus.NotMine, (await route.TryGetAsync(child)).Status); + Assert.IsFalse(FileExtensionHelpers.IsZipPath(child)); + var resolver = new StorableResolver([route, new LocalStorableRoute()]); + Assert.IsInstanceOfType((await resolver.TryGetAsync(child)).Item); + } + + [TestMethod] + public async Task BrowseImplicitDirectoriesAndOpenMembers() + { + var path = Zip(("dir/sub/a.txt", "alpha"), ("empty/", ""), ("root.txt", "root")); + var route = new ArchiveStorableRoute(service); + var folder = (ArchiveFolder)(await route.TryGetAsync(path)).Item!; + var children = new List(); + await foreach (var child in folder.GetItemsAsync()) children.Add(child); + CollectionAssert.AreEquivalent(new[] { "dir", "empty", "root.txt" }, children.Select(child => child.Name).ToArray()); + Assert.IsFalse((object)folder is IModifiableFolder); + var nested = (ArchiveFolder)await folder.GetItemAsync(path + "/dir"); + Assert.AreEqual(path, (await nested.GetParentAsync())!.Id); + var member = (ArchiveEntryFile)(await route.TryGetAsync(path + "/dir/sub/a.txt")).Item!; + Assert.AreEqual(path + "/dir/sub", (await member.GetParentAsync())!.Id); + using var stream = await member.OpenStreamAsync(FileAccess.Read); + Assert.IsFalse(stream.CanWrite); + using var reader = new StreamReader(stream); + Assert.AreEqual("alpha", await reader.ReadToEndAsync()); + await Assert.ThrowsAsync(() => member.OpenStreamAsync(FileAccess.Write)); + await Assert.ThrowsAsync(() => folder.GetItemAsync(path + "/dir/sub/a.txt")); + Assert.AreEqual(StorableStatus.NotFound, (await route.TryGetAsync(path + "/missing")).Status); + Assert.IsFalse(((IArchiveService)service).CanWriteEntries); + } + + [TestMethod] + public async Task NamesAreEscapedButIdentifiersRemainExact() + { + const string name = "line\n\u202E.txt"; + var path = Zip((name, "data")); + var member = (ArchiveEntryFile)(await new ArchiveStorableRoute(service).TryGetAsync(path + "/" + name)).Item!; + Assert.AreEqual("line\\u000A\\u202E.txt", member.Name); + Assert.AreEqual(path + "/" + name, member.Id); + using var stream = await member.OpenStreamAsync(FileAccess.Read); + Assert.AreEqual(4, stream.Length); + } + + [TestMethod] + public void ArchivePathDetectionAcceptsBothSeparatorsAndRequiresBoundaries() + { + Assert.IsTrue(FileExtensionHelpers.IsZipPath("/tmp/test.zip/dir/a.txt", false)); + Assert.IsTrue(FileExtensionHelpers.IsZipPath(@"C:\temp\test.zip\dir\a.txt", false)); + Assert.IsTrue(FileExtensionHelpers.IsZipPath("/tmp/test.zip")); + Assert.IsFalse(FileExtensionHelpers.IsZipPath("/tmp/test.zip", false)); + Assert.IsFalse(FileExtensionHelpers.IsZipPath("/tmp/test.zip.backup/a.txt")); + Assert.IsFalse(FileExtensionHelpers.IsZipPath("/tmp/test.zipfolder/a.txt")); + } + + [TestMethod] + public async Task TraversalAndAmbiguousArchiveTreesAreRejected() + { + var path = Zip(("../outside", "no")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "../outside")); + File.Delete(path); + path = Zip(("a", "file"), ("a/b", "file")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + File.Delete(path); + path = Zip(("a/b", "one"), ("a\\b", "two")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + } + + [TestMethod] + public async Task MemberReadsRejectMissingEntriesAndCancellation() + { + var path = Zip(("a.txt", "a")); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "missing")); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "a.txt", cancellationToken: cts.Token)); + await Assert.ThrowsAsync(() => new ArchiveStorableRoute(service).TryGetAsync(path, cts.Token)); + } + + [TestMethod] + public async Task MemberReadsEnforceDeclaredRatioAndActualGzipBytes() + { + var path = Zip(("zeros", new string('0', 2 * 1024 * 1024))); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "zeros")); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Update)) + using (var padding = zip.CreateEntry("padding").Open()) + { + var buffer = new byte[2 * 1024 * 1024]; + new Random(42).NextBytes(buffer); + padding.Write(buffer); + } + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "zeros")); + var gzipPath = Path.Combine(root, "zeros.gz"); + using (var file = File.Create(gzipPath)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + gzip.Write(new byte[2 * 1024 * 1024]); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(gzipPath, "zeros")); + } + + [TestMethod] + public async Task DeclaredMemberSizeAndImplicitHierarchyAreBounded() + { + var path = Path.Combine(root, "large.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + using (var stream = zip.CreateEntry("large").Open()) + { + var buffer = new byte[1024 * 1024]; + for (var i = 0; i < 65; i++) stream.Write(buffer); + } + var violation = await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "large")); + Assert.AreEqual("bytes", violation.Violation.Limit); + path = Zip((string.Join('/', Enumerable.Repeat("a", 129)) + "/file", "data")); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + File.Delete(path); + path = Zip(Enumerable.Range(0, 1100).Select(i => ($"dir{i}/a/b/c/d/e/f/g/h/i/file", "data")).ToArray()); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + } + + [TestMethod] + public async Task SolidTraversalCountsSkippedMembers() + { + var path = Path.Combine(root, "solid.tar.gz"); + using (var file = File.Create(path)) + using (var gzip = new GZipStream(file, CompressionLevel.Fastest)) + using (var writer = new TarWriter(gzip)) + { + using var data = new MemoryStream(new byte[2 * 1024 * 1024]); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "first") { DataStream = data }); + using var last = new MemoryStream(Encoding.UTF8.GetBytes("last")); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "last") { DataStream = last }); + } + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "last")); + } + + [TestMethod] + public async Task CorruptArchivesReturnErrorAndZipLinksAreHidden() + { + var path = Path.Combine(root, "broken.zip"); + File.WriteAllText(path, "invalid"); + Assert.AreEqual(StorableStatus.Error, (await new ArchiveStorableRoute(service).TryGetAsync(path)).Status); + path = Path.Combine(root, "link.zip"); + using (var zip = ZipFile.Open(path, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("link"); + entry.ExternalAttributes = unchecked((int)0xA1FF0000); + using var writer = new StreamWriter(entry.Open()); + writer.Write("/etc/passwd"); + } + Assert.AreEqual(StorableStatus.NotFound, (await new ArchiveStorableRoute(service).TryGetAsync(path + "/link")).Status); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "link")); + } + + [TestMethod] + public async Task UpwardLinkTargetsAreSkippedEvenWhenLexicallyInside() + { + var path = Path.Combine(root, "chain.tar"); + using (var file = File.Create(path)) + using (var writer = new TarWriter(file)) + { + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "sub/alias") { LinkName = ".." }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "sub/alias/../outside" }); + } + var destination = Path.Combine(root, "extract"); + var result = await service.ExtractAsync(path, destination); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.AreEqual(2, result.ItemsSkipped); + Assert.IsNull(new FileInfo(Path.Combine(destination, "link")).LinkTarget); + } + + [TestMethod] + public async Task ArchiveLinksCannotEscapeThroughDestinationLinks() + { + var path = Path.Combine(root, "link.tar"); + using (var file = File.Create(path)) + using (var writer = new TarWriter(file)) + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "alias/file" }); + var destination = Directory.CreateDirectory(Path.Combine(root, "out")).FullName; + var outside = Directory.CreateDirectory(Path.Combine(root, "outside")).FullName; + Directory.CreateSymbolicLink(Path.Combine(destination, "alias"), outside); + var result = await service.ExtractAsync(path, destination); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.AreEqual(1, result.ItemsSkipped); + Assert.IsNull(new FileInfo(Path.Combine(destination, "link")).LinkTarget); + } + + [TestMethod] + public async Task ExtractionConflictNamesAreEscapedWithoutChangingDestination() + { + const string name = "spoof\u202E.txt"; + var path = Zip((name, "new")); + var destination = Directory.CreateDirectory(Path.Combine(root, "out")).FullName; + var target = Path.Combine(destination, name); + File.WriteAllText(target, "old"); + ArchiveConflict? conflict = null; + var result = await service.ExtractAsync(path, destination, new ArchiveExtractOptions + { + ResolveConflict = (item, _) => + { + conflict = item; + return Task.FromResult(new Files.Platform.Abstractions.FileOperations.ConflictResolution(Files.Platform.Abstractions.FileOperations.ConflictAction.Skip)); + } + }); + Assert.IsTrue(result.Succeeded, result.Error); + Assert.IsNotNull(conflict); + Assert.IsFalse(conflict.EntryPath.Contains('\u202E')); + Assert.AreEqual(target, conflict.DestinationPath); + Assert.AreEqual("old", File.ReadAllText(target)); + } + + [TestMethod] + public async Task LinksAreHiddenAndCannotBeOpened() + { + var path = Path.Combine(root, "links.tar"); + using (var file = File.Create(path)) + using (var writer = new TarWriter(file)) + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "link") { LinkName = "/etc/passwd" }); + var folder = (ArchiveFolder)(await new ArchiveStorableRoute(service).TryGetAsync(path)).Item!; + var count = 0; + await foreach (var child in folder.GetItemsAsync()) count++; + Assert.AreEqual(0, count); + await Assert.ThrowsAsync(() => service.OpenEntryAsync(path, "link")); + } + } +} diff --git a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs index ea910ebdf741..cebdd6b7e3f6 100644 --- a/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs +++ b/tests/Files.Platform.Tests/Archives/ArchiveServiceTests.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; using Files.Platform.Abstractions.Archives; +using Files.App.Storage.Archives; using Files.Platform.Abstractions.FileOperations; using Files.Platform.Linux.Archives; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -681,6 +682,19 @@ public async Task PasswordProtectedZip() await Assert.ThrowsExactlyAsync(() => service.ExtractAsync(archive, Out, new ArchiveExtractOptions { Password = "wrong" })); Assert.AreEqual(0, Directory.GetFileSystemEntries(Out).Length); + await Assert.ThrowsExactlyAsync(() => service.OpenEntryAsync(archive, "Documents/a.txt")); + await Assert.ThrowsExactlyAsync(() => service.OpenEntryAsync(archive, "Documents/a.txt", "wrong")); + using (var stream = await service.OpenEntryAsync(archive, "Documents/a.txt", "s3cret")) + using (var reader = new StreamReader(stream)) + Assert.AreEqual("alpha", await reader.ReadToEndAsync()); + + var prompt = new TestPasswordPrompt(); + var route = new ArchiveStorableRoute(service, prompt); + var member = (ArchiveEntryFile)(await route.TryGetAsync(archive + "/Documents/a.txt")).Item!; + using (var stream = await member.OpenStreamAsync(FileAccess.Read)) + Assert.AreEqual(5, stream.Length); + Assert.AreEqual(2, prompt.Requests); + var ok = await service.ExtractAsync(archive, Out, new ArchiveExtractOptions { Password = "s3cret" }); Assert.IsTrue(ok.Succeeded, ok.Error); Assert.AreEqual("alpha", File.ReadAllText(Path.Combine(Out, "Documents", "a.txt"))); @@ -757,6 +771,16 @@ private static bool Run(string tool, string workingDirectory, IReadOnlyList RequestPasswordAsync(string archivePath, bool retry, CancellationToken cancellationToken = default) + { + Assert.AreEqual(Requests > 0, retry); + return Task.FromResult(++Requests == 1 ? "wrong" : "s3cret"); + } + } + private sealed class FakeSevenZip : ISevenZipRunner { private readonly string? binary; diff --git a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj index 5194ff8ab78d..cf89aa1f7624 100644 --- a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj +++ b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj @@ -41,4 +41,8 @@ + + + + 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() { diff --git a/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs b/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs index 457bcf3725a1..da2ab0a32af4 100644 --- a/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs +++ b/tests/Files.Platform.Tests/Mime/MimeTypeServiceTests.cs @@ -30,6 +30,37 @@ private static LinuxMimeTypeService Create(XdgFixture fx, string lang = "en-US") return new LinuxMimeTypeService(fx.Directories, CultureInfo.GetCultureInfo(lang)); } + [TestMethod] + public async Task MimeXml_InstalledAfterFirstLookup_IsPickedUp() + { + using var fx = new XdgFixture(); + fx.Write("usr-share/mime/globs2", Globs2); + var svc = new LinuxMimeTypeService(fx.Directories, CultureInfo.GetCultureInfo("en-US")) { RecheckInterval = System.TimeSpan.Zero }; + + Assert.AreEqual("application-x-newtype", await svc.GetIconNameAsync("application/x-newtype")); + + fx.Write("usr-share/mime/application/x-newtype.xml", + ""); + + Assert.AreEqual("custom-icon", await svc.GetIconNameAsync("application/x-newtype")); + } + + [TestMethod] + public async Task MimeXml_HigherPriorityOverrideInstalledLater_IsPickedUp() + { + using var fx = new XdgFixture(); + fx.Write("usr-share/mime/globs2", Globs2); + const string xml = ""; + fx.Write("usr-share/mime/application/x-over.xml", string.Format(xml, "system-icon")); + var svc = new LinuxMimeTypeService(fx.Directories, CultureInfo.GetCultureInfo("en-US")) { RecheckInterval = System.TimeSpan.Zero }; + + Assert.AreEqual("system-icon", await svc.GetIconNameAsync("application/x-over")); + + fx.Write("data/mime/application/x-over.xml", string.Format(xml, "user-icon")); + + Assert.AreEqual("user-icon", await svc.GetIconNameAsync("application/x-over")); + } + [TestMethod] public async Task Glob_SimpleExtension_CaseInsensitive() {