diff --git a/.github/workflows/nextcloudfileproviderkit.yml b/.github/workflows/nextcloudfileproviderkit.yml new file mode 100644 index 0000000000000..0a0cebabae21f --- /dev/null +++ b/.github/workflows/nextcloudfileproviderkit.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-FileCopyrightText: 2025 Iva Horn +# SPDX-License-Identifier: LGPL-3.0-or-later + +name: NextcloudFileProviderKit + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + Lint: + runs-on: macos-latest + + defaults: + run: + working-directory: shell_integration/MacOSX/NextcloudFileProviderKit + + steps: + - uses: actions/checkout@v4 + + - name: SwiftFormat + run: swiftformat --lint . --reporter github-actions-log + + Tests: + runs-on: macos-latest + + defaults: + run: + working-directory: shell_integration/MacOSX/NextcloudFileProviderKit + + steps: + - uses: actions/checkout@v4 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Run Tests + run: xcodebuild clean build test -scheme NextcloudFileProviderKit -destination "platform=macOS,name=My Mac" diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/.gitignore b/shell_integration/MacOSX/NextcloudFileProviderKit/.gitignore new file mode 100644 index 0000000000000..085e1d6c762ba --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/.gitignore @@ -0,0 +1,92 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +.DS_Store + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/.swift-version b/shell_integration/MacOSX/NextcloudFileProviderKit/.swift-version new file mode 100644 index 0000000000000..913671cdf7af5 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/.swift-version @@ -0,0 +1 @@ +6.2 \ No newline at end of file diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/.swiftformat b/shell_integration/MacOSX/NextcloudFileProviderKit/.swiftformat new file mode 100644 index 0000000000000..62c2fa234fc88 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/.swiftformat @@ -0,0 +1,2 @@ +--indent-case true +--trailing-commas never \ No newline at end of file diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/CC0-1.0.txt b/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000000000..0e259d42c9967 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/LGPL-3.0-or-later.txt b/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/LGPL-3.0-or-later.txt new file mode 100644 index 0000000000000..f68378b4b54e9 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/LGPL-3.0-or-later.txt @@ -0,0 +1,165 @@ +GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates + the terms and conditions of version 3 of the GNU General Public + License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser + General Public License, and the "GNU GPL" refers to version 3 of the GNU + General Public License. + + "The Library" refers to a covered work governed by this License, + other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided + by the Library, but which is not otherwise based on the Library. + Defining a subclass of a class defined by the Library is deemed a mode + of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an + Application with the Library. The particular version of the Library + with which the Combined Work was made is also called the "Linked + Version". + + The "Minimal Corresponding Source" for a Combined Work means the + Corresponding Source for the Combined Work, excluding any source code + for portions of the Combined Work that, considered in isolation, are + based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the + object code and/or source code for the Application, including any data + and utility programs needed for reproducing the Combined Work from the + Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License + without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a + facility refers to a function or data to be supplied by an Application + that uses the facility (other than as an argument passed when the + facility is invoked), then you may convey a copy of the modified + version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from + a header file that is part of the Library. You may convey such object + code under terms of your choice, provided that, if the incorporated + material is not limited to numerical parameters, data structure + layouts and accessors, or small macros, inline functions and templates + (ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, + taken together, effectively do not restrict modification of the + portions of the Library contained in the Combined Work and reverse + engineering for debugging such modifications, if you also do each of + the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the + Library side by side in a single library together with other library + facilities that are not Applications and are not covered by this + License, and convey such a combined library under terms of your + choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions + of the GNU Lesser General Public License from time to time. Such new + versions will be similar in spirit to the present version, but may + differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the + Library as you received it specifies that a certain numbered version + of the GNU Lesser General Public License "or any later version" + applies to it, you have the option of following the terms and + conditions either of that published version or of any later version + published by the Free Software Foundation. If the Library as you + received it does not specify a version number of the GNU Lesser + General Public License, you may choose any version of the GNU Lesser + General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide + whether future versions of the GNU Lesser General Public License shall + apply, that proxy's public statement of acceptance of any version is + permanent authorization for you to choose that version for the + Library. diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/LicenseRef-NextcloudTrademarks.txt b/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/LicenseRef-NextcloudTrademarks.txt new file mode 100644 index 0000000000000..464a30b58bdcd --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/LICENSES/LicenseRef-NextcloudTrademarks.txt @@ -0,0 +1,9 @@ +The Nextcloud marks +Nextcloud and the Nextcloud logo is a registered trademark of Nextcloud GmbH in Germany and/or other countries. +These guidelines cover the following marks pertaining both to the product names and the logo: “Nextcloud” +and the blue/white cloud logo with or without the word Nextcloud; the service “Nextcloud Enterprise”; +and our products: “Nextcloud Files”; “Nextcloud Groupware” and “Nextcloud Talk”. +This set of marks is collectively referred to as the “Nextcloud marks.” + +Use of Nextcloud logos and other marks is only permitted under the guidelines provided by the Nextcloud GmbH. +A copy can be found at https://nextcloud.com/trademarks/ diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.png b/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.png new file mode 100644 index 0000000000000..25eb6718721e3 Binary files /dev/null and b/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.png differ diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.pxd b/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.pxd new file mode 100644 index 0000000000000..14a371f132095 Binary files /dev/null and b/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.pxd differ diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.svg b/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.svg new file mode 100644 index 0000000000000..725524ba6fcf2 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/NextcloudFileProviderKit.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Package.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Package.swift new file mode 100644 index 0000000000000..b953100bc2a1e --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Package.swift @@ -0,0 +1,66 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NextcloudFileProviderKit", + platforms: [ + .iOS(.v16), + .macOS(.v11), + .visionOS(.v1) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "NextcloudFileProviderKit", + targets: ["NextcloudFileProviderKit"] + ) + ], + dependencies: [ + .package(url: "https://github.com/nextcloud/NextcloudCapabilitiesKit.git", from: "2.4.0"), + .package(url: "https://github.com/nextcloud/NextcloudKit", from: "7.2.3"), + .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.55.0"), + .package(url: "https://github.com/realm/realm-swift.git", from: "20.0.1"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0") + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "NextcloudFileProviderKit", + dependencies: [ + .product(name: "NextcloudCapabilitiesKit", package: "NextcloudCapabilitiesKit"), + .product(name: "NextcloudKit", package: "NextcloudKit"), + .product(name: "RealmSwift", package: "realm-swift") + ] + ), + .target( + name: "NextcloudFileProviderKitMocks", + dependencies: [ + "NextcloudFileProviderKit" + ] + ), + .target( + name: "TestInterface", + dependencies: [ + "NextcloudFileProviderKit", + "NextcloudFileProviderKitMocks", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOWebSocket", package: "swift-nio") + ], + path: "Tests/Interface" + ), + .testTarget( + name: "TestInterfaceTests", + dependencies: ["NextcloudFileProviderKit", "TestInterface"], + path: "Tests/InterfaceTests" + ), + .testTarget( + name: "NextcloudFileProviderKitTests", + dependencies: ["NextcloudFileProviderKit", "TestInterface"] + ) + ] +) diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/README.md b/shell_integration/MacOSX/NextcloudFileProviderKit/README.md new file mode 100644 index 0000000000000..200e6bbc23856 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/README.md @@ -0,0 +1,56 @@ + + +
+ Logo of NextcloudFileProviderKit +
+ +# NextcloudFileProviderKit + +NextcloudFileProviderKit is a Swift package designed to simplify the development of Nextcloud synchronization applications on Apple devices using the [File Provider Framework](https://developer.apple.com/documentation/FileProvider). This package provides the core functionality for virtual files in the macOS Nextcloud client, making it easier for developers to integrate Nextcloud syncing capabilities into their applications. + +NextcloudFileProviderKit depends on NextcloudKit to communicate with the server. + +## Features + +- **Easy Integration**: Seamlessly integrate Nextcloud syncing into your Apple applications using the FileProvider API. +- **Core Functionality**: Provides the essential features needed for handling virtual files, including fetching contents, creating, modifying, and deleting items. +- **macOS Support**: Used as the core functionality package for virtual files in the macOS Nextcloud client. + +## Installation + +To install NextcloudFileProviderKit, add the following to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/nextcloud/NextcloudFileProviderKit.git", from: "1.0.0") +] +``` + +## Usage + +This section has been removed due to being out of dated and frequent changes to the code. +As a reference, you can have a look at [this Xcode project](https://github.com/nextcloud/desktop/tree/master/shell_integration/MacOSX/NextcloudIntegration) in the Nextcloud desktop client. +There are also plans to make this package more self-contained than it currently is and some code will be migrated from the other project to this one. + +## Contributing + +Contributions are welcome! Please feel free to submit a pull request or open an issue if you encounter any problems or have suggestions for improvements. + +### Code Style + +[SwiftFormat](https://github.com/nicklockwood/SwiftFormat) was introduced into this project. +Before submitting a pull request, please ensure that your code changes comply with the currently configured code style. +You can run the following command in the root of the package repository clone: + +```bash +swift package plugin --allow-writing-to-package-directory swiftformat --verbose --cache ignore +``` + +## License + +This project is licensed under the LGPLv3 License. See the [LICENSE](LICENSE) file for more details. + +--- diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/REUSE.toml b/shell_integration/MacOSX/NextcloudFileProviderKit/REUSE.toml new file mode 100644 index 0000000000000..01a9b0f3fe562 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/REUSE.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: LGPL-3.0-or-later +version = 1 +PDX-PackageName = "NextcloudFileProviderKit" +SPDX-PackageSupplier = "Nextcloud " +SPDX-PackageDownloadLocation = "https://github.com/nextcloud/nextcloudfileproviderkit" + +[[annotations]] +path = [ + "NextcloudFileProviderKit.png", + "NextcloudFileProviderKit.pxd", + "NextcloudFileProviderKit.svg" +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2024 Nextcloud GmbH" +SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" + +[[annotations]] +path = [ + ".gitignore", + ".swiftformat", + ".swift-version", + "Package.swift", + "Sources/NextcloudFileProviderKit/Documentation.docc/theme-settings.json" +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2025 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "LGPL-3.0-or-later" diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift new file mode 100644 index 0000000000000..1e18e44af11f2 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import RealmSwift + +public extension FilesDatabaseManager { + private func fullServerPathUrl(for metadata: any ItemMetadata) -> String { + if metadata.ocId == NSFileProviderItemIdentifier.rootContainer.rawValue { + metadata.serverUrl + } else { + metadata.serverUrl + "/" + metadata.fileName + } + } + + func childItems(directoryMetadata: SendableItemMetadata) -> [SendableItemMetadata] { + let directoryServerUrl = fullServerPathUrl(for: directoryMetadata) + return itemMetadatas + .where { $0.serverUrl.starts(with: directoryServerUrl) } + .toUnmanagedResults() + } + + func childItemCount(directoryMetadata: SendableItemMetadata) -> Int { + let directoryServerUrl = fullServerPathUrl(for: directoryMetadata) + return itemMetadatas + .where { $0.serverUrl.starts(with: directoryServerUrl) } + .count + } + + func parentDirectoryMetadataForItem(_ itemMetadata: SendableItemMetadata) -> SendableItemMetadata? { + self.itemMetadata(account: itemMetadata.account, locatedAtRemoteUrl: itemMetadata.serverUrl) + } + + func directoryMetadata(ocId: String) -> SendableItemMetadata? { + if let metadata = itemMetadatas.where({ $0.ocId == ocId && $0.directory }).first { + return SendableItemMetadata(value: metadata) + } + + return nil + } + + // Deletes all metadatas related to the info of the directory provided + func deleteDirectoryAndSubdirectoriesMetadata( + ocId: String + ) -> [SendableItemMetadata]? { + guard let directoryMetadata = itemMetadatas + .where({ $0.ocId == ocId && $0.directory }) + .first + else { + logger.error("Could not find directory metadata for ocId. Not proceeding with deletion.", [.item: ocId]) + return nil + } + + let directoryMetadataCopy = SendableItemMetadata(value: directoryMetadata) + let directoryOcId = directoryMetadata.ocId + let directoryUrlPath = directoryMetadata.serverUrl + "/" + directoryMetadata.fileName + let directoryAccount = directoryMetadata.account + let directoryEtag = directoryMetadata.etag + + logger.debug("Deleting root directory metadata in recursive delete.", [.eTag: directoryEtag, .item: directoryMetadata.ocId, .url: directoryUrlPath]) + + let database = ncDatabase() + do { + try database.write { directoryMetadata.deleted = true } + } catch { + logger.error("Failure to delete root directory metadata in recursive delete.", [.error: error, .eTag: directoryEtag, .item: directoryOcId, .url: directoryUrlPath]) + return nil + } + + var deletedMetadatas: [SendableItemMetadata] = [directoryMetadataCopy] + + let results = itemMetadatas.where { + $0.account == directoryAccount && $0.serverUrl.starts(with: directoryUrlPath) + } + + for result in results { + let inactiveItemMetadata = SendableItemMetadata(value: result) + do { + try database.write { result.deleted = true } + deletedMetadatas.append(inactiveItemMetadata) + } catch { + logger.error("Failure to delete directory metadata child in recursive delete", [.error: error, .eTag: directoryEtag, .item: directoryOcId, .url: directoryUrlPath]) + } + } + + logger.debug("Completed deletions in directory recursive delete.", [.eTag: directoryEtag, .item: directoryOcId, .url: directoryUrlPath]) + + return deletedMetadatas + } + + func renameDirectoryAndPropagateToChildren( + ocId: String, newServerUrl: String, newFileName: String + ) -> [SendableItemMetadata]? { + guard let directoryMetadata = itemMetadatas + .where({ $0.ocId == ocId && $0.directory }) + .first + else { + logger.error("Could not find a directory with ocID \(ocId), cannot proceed with recursive renaming.", [.item: ocId]) + return nil + } + + let oldItemServerUrl = directoryMetadata.serverUrl + let oldItemFilename = directoryMetadata.fileName + let oldDirectoryServerUrl = oldItemServerUrl + "/" + oldItemFilename + let newDirectoryServerUrl = newServerUrl + "/" + newFileName + let childItemResults = itemMetadatas.where { + $0.account == directoryMetadata.account && + $0.serverUrl.starts(with: oldDirectoryServerUrl) + } + + renameItemMetadata(ocId: ocId, newServerUrl: newServerUrl, newFileName: newFileName) + logger.debug("Renamed root renaming directory from \"\(oldDirectoryServerUrl)\" to \"\(newDirectoryServerUrl)\".", [.item: ocId]) + + do { + let database = ncDatabase() + try database.write { + for childItem in childItemResults { + let oldServerUrl = childItem.serverUrl + let movedServerUrl = oldServerUrl.replacingOccurrences( + of: oldDirectoryServerUrl, with: newDirectoryServerUrl + ) + childItem.serverUrl = movedServerUrl + database.add(childItem, update: .all) + logger.debug( + """ + Moved childItem at: \(oldServerUrl) + to: \(movedServerUrl) + """) + } + } + } catch { + logger.error("Could not rename directory metadata.", [.error: error, .item: ocId, .url: newServerUrl]) + return nil + } + + return itemMetadatas + .where { + $0.account == directoryMetadata.account && + $0.serverUrl.starts(with: newDirectoryServerUrl) + } + .toUnmanagedResults() + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift new file mode 100644 index 0000000000000..32f47d6a3e824 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import RealmSwift + +public extension FilesDatabaseManager { + func set(keepDownloaded: Bool, for metadata: SendableItemMetadata) throws -> SendableItemMetadata? { + guard #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) else { + let error = "Could not update keepDownloaded status for item because the system does not support this state." + logger.error(error, [.item: metadata.ocId, .name: metadata.fileName]) + + throw NSError( + domain: Self.errorDomain, + code: NSFeatureUnsupportedError, + userInfo: [NSLocalizedDescriptionKey: error] + ) + } + + guard let result = itemMetadatas.where({ $0.ocId == metadata.ocId }).first else { + let error = "Did not update keepDownloaded for item metadata as it was not found." + logger.error(error, [.item: metadata.ocId, .name: metadata.fileName]) + + throw NSError( + domain: Self.errorDomain, + code: ErrorCode.metadataNotFound.rawValue, + userInfo: [NSLocalizedDescriptionKey: error] + ) + } + + try ncDatabase().write { + result.keepDownloaded = keepDownloaded + + logger.debug("Updated keepDownloaded status for item metadata.", [.item: metadata.ocId, .name: metadata.fileName]) + } + return SendableItemMetadata(value: result) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift new file mode 100644 index 0000000000000..1543366adc627 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import RealmSwift + +extension FilesDatabaseManager { + func trashedItemMetadatas(account: Account) -> [SendableItemMetadata] { + ncDatabase() + .objects(RealmItemMetadata.self) + .where { + $0.account == account.ncKitAccount && $0.serverUrl.starts(with: account.trashUrl) + } + .toUnmanagedResults() + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift new file mode 100644 index 0000000000000..c421ad5d26c03 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift @@ -0,0 +1,629 @@ +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import RealmSwift + +/// +/// Realm database abstraction and management. +/// +public final class FilesDatabaseManager: Sendable { + public enum ErrorCode: Int { + case metadataNotFound = -1000 + case parentMetadataNotFound = -1001 + } + + public enum ErrorUserInfoKey: String { + case missingParentServerUrlAndFileName = "MissingParentServerUrlAndFileName" + } + + static let errorDomain = "FilesDatabaseManager" + + static func error(code: ErrorCode, userInfo: [String: String]) -> NSError { + NSError(domain: errorDomain, code: code.rawValue, userInfo: userInfo) + } + + static func parentMetadataNotFoundError(itemUrl: String) -> NSError { + error( + code: .parentMetadataNotFound, + userInfo: [ErrorUserInfoKey.missingParentServerUrlAndFileName.rawValue: itemUrl] + ) + } + + private static let schemaVersion = SchemaVersion.addedIsLockFileOfLocalOriginToRealmItemMetadata + let logger: FileProviderLogger + let account: Account + + var itemMetadatas: Results { ncDatabase().objects(RealmItemMetadata.self) } + + /// + /// Convenience initializer which defines a default configuration for Realm. + /// + /// - Parameters: + /// - customConfiguration: Optional custom Realm configuration to use instead of the default one. + /// - account: The Nextcloud account for which the database is being created. + /// - customDatabaseDirectory: Optional custom directory where the database files should be stored. If not provided, the default directory will be used. + /// + public init(realmConfiguration customConfiguration: Realm.Configuration? = nil, account: Account, databaseDirectory customDatabaseDirectory: URL? = nil, fileProviderDomainIdentifier: NSFileProviderDomainIdentifier, log: any FileProviderLogging) { + self.account = account + logger = FileProviderLogger(category: "FilesDatabaseManager", log: log) + + let defaultDatabaseDirectory = FileManager.default.fileProviderDomainSupportDirectory(for: fileProviderDomainIdentifier) + + guard let databaseDirectory = customDatabaseDirectory ?? defaultDatabaseDirectory else { + logger.fault("Neither custom nor default database directory defined!") + return + } + + let databaseLocation = databaseDirectory + .appendingPathComponent(fileProviderDomainIdentifier.rawValue) + .appendingPathExtension("realm") + + let configuration = customConfiguration ?? Realm.Configuration( + fileURL: databaseLocation, + schemaVersion: Self.schemaVersion.rawValue, + migrationBlock: { migration, oldSchemaVersion in + if oldSchemaVersion == SchemaVersion.initial.rawValue { + var localFileMetadataOcIds = Set() + + migration.enumerateObjects(ofType: "LocalFileMetadata") { oldObject, _ in + guard let oldObject, let lfmOcId = oldObject["ocId"] as? String else { + return + } + + localFileMetadataOcIds.insert(lfmOcId) + } + + migration.enumerateObjects(ofType: RealmItemMetadata.className()) { _, newObject in + guard let newObject, + let imOcId = newObject["ocId"] as? String, + localFileMetadataOcIds.contains(imOcId) + else { return } + + newObject["downloaded"] = true + newObject["uploaded"] = true + } + } + + }, + objectTypes: [RealmItemMetadata.self, RemoteFileChunk.self] + ) + + Realm.Configuration.defaultConfiguration = configuration + + do { + _ = try Realm() + logger.info("Successfully created Realm.") + } catch { + logger.fault("Error creating Realm: \(error)") + } + } + + func ncDatabase() -> Realm { + let realm = try! Realm() + realm.refresh() + return realm + } + + public func anyItemMetadatasForAccount(_ account: String) -> Bool { + !itemMetadatas.where { $0.account == account }.isEmpty + } + + public func itemMetadata(ocId: String) -> SendableItemMetadata? { + // Realm objects are live-fire, i.e. they will be changed and invalidated according to + // changes in the db. + // + // Let's therefore create a copy + if let itemMetadata = itemMetadatas.where({ $0.ocId == ocId }).first { + return SendableItemMetadata(value: itemMetadata) + } + + return nil + } + + public func itemMetadata(_ identifier: NSFileProviderItemIdentifier) -> SendableItemMetadata? { + itemMetadata(ocId: identifier.rawValue) + } + + /// + /// Look up the item metadata by its account identifier and remote address. + /// + /// - Parameters: + /// - account: The account this item is scoped by. + /// - remoteURL: The full remote URL of the item as a `String`. + /// + /// - Returns: Metadata related to the item found by the parameters. + /// + public func itemMetadata(account: String, locatedAtRemoteUrl rawRemoteURL: String) -> SendableItemMetadata? { + guard let remoteURLComponents = URLComponents(string: rawRemoteURL) else { + return nil + } + + guard let remoteURL = remoteURLComponents.url else { + return nil + } + + // Get the file name but also take the possible fragment into consideration which is not part of a URL path but a file name. + var fileName = remoteURL.lastPathComponent + + if let fragment = remoteURL.fragment { + fileName = "\(fileName)#\(fragment)" + } + + // Derive the parent address by removing the last path component and discarding the fragment which may actually be part of the file name and not a URL fragment. + var parentURLComponents = remoteURLComponents + parentURLComponents.path = remoteURL.deletingLastPathComponent().path + parentURLComponents.fragment = nil + + guard var rawParentURL = parentURLComponents.url?.absoluteString.removingPercentEncoding else { + return nil + } + + // Remove any trailing slash. + if rawParentURL.hasSuffix("/") { + rawParentURL.removeLast() + } + + if let metadata = itemMetadatas.where({ + $0.account == account && $0.serverUrl == rawParentURL && $0.fileName == fileName + }).first { + return SendableItemMetadata(value: metadata) + } + + return nil + } + + /// + /// Fetch the metadata object for the root container of the given account. + /// + /// This is useful for when you have only the `NSFileProviderItemIdentifier.rootContainer` but no `ocId` to look up metadata by. + /// + public func rootItemMetadata(account: Account) -> SendableItemMetadata? { + guard let object = itemMetadatas.where({ $0.account == account.ncKitAccount && $0.directory && $0.path == Account.webDavFilesUrlSuffix }).first else { + return nil + } + + return SendableItemMetadata(value: object) + } + + public func itemMetadatas(account: String) -> [SendableItemMetadata] { + itemMetadatas + .where { $0.account == account } + .toUnmanagedResults() + } + + public func itemMetadatas( + account: String, underServerUrl serverUrl: String + ) -> [SendableItemMetadata] { + itemMetadatas + .where { $0.account == account && $0.serverUrl.starts(with: serverUrl) } + .toUnmanagedResults() + } + + private func processItemMetadatasToDelete( + existingMetadatas: Results, + updatedMetadatas: [SendableItemMetadata] + ) -> [RealmItemMetadata] { + var deletedMetadatas: [RealmItemMetadata] = [] + + for existingMetadata in existingMetadatas { + guard !updatedMetadatas.contains(where: { $0.ocId == existingMetadata.ocId }), + let metadataToDelete = itemMetadatas.where({ $0.ocId == existingMetadata.ocId }).first + else { continue } + + deletedMetadatas.append(metadataToDelete) + + logger.debug("Deleting item metadata during update.", [.item: existingMetadata.ocId]) + } + + return deletedMetadatas + } + + private func processItemMetadatasToUpdate(existingMetadatas: Results, updatedMetadatas: [SendableItemMetadata], keepExistingDownloadState: Bool) -> (newMetadatas: [SendableItemMetadata], updatedMetadatas: [SendableItemMetadata], directoriesNeedingRename: [SendableItemMetadata]) { + var returningNewMetadatas: [SendableItemMetadata] = [] + var returningUpdatedMetadatas: [SendableItemMetadata] = [] + var directoriesNeedingRename: [SendableItemMetadata] = [] + + for var updatedMetadata in updatedMetadatas { + if let existingMetadata = existingMetadatas.first(where: { $0.ocId == updatedMetadata.ocId }) { + if existingMetadata.status == Status.normal.rawValue, !existingMetadata.isInSameDatabaseStoreableRemoteState(updatedMetadata) { + if updatedMetadata.directory, + updatedMetadata.serverUrl != existingMetadata.serverUrl || + updatedMetadata.fileName != existingMetadata.fileName + { + directoriesNeedingRename.append(updatedMetadata) + } + + if keepExistingDownloadState { + updatedMetadata.downloaded = existingMetadata.downloaded + } + + updatedMetadata.visitedDirectory = existingMetadata.visitedDirectory + updatedMetadata.keepDownloaded = existingMetadata.keepDownloaded + updatedMetadata.lockToken = existingMetadata.lockToken + + returningUpdatedMetadatas.append(updatedMetadata) + + logger.debug("Updated existing item metadata.", [ + .item: updatedMetadata.ocId, + .eTag: updatedMetadata.etag, + .name: updatedMetadata.fileName, + .syncTime: updatedMetadata.syncTime.description + ]) + } else { + logger.debug("Skipping item metadata update; same as existing, or still in transit.", [ + .item: updatedMetadata.ocId, + .eTag: updatedMetadata.etag, + .name: updatedMetadata.fileName, + .syncTime: updatedMetadata.syncTime.description + ]) + } + + } else { // This is a new metadata + returningNewMetadatas.append(updatedMetadata) + + logger.debug("Created new item metadata during update.", [.item: updatedMetadata.ocId]) + } + } + + return (returningNewMetadatas, returningUpdatedMetadatas, directoriesNeedingRename) + } + + // ONLY HANDLES UPDATES FOR IMMEDIATE CHILDREN + // (in case of directory renames/moves, the changes are recursed down) + public func depth1ReadUpdateItemMetadatas( + account: String, + serverUrl: String, + updatedMetadatas: [SendableItemMetadata], + keepExistingDownloadState: Bool + ) -> ( + newMetadatas: [SendableItemMetadata]?, + updatedMetadatas: [SendableItemMetadata]?, + deletedMetadatas: [SendableItemMetadata]? + ) { + let database = ncDatabase() + + do { + // Find the metadatas that we previously knew to be on the server for this account + // (we need to check if they were uploaded to prevent deleting ignored/lock files) + // + // - the ones that do exist remotely still are either the same or have been updated + // - the ones that don't have been deleted + var cleanServerUrl = serverUrl + if cleanServerUrl.last == "/" { + cleanServerUrl.removeLast() + } + let existingMetadatas = database + .objects(RealmItemMetadata.self) + .where { + // Don't worry — root will be updated at the end of this method if is the target + $0.ocId != NSFileProviderItemIdentifier.rootContainer.rawValue && + $0.account == account && + $0.serverUrl == cleanServerUrl && + $0.uploaded + } + + var updatedChildMetadatas = updatedMetadatas + + let readTargetMetadata: SendableItemMetadata? = if let targetMetadata = updatedMetadatas.first { + if targetMetadata.directory { + updatedChildMetadatas.removeFirst() + } else { + targetMetadata + } + } else { + nil + } + + let metadatasToDelete = processItemMetadatasToDelete( + existingMetadatas: existingMetadatas, + updatedMetadatas: updatedChildMetadatas + ).map { + var metadata = SendableItemMetadata(value: $0) + metadata.deleted = true + return metadata + } + + let metadatasToChange = processItemMetadatasToUpdate( + existingMetadatas: existingMetadatas, + updatedMetadatas: updatedChildMetadatas, + keepExistingDownloadState: keepExistingDownloadState + ) + + var metadatasToUpdate = metadatasToChange.updatedMetadatas + var metadatasToCreate = metadatasToChange.newMetadatas + let directoriesNeedingRename = metadatasToChange.directoriesNeedingRename + + for metadata in directoriesNeedingRename { + if let updatedDirectoryChildren = renameDirectoryAndPropagateToChildren( + ocId: metadata.ocId, + newServerUrl: metadata.serverUrl, + newFileName: metadata.fileName + ) { + metadatasToUpdate += updatedDirectoryChildren + } + } + + if var readTargetMetadata { + if readTargetMetadata.directory { + readTargetMetadata.visitedDirectory = true + } + + if let existing = itemMetadata(ocId: readTargetMetadata.ocId) { + if existing.status == Status.normal.rawValue, + !existing.isInSameDatabaseStoreableRemoteState(readTargetMetadata) + { + logger.info("Depth 1 read target changed: \(readTargetMetadata.ocId)") + if keepExistingDownloadState { + readTargetMetadata.downloaded = existing.downloaded + } + readTargetMetadata.keepDownloaded = existing.keepDownloaded + metadatasToUpdate.insert(readTargetMetadata, at: 0) + } + } else { + logger.info("Depth 1 read target is new: \(readTargetMetadata.ocId)") + metadatasToCreate.insert(readTargetMetadata, at: 0) + } + } + + try database.write { + // Do not delete the metadatas that have been deleted + database.add(metadatasToDelete.map { RealmItemMetadata(value: $0) }, update: .modified) + database.add(metadatasToUpdate.map { RealmItemMetadata(value: $0) }, update: .modified) + database.add(metadatasToCreate.map { RealmItemMetadata(value: $0) }, update: .all) + } + + return (metadatasToCreate, metadatasToUpdate, metadatasToDelete) + } catch { + logger.error("Could not update any item metadatas.", [.error: error]) + return (nil, nil, nil) + } + } + + // If setting a downloading or uploading status, also modified the relevant boolean properties + // of the item metadata object + public func setStatusForItemMetadata( + _ metadata: SendableItemMetadata, status: Status + ) -> SendableItemMetadata? { + guard let result = itemMetadatas.where({ $0.ocId == metadata.ocId }).first else { + logger.debug("Did not update status for item metadata as it was not found. ocID: \(metadata.ocId)") + return nil + } + + do { + let database = ncDatabase() + try database.write { + result.status = status.rawValue + if result.isDownload { + result.downloaded = false + } else if result.isUpload { + result.uploaded = false + result.chunkUploadId = UUID().uuidString + } else if status == .normal, metadata.isUpload { + result.chunkUploadId = nil + } + + logger.debug("Updated status for item metadata.", [ + .item: metadata.ocId, + .eTag: metadata.etag, + .name: metadata.fileName, + .syncTime: metadata.syncTime + ]) + } + return SendableItemMetadata(value: result) + } catch { + logger.error("Could not update status for item metadata.", [ + .item: metadata.ocId, + .eTag: metadata.etag, + .error: error, + .name: metadata.fileName + ]) + } + + return nil + } + + public func addItemMetadata(_ metadata: SendableItemMetadata) { + let database = ncDatabase() + + do { + try database.write { + database.add(RealmItemMetadata(value: metadata), update: .all) + logger.debug("Added item metadata.", [.item: metadata.ocId, .name: metadata.name, .url: metadata.serverUrl]) + } + } catch { + logger.error("Failed to add item metadata.", [.item: metadata.ocId, .name: metadata.name, .url: metadata.serverUrl, .error: error]) + } + } + + @discardableResult public func deleteItemMetadata(ocId: String) -> Bool { + do { + let results = itemMetadatas.where { $0.ocId == ocId } + let database = ncDatabase() + try database.write { + logger.debug("Deleting item metadata. \(ocId)") + results.forEach { $0.deleted = true } + } + return true + } catch { + logger.error("Could not delete item metadata with ocId \(ocId).", [.error: error]) + return false + } + } + + public func renameItemMetadata(ocId: String, newServerUrl: String, newFileName: String) { + guard let itemMetadata = itemMetadatas.where({ $0.ocId == ocId }).first else { + logger.error("Could not find an item with ocID \(ocId) to rename to \(newFileName)") + return + } + + do { + let database = ncDatabase() + try database.write { + let oldFileName = itemMetadata.fileName + let oldServerUrl = itemMetadata.serverUrl + + itemMetadata.fileName = newFileName + itemMetadata.fileNameView = newFileName + itemMetadata.serverUrl = newServerUrl + + database.add(itemMetadata, update: .all) + + logger.debug("Renamed item \(oldFileName) to \(newFileName), moved from serverUrl: \(oldServerUrl) to serverUrl: \(newServerUrl)") + } + } catch { + logger.error("Could not rename filename of item metadata with ocID: \(ocId) to proposed name \(newFileName) at proposed serverUrl \(newServerUrl).", [.error: error]) + } + } + + public func parentItemIdentifierFromMetadata( + _ metadata: SendableItemMetadata + ) -> NSFileProviderItemIdentifier? { + let homeServerFilesUrl = metadata.urlBase + Account.webDavFilesUrlSuffix + metadata.userId + let trashServerFilesUrl = metadata.urlBase + Account.webDavTrashUrlSuffix + metadata.userId + "/trash" + + if metadata.serverUrl == homeServerFilesUrl { + return .rootContainer + } else if metadata.serverUrl == trashServerFilesUrl { + return .trashContainer + } + + guard let parentDirectoryMetadata = parentDirectoryMetadataForItem(metadata) else { + logger.error("Could not get item parent directory item metadata for metadata.", [.item: metadata.ocId]) + + return nil + } + + return NSFileProviderItemIdentifier(parentDirectoryMetadata.ocId) + } + + public func parentItemIdentifierWithRemoteFallback( + fromMetadata metadata: SendableItemMetadata, + remoteInterface: RemoteInterface, + account: Account + ) async -> NSFileProviderItemIdentifier? { + if let parentItemIdentifier = parentItemIdentifierFromMetadata(metadata) { + return parentItemIdentifier + } + + let (metadatas, _, _, _, _, error) = await Enumerator.readServerUrl( + metadata.serverUrl, + account: account, + remoteInterface: remoteInterface, + dbManager: self, + depth: .target, + log: logger.log + ) + + guard error == nil, let parentMetadata = metadatas?.first else { + logger.error("Could not retrieve parent item identifier remotely.", [ + .error: error, + .item: metadata.ocId, + .name: metadata.fileName + ]) + + return nil + } + return NSFileProviderItemIdentifier(parentMetadata.ocId) + } + + private func managedMaterialisedItemMetadatas(account: String) -> Results { + itemMetadatas.where { candidate in + let belongsToAccount = candidate.account == account + let isVisitedDirectory = candidate.directory && candidate.visitedDirectory + let isDownloadedFile = candidate.directory == false && candidate.downloaded + + return belongsToAccount && (isVisitedDirectory || isDownloadedFile) + } + } + + /// + /// Return metadata for materialized file provider items. + /// + /// - Parameters: + /// - account: The account identifier to filter by. + /// + /// - Returns: An array of sendable metadata objects. + /// + public func materialisedItemMetadatas(account: String) -> [SendableItemMetadata] { + managedMaterialisedItemMetadatas(account: account).toUnmanagedResults() + } + + public func pendingWorkingSetChanges(account: Account, since date: Date) -> (updated: [SendableItemMetadata], deleted: [SendableItemMetadata]) { + let accId = account.ncKitAccount + let pending = managedMaterialisedItemMetadatas(account: accId).where { $0.syncTime > date } + var updated = pending.where { !$0.deleted }.toUnmanagedResults() + var deleted = pending.where { $0.deleted }.toUnmanagedResults() + var handledUpdateOcIds = Set(updated.map(\.ocId)) + + updated + .map { $0.remotePath() } + .forEach { serverUrl in + logger.debug("Checking updated item...", [.url: serverUrl]) + + itemMetadatas + .where { $0.serverUrl == serverUrl && $0.syncTime > date } + .forEach { metadata in + guard !handledUpdateOcIds.contains(metadata.ocId) else { + return + } + + handledUpdateOcIds.insert(metadata.ocId) + let sendableMetadata = SendableItemMetadata(value: metadata) + + if metadata.deleted { + deleted.append(sendableMetadata) + logger.debug("Appended deleted item to working set changes.", [.item: metadata.ocId, .url: serverUrl]) + } else { + updated.append(sendableMetadata) + logger.debug("Appended updated item to working set changes.", [.item: metadata.ocId, .url: serverUrl]) + } + } + } + + let handledDeleteOcIds = Set(deleted.map(\.ocId)) + + deleted + .map { $0.remotePath() } + .forEach { serverUrl in + logger.debug("Verifying deleted item...", [.url: serverUrl]) + + itemMetadatas.where { + $0.serverUrl.starts(with: serverUrl) && $0.syncTime > date + }.forEach { metadata in + guard metadata.isLockFileOfLocalOrigin == false else { + logger.info("Excluding item from deletion because it is a lock file from local origin.", [.item: metadata.ocId]) + return + } + + guard !handledDeleteOcIds.contains(metadata.ocId) else { + return + } + + deleted.append(SendableItemMetadata(value: metadata)) + logger.debug("Appended deleted item to working set changes.", [.item: metadata.ocId, .url: serverUrl]) + } + } + + return (updated, deleted) + } + + public func itemsMetadataByFileNameSuffix(suffix: String) -> [SendableItemMetadata] { + logger.debug("Trying to find files matching pattern \"\(suffix)\".") + + let results = itemMetadatas.where { + $0.fileName.ends(with: suffix) && !$0.directory + } + + guard !results.isEmpty else { + logger.debug("Could not find files matching pattern \"\(suffix)\".") + return [] + } + + let filesMetadata = results.toUnmanagedResults() + logger.debug("Found \(filesMetadata.count) file(s) that match \"\(suffix)\" metadata: \(filesMetadata)") + + return filesMetadata + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift new file mode 100644 index 0000000000000..8019e21014043 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// +/// Different schema versions shipped with this project. +/// +enum SchemaVersion: UInt64 { + case initial = 100 + case deletedLocalFileMetadata = 200 + case addedLockTokenPropertyToRealmItemMetadata = 201 + case addedIsLockFileOfLocalOriginToRealmItemMetadata = 202 +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md new file mode 100644 index 0000000000000..485c9a1e5fc82 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md @@ -0,0 +1,45 @@ + + +# ``NextcloudFileProviderKit`` + +NextcloudFileProviderKit is a Swift package designed to simplify the development of Nextcloud synchronization applications on Apple devices using the File Provider Framework. This package provides the core functionality for virtual files in the macOS Nextcloud client, making it easier for developers to integrate Nextcloud syncing capabilities into their applications. + + +## Topics + +### File Provider + +- ``Enumerator`` +- ``Item`` +- ``MaterialisedEnumerationObserver`` + +### Lock File Support + +Some applications like Microsoft Office and LibreOffice create hidden lock files in the same directory a file opened by them is located in. +They usually equal the name of the opened file with prefixes like `~$` or suffixes like `#`. +These are recognized by the file provider extension and not synchronized to the server. +However, the capabilities of the `files_lock` server app are used to lock the file for editing remotely on the server. + +- ``isLockFileName(_:)`` +- ``originalFileName(fromLockFileName:dbManager:)`` + +### Logging + +This package comes with its own reusable logging solution which builds on top of the unified logging system provided by the platform and a JSON lines based file logging. +It is designed specifically for the implementation of this file provider extension. + +- ``FileProviderLog`` +- ``FileProviderLogDetail`` +- ``FileProviderLogDetailKey`` +- ``FileProviderLogger`` +- ``FileProviderLogging`` +- ``FileProviderLogMessage`` + +### Persistence + +- ``FilesDatabaseManager`` +- ``ItemMetadata`` +- ``SendableItemMetadata`` diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Documentation.docc/theme-settings.json b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Documentation.docc/theme-settings.json new file mode 100644 index 0000000000000..335eb5c59565f --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Documentation.docc/theme-settings.json @@ -0,0 +1,22 @@ +{ + "theme": { + "color": { + "documentation-intro-eyebrow": "rgba(255, 255, 255, 0.6)", + "documentation-intro-figure": "white", + "documentation-intro-fill": "#0082c9", + "documentation-intro-title": "white", + "fill": { + "dark": "#181818", + "light": "white" + }, + "link": "#0082c9", + "text": { + "dark": "#D8D8D8", + "light": "#222222" + } + }, + "typography": { + "html-font": "\"M1 PLUS 1\", \"Open Sans\", system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif" + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift new file mode 100644 index 0000000000000..1f0189543b7ab --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit + +extension Enumerator { + static func handlePagedReadResults( + files: [NKFile], pageIndex: Int, dbManager: FilesDatabaseManager + ) -> (metadatas: [SendableItemMetadata]?, error: NKError?) { + // First PROPFIND contains the target item, but we do not want to report this in the + // retrieved metadatas (the enumeration observers don't expect you to enumerate the + // target item, hence why we always strip the target item out) + let startIndex = pageIndex > 0 ? 0 : 1 + if pageIndex == 0 { + guard let firstFile = files.first else { return (nil, .invalidResponseError) } + var metadata = firstFile.toItemMetadata() + if metadata.directory { + metadata.visitedDirectory = true + if let existingMetadata = dbManager.itemMetadata(ocId: metadata.ocId) { + metadata.downloaded = existingMetadata.downloaded + metadata.keepDownloaded = existingMetadata.keepDownloaded + } + } + dbManager.addItemMetadata(metadata) + } + let metadatas = files[startIndex...].map { $0.toItemMetadata() } + metadatas.forEach { dbManager.addItemMetadata($0) } + return (metadatas, nil) + } + + // With paginated requests, you do not have a way to know what has changed remotely when + // handling the result of an individual PROPFIND request. When handling a paginated read this + // therefore only returns the acquired metadatas. + static func handleDepth1ReadFileOrFolder( + serverUrl: String, + account: Account, + dbManager: FilesDatabaseManager, + files: [NKFile], + pageIndex: Int?, + log: any FileProviderLogging + ) async -> ( + metadatas: [SendableItemMetadata]?, + newMetadatas: [SendableItemMetadata]?, + updatedMetadatas: [SendableItemMetadata]?, + deletedMetadatas: [SendableItemMetadata]?, + readError: NKError? + ) { + let logger = FileProviderLogger(category: "Enumerator", log: log) + + logger.debug("Starting async conversion of NKFiles for serverUrl: \(serverUrl) for user: \(account.ncKitAccount)") + + if let pageIndex { + let (metadatas, error) = + handlePagedReadResults(files: files, pageIndex: pageIndex, dbManager: dbManager) + return (metadatas, nil, nil, nil, error) + } + + guard var (directory, _, files) = await files.toSendableDirectoryMetadata(account: account, directoryToRead: serverUrl) else { + logger.error("Failed to convert array of NKFile to directory and files metadata objects!") + return (nil, nil, nil, nil, .invalidData) + } + + // STORE DATA FOR CURRENTLY SCANNED DIRECTORY + guard directory.directory else { + logger.error("Expected directory metadata but received file metadata!", [.url: serverUrl]) + return (nil, nil, nil, nil, .invalidData) + } + + if let existingMetadata = dbManager.itemMetadata(ocId: directory.ocId) { + directory.downloaded = existingMetadata.downloaded + directory.keepDownloaded = existingMetadata.keepDownloaded + } + + directory.visitedDirectory = true + + files.insert(directory, at: 0) + + let changedMetadatas = dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: serverUrl, + updatedMetadatas: files, + keepExistingDownloadState: true + ) + + return ( + files, + changedMetadatas.newMetadatas, + changedMetadatas.updatedMetadatas, + changedMetadatas.deletedMetadatas, + nil + ) + } + + // READ THIS CAREFULLY. + // + // This method supports paginated and non-paginated reads. Handled by the pageSettings argument. + // Paginated reads is used by enumerateItems, non-paginated reads is used by enumerateChanges. + // + // Paginated reads WILL NOT HANDLE REMOVAL OF REMOTELY DELETED ITEMS FROM THE LOCAL DATABASE. + // Paginated reads WILL ONLY REPORT THE FILES DISCOVERED REMOTELY. + // This means that if you decide to use this method to implement change enumeration, you will + // have to collect the full results of all the pages before proceeding with discovering what + // has changed relative to the state of the local database -- manually! + // + // Non-paginated reads will update the database with all of the discovered files and folders + // that have been found to be created, updated, and deleted. No extra work required. + static func readServerUrl( + _ serverUrl: String, + pageSettings: (page: NSFileProviderPage?, index: Int, size: Int)? = nil, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + domain: NSFileProviderDomain? = nil, + enumeratedItemIdentifier: NSFileProviderItemIdentifier? = nil, + depth: EnumerateDepth = .targetAndDirectChildren, + log: any FileProviderLogging + ) async -> ( + metadatas: [SendableItemMetadata]?, + newMetadatas: [SendableItemMetadata]?, + updatedMetadatas: [SendableItemMetadata]?, + deletedMetadatas: [SendableItemMetadata]?, + nextPage: EnumeratorPageResponse?, + readError: NKError? + ) { + let logger = FileProviderLogger(category: "Enumerator", log: log) + + logger.debug("Starting to read server URL.", [.url: serverUrl]) + + let options: NKRequestOptions = if let pageSettings { + .init( + page: pageSettings.page, + offset: pageSettings.index * pageSettings.size, + count: pageSettings.size + ) + } else { + .init() + } + + let (_, files, data, error) = await remoteInterface.enumerate( + remotePath: serverUrl, + depth: depth, + showHiddenFiles: true, + includeHiddenFiles: [], + requestBody: nil, + account: account, + options: options, + taskHandler: { task in + if let domain, let enumeratedItemIdentifier { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: enumeratedItemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard error == .success else { + logger.error("Read of URL did fail.", [.error: error, .url: serverUrl]) + return (nil, nil, nil, nil, nil, error) + } + + guard let data else { + logger.error("\(depth.rawValue) depth read of url \(serverUrl) did not return data.") + return (nil, nil, nil, nil, nil, error) + } + + // This will be nil if the page settings were also nil, as the server will not give us the + // pagination-related headers. + let nextPage = EnumeratorPageResponse(nkResponseData: data, index: (pageSettings?.index ?? 0) + 1, log: log) + + guard let receivedFile = files.first else { + logger.error("Received no items.", [.url: serverUrl]) + // This is technically possible when doing a paginated request with the index too high. + // It's technically not an error reply. + return ([], nil, nil, nil, nextPage, nil) + } + + // Generally speaking a PROPFIND will provide the target of the PROPFIND as the first result + // That is NOT the case for paginated results with offsets + let isFollowUpPaginatedRequest = (pageSettings?.page != nil && pageSettings?.index ?? 0 > 0) + if !isFollowUpPaginatedRequest { + guard receivedFile.directory || + serverUrl == dbManager.account.davFilesUrl || + receivedFile.fullUrlMatches(dbManager.account.davFilesUrl + "/.") || + (receivedFile.fileName == NextcloudKit.shared.nkCommonInstance.rootFileName && receivedFile.serverUrl == dbManager.account.davFilesUrl) + else { + logger.debug("Read item is a file, converting.", [.url: serverUrl]) + var metadata = receivedFile.toItemMetadata() + let existing = dbManager.itemMetadata(ocId: metadata.ocId) + let isNew = existing == nil + let newItems: [SendableItemMetadata] = isNew ? [metadata] : [] + metadata.lockToken = existing?.lockToken + let updatedItems: [SendableItemMetadata] = isNew ? [] : [metadata] + metadata.downloaded = existing?.downloaded == true + metadata.keepDownloaded = existing?.keepDownloaded == true + dbManager.addItemMetadata(metadata) + return ([metadata], newItems, updatedItems, nil, nextPage, nil) + } + } + + if depth == .target { + var metadata = receivedFile.toItemMetadata() + let existing = dbManager.itemMetadata(ocId: metadata.ocId) + let isNew = existing == nil + let updatedMetadatas = isNew ? [] : [metadata] + let newMetadatas = isNew ? [metadata] : [] + + metadata.downloaded = existing?.downloaded == true + metadata.keepDownloaded = existing?.keepDownloaded == true + dbManager.addItemMetadata(metadata) + + return ([metadata], newMetadatas, updatedMetadatas, nil, nextPage, nil) + } else if depth == .targetAndDirectChildren { + let (allMetadatas, newMetadatas, updatedMetadatas, deletedMetadatas, readError) = await handleDepth1ReadFileOrFolder( + serverUrl: serverUrl, + account: account, + dbManager: dbManager, + files: files, + pageIndex: pageSettings?.index, + log: logger.log + ) + + return (allMetadatas, newMetadatas, updatedMetadatas, deletedMetadatas, nextPage, readError) + } else if let pageIndex = pageSettings?.index { + let (metadatas, error) = handlePagedReadResults( + files: files, pageIndex: pageIndex, dbManager: dbManager + ) + return (metadatas, nil, nil, nil, nextPage, error) + } else { + // Infinite depth unpaged reads are a bad idea + return (nil, nil, nil, nil, nil, .invalidResponseError) + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift new file mode 100644 index 0000000000000..d868a3d3c0923 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit + +extension Enumerator { + static func completeEnumerationObserver( + _ observer: NSFileProviderEnumerationObserver, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + numPage: Int, + trashItems: [NKTrash], + log: any FileProviderLogging + ) { + var metadatas = [SendableItemMetadata]() + for trashItem in trashItems { + let metadata = trashItem.toItemMetadata(account: account) + dbManager.addItemMetadata(metadata) + metadatas.append(metadata) + } + + Task { [metadatas] in + let logger = FileProviderLogger(category: "Enumerator", log: log) + + do { + let items = try await metadatas.toFileProviderItems(account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: log) + + Task { @MainActor in + observer.didEnumerate(items) + logger.info("Did enumerate \(items.count) trash items.") + observer.finishEnumerating(upTo: fileProviderPageforNumPage(numPage)) + } + } catch { + logger.error("Finishing enumeration with error.") + Task { @MainActor in observer.finishEnumeratingWithError(error) } + } + } + } + + static func completeChangesObserver( + _ observer: NSFileProviderChangeObserver, + anchor: NSFileProviderSyncAnchor, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + trashItems: [NKTrash], + log: any FileProviderLogging + ) async { + let logger = FileProviderLogger(category: "Enumerator", log: log) + var newTrashedItems = [NSFileProviderItem]() + + // NKTrash items do not have an etag ; we assume they cannot be modified while they are in + // the trash, so we will just check by ocId + var existingTrashedItems = dbManager.trashedItemMetadatas(account: account) + + for trashItem in trashItems { + if let existingTrashItemIndex = existingTrashedItems.firstIndex( + where: { $0.ocId == trashItem.ocId } + ) { + existingTrashedItems.remove(at: existingTrashItemIndex) + continue + } + + let metadata = trashItem.toItemMetadata(account: account) + dbManager.addItemMetadata(metadata) + + let item = await Item( + metadata: metadata, + parentItemIdentifier: .trashContainer, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ) + newTrashedItems.append(item) + + logger.debug("Will enumerate changed trash item.", [.item: metadata.ocId, .name: metadata.fileName]) + } + + let deletedTrashedItemsIdentifiers = existingTrashedItems.map { + NSFileProviderItemIdentifier($0.ocId) + } + if !deletedTrashedItemsIdentifiers.isEmpty { + for itemIdentifier in deletedTrashedItemsIdentifiers { + dbManager.deleteItemMetadata(ocId: itemIdentifier.rawValue) + } + + logger.debug("Will enumerate deleted trashed items: \(deletedTrashedItemsIdentifiers)") + observer.didDeleteItems(withIdentifiers: deletedTrashedItemsIdentifiers) + } + + if !newTrashedItems.isEmpty { + observer.didUpdate(newTrashedItems) + } + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift new file mode 100644 index 0000000000000..99cbaa761eab9 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -0,0 +1,638 @@ +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit + +/// +/// The `NSFileProviderEnumerator` implementation to enumerate file provider items and related change sets. +/// +public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { + let enumeratedItemIdentifier: NSFileProviderItemIdentifier + private let enumeratedItemMetadata: SendableItemMetadata? + + private var enumeratingSystemIdentifier: Bool { + Self.isSystemIdentifier(enumeratedItemIdentifier) + } + + let domain: NSFileProviderDomain? + let dbManager: FilesDatabaseManager + + private let currentAnchor = NSFileProviderSyncAnchor(ISO8601DateFormatter().string(from: Date()).data(using: .utf8)!) + private let pageItemCount: Int + let logger: FileProviderLogger + let account: Account + let remoteInterface: RemoteInterface + let serverUrl: String + + private static func isSystemIdentifier(_ identifier: NSFileProviderItemIdentifier) -> Bool { + identifier == .rootContainer || identifier == .trashContainer || identifier == .workingSet + } + + public init( + enumeratedItemIdentifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + domain: NSFileProviderDomain? = nil, + pageSize: Int = 100, + log: any FileProviderLogging + ) { + self.enumeratedItemIdentifier = enumeratedItemIdentifier + self.remoteInterface = remoteInterface + self.account = account + self.dbManager = dbManager + self.domain = domain + pageItemCount = pageSize + logger = FileProviderLogger(category: "Enumerator", log: log) + + if Self.isSystemIdentifier(enumeratedItemIdentifier) { + logger.info("Providing enumerator for a system defined container.", [.item: enumeratedItemIdentifier]) + serverUrl = account.davFilesUrl + enumeratedItemMetadata = nil + } else { + logger.debug("Providing enumerator for item with identifier.", [.item: enumeratedItemIdentifier]) + enumeratedItemMetadata = dbManager.itemMetadata( + enumeratedItemIdentifier) + + if let enumeratedItemMetadata { + serverUrl = enumeratedItemMetadata.serverUrl + "/" + enumeratedItemMetadata.fileName + } else { + serverUrl = "" + logger.error("Could not find itemMetadata for file with identifier.", [.item: enumeratedItemIdentifier]) + } + } + + logger.info("Set up enumerator.", [.account: self.account.ncKitAccount, .url: serverUrl]) + super.init() + } + + public func invalidate() { + logger.debug("Enumerator is being invalidated.", [.item: enumeratedItemIdentifier]) + } + + // MARK: - Protocol methods + + public func enumerateItems(for observer: NSFileProviderEnumerationObserver, startingAt page: NSFileProviderPage) { + logger.info("Received enumerate items request for enumerator with user", [.account: account.ncKitAccount, .url: serverUrl]) + + /* + - inspect the page to determine whether this is an initial or a follow-up request (TODO) + + If this is an enumerator for a directory, the root container or all directories: + - perform a server request to fetch directory contents + If this is an enumerator for the working set: + - perform a server request to update your local database + - fetch the working set from your local database + + - inform the observer about the items returned by the server (possibly multiple times) + - inform the observer that you are finished with this page + */ + + if enumeratedItemIdentifier == .trashContainer { + logger.info("Enumerating trash.", [.account: account.ncKitAccount, .url: serverUrl]) + + Task { [weak self] in + guard let self else { + return + } + + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities(account: account) + + guard let capabilities, error == .success else { + logger.error("Could not acquire capabilities, cannot check trash.", [.error: error]) + observer.finishEnumeratingWithError(NSFileProviderError(.serverUnreachable)) + return + } + + guard capabilities.files?.undelete == true else { + logger.error("Trash is unsupported on server, cannot enumerate items.") + observer.finishEnumeratingWithError(NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + return + } + + let domain = domain + let enumeratedItemIdentifier = enumeratedItemIdentifier + + let (_, trashedItems, _, trashReadError) = await remoteInterface.listingTrashAsync( + filename: nil, + showHiddenFiles: true, + account: account.ncKitAccount, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: enumeratedItemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard trashReadError == .success else { + let error = trashReadError.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) + observer.finishEnumeratingWithError(error) + return + } + + Self.completeEnumerationObserver( + observer, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + numPage: 1, + trashItems: trashedItems ?? [], + log: logger.log + ) + } + + return + } + + if enumeratedItemIdentifier == .workingSet { + logger.info("Upcoming enumeration is of working set.") + let ncKitAccount = account.ncKitAccount + // Visited folders and downloaded files + let materialisedItems = dbManager.materialisedItemMetadatas(account: ncKitAccount) + completeEnumerationObserver(observer, nextPage: nil, itemMetadatas: materialisedItems) + return + } + + guard serverUrl != "" else { + logger.error("Enumerator has empty serverUrl - cannot enumerate that!", [.item: enumeratedItemIdentifier]) + + let error = NSError.fileProviderErrorForNonExistentItem(withIdentifier: enumeratedItemIdentifier) + observer.finishEnumeratingWithError(error) + return + } + + logger.debug("Enumerating page: \(String(data: page.rawValue, encoding: .utf8) ?? "")", [.account: account.ncKitAccount, .url: serverUrl]) + + Task { + // Do not pass in the NSFileProviderPage default pages, these are not valid Nextcloud + // pagination tokens + var pageTotal: Int? = nil + + if page != NSFileProviderPage.initialPageSortedByName as NSFileProviderPage, page != NSFileProviderPage.initialPageSortedByDate as NSFileProviderPage { + if let enumPageResponse = try? JSONDecoder().decode(EnumeratorPageResponse.self, from: page.rawValue) { + if let total = enumPageResponse.total { + pageTotal = total + } + } else { + logger.error("Could not parse page") + } + } + + let readResult = await Self.readServerUrl( + serverUrl, + pageSettings: nil, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + depth: .targetAndDirectChildren, + log: logger.log + ) + + let metadatas = readResult.metadatas + let readError = readResult.readError + var nextPage = readResult.nextPage + + guard readError == nil else { + logger.error("Finishing enumeration for page with error.", [.account: self.account.ncKitAccount, .error: readError, .url: self.serverUrl]) + + // TODO: Refactor for conciseness + let error = readError?.fileProviderError( + handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier + ) ?? NSFileProviderError(.cannotSynchronize) + observer.finishEnumeratingWithError(error) + return + } + + guard let metadatas else { + logger.error("Finishing enumeration with invalid metadata.", [.account: self.account.ncKitAccount, .url: self.serverUrl]) + observer.finishEnumeratingWithError(NSFileProviderError(.cannotSynchronize)) + return + } + + pageTotal = nextPage?.total ?? pageTotal + if let rPage = nextPage, let pageTotal, rPage.index * pageItemCount >= pageTotal { + // Server will sometimes provide a valid next page data even though there are no + // items to enumerate anymore + logger.debug("No more items to enumerate, stopping paged enumeration.") + nextPage = nil + } + + nextPage = nil + + logger.info( + """ + Finished reading page: + \(String(data: page.rawValue, encoding: .utf8) ?? "") + serverUrl: \(self.serverUrl) + for user: \(self.account.ncKitAccount). + Processed \(metadatas.count) metadatas + """ + ) + + completeEnumerationObserver(observer, nextPage: nextPage, itemMetadatas: metadatas) + } + } + + public func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { + logger.debug("Enumerating changes (anchor: \(String(data: anchor.rawValue, encoding: .utf8) ?? "")).", [.url: serverUrl]) + + /* + - query the server for updates since the passed-in sync anchor (TODO) + + If this is an enumerator for the working set: + - note the changes in your local database + + - inform the observer about item deletions and updates (modifications + insertions) + - inform the observer when you have finished enumerating up to a subsequent sync anchor + */ + + if enumeratedItemIdentifier == .workingSet { + logger.debug("Enumerating changes in working set.", [.account: account]) + + let formatter = ISO8601DateFormatter() + + guard let anchorDateString = String(data: anchor.rawValue, encoding: .utf8), + let date = formatter.date(from: anchorDateString) + else { + logger.error("Could not parse sync anchor \"\(anchor.rawValue)\".") + observer.finishEnumeratingWithError(NSFileProviderError(.syncAnchorExpired)) + return + } + + let pendingChanges = dbManager.pendingWorkingSetChanges(account: account, since: date) + + completeChangesObserver( + observer, + anchor: currentAnchor, + enumeratedItemIdentifier: enumeratedItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + newMetadatas: [], + updatedMetadatas: pendingChanges.updated, + deletedMetadatas: pendingChanges.deleted + ) + + return + } else if enumeratedItemIdentifier == .trashContainer { + logger.debug("Enumerating changes in trash.", [.account: account.ncKitAccount]) + + Task { [weak self] in + guard let self else { + return + } + + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities(account: account) + + guard let capabilities, error == .success else { + logger.error("Could not acquire capabilities, cannot check trash.", [.error: error]) + observer.finishEnumeratingWithError(NSFileProviderError(.serverUnreachable)) + return + } + + guard capabilities.files?.undelete == true else { + logger.error("Trash is unsupported on server. Cannot enumerate changes.") + + observer.finishEnumeratingWithError( + NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError) + ) + return + } + + let domain = domain + let enumeratedItemIdentifier = enumeratedItemIdentifier + + let (_, trashedItems, _, trashReadError) = await remoteInterface.listingTrashAsync( + filename: nil, + showHiddenFiles: true, + account: account.ncKitAccount, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: enumeratedItemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard trashReadError == .success else { + let error = trashReadError.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) + observer.finishEnumeratingWithError(error) + return + } + + await Self.completeChangesObserver( + observer, + anchor: anchor, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + trashItems: trashedItems ?? [], + log: logger.log + ) + } + + return + } + + logger.info("Enumerating changes in item.", [.url: serverUrl]) + + // No matter what happens here we finish enumeration in some way, either from the error + // handling below or from the completeChangesObserver + // TODO: Move to the sync engine extension + Task { [weak self] in + guard let self else { + return + } + + let ( + _, newMetadatas, updatedMetadatas, deletedMetadatas, _, readError + ) = await Self.readServerUrl( + serverUrl, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: logger.log + ) + + // If we get a 404 we might add more deleted metadatas + var currentDeletedMetadatas: [SendableItemMetadata] = [] + if let notNilDeletedMetadatas = deletedMetadatas { + currentDeletedMetadatas = notNilDeletedMetadatas + } + + guard readError == nil else { + logger.error("Finished enumerating changes.", [.url: serverUrl, .error: readError]) + + let error = readError?.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) + + if readError!.isNotFoundError { + logger.info("404 error means item no longer exists. Deleting metadata and reporting deletion without error.", [.url: serverUrl]) + + guard let itemMetadata = enumeratedItemMetadata else { + logger.error("Invalid enumeratedItemMetadata. Could not delete metadata nor report deletion.") + observer.finishEnumeratingWithError(error) + return + } + + if itemMetadata.directory { + if let deletedDirectoryMetadatas = dbManager.deleteDirectoryAndSubdirectoriesMetadata(ocId: itemMetadata.ocId) { + currentDeletedMetadatas += deletedDirectoryMetadatas + } else { + logger.error("Something went wrong when recursively deleting directory. It's metadata was not found. Cannot report it as deleted.") + } + } else { + dbManager.deleteItemMetadata(ocId: itemMetadata.ocId) + } + + completeChangesObserver( + observer, + anchor: anchor, + enumeratedItemIdentifier: enumeratedItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + newMetadatas: nil, + updatedMetadatas: nil, + deletedMetadatas: [itemMetadata] + ) + return + } else if readError!.isNoChangesError { // All is well, just no changed etags + logger.info("Error was to say no changed files - not bad error. Finishing change enumeration.") + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + return + } + + observer.finishEnumeratingWithError(error) + return + } + + logger.info("Finished reading remote changes.", [.account: account.ncKitAccount, .url: serverUrl]) + + completeChangesObserver( + observer, + anchor: anchor, + enumeratedItemIdentifier: enumeratedItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + newMetadatas: newMetadatas, + updatedMetadatas: updatedMetadatas, + deletedMetadatas: deletedMetadatas + ) + } + } + + public func currentSyncAnchor(completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void) { + completionHandler(currentAnchor) + } + + // MARK: - Helper methods + + static func fileProviderPageforNumPage(_: Int) -> NSFileProviderPage? { + nil + // TODO: Handle paging properly + // NSFileProviderPage("\(numPage)".data(using: .utf8)!) + } + + private func completeEnumerationObserver( + _ observer: NSFileProviderEnumerationObserver, + nextPage: EnumeratorPageResponse?, + itemMetadatas: [SendableItemMetadata], + handleInvalidParent: Bool = true + ) { + Task { + do { + let items = try await itemMetadatas.toFileProviderItems( + account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: self.logger.log + ) + + Task { @MainActor in + observer.didEnumerate(items) + logger.info("Did enumerate \(items.count) items. Next page is nil: \(nextPage == nil)") + + if let nextPage, let nextPageData = try? JSONEncoder().encode(nextPage) { + logger.info( + "Next page: \(String(data: nextPageData, encoding: .utf8) ?? "?")" + ) + observer.finishEnumerating(upTo: NSFileProviderPage(nextPageData)) + } else { + observer.finishEnumerating(upTo: nil) + } + } + } catch let error as NSError { // This error can only mean a missing parent item identifier + guard handleInvalidParent else { + logger.info("Not handling invalid parent in enumeration.") + observer.finishEnumeratingWithError(error) + return + } + + do { + let metadata = try await attemptInvalidParentRecovery( + error: error, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager + ) + + completeEnumerationObserver( + observer, + nextPage: nextPage, + itemMetadatas: [metadata] + itemMetadatas, + handleInvalidParent: false + ) + } catch { + observer.finishEnumeratingWithError(error) + } + } + } + } + + private func completeChangesObserver( + _ observer: NSFileProviderChangeObserver, + anchor: NSFileProviderSyncAnchor, + enumeratedItemIdentifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + newMetadatas: [SendableItemMetadata]?, + updatedMetadatas: [SendableItemMetadata]?, + deletedMetadatas: [SendableItemMetadata]?, + handleInvalidParent: Bool = true + ) { + guard newMetadatas != nil || updatedMetadatas != nil || deletedMetadatas != nil else { + logger.error("Received invalid newMetadatas, updatedMetadatas or deletedMetadatas. Finished enumeration of changes with error.") + + observer.finishEnumeratingWithError( + NSError.fileProviderErrorForNonExistentItem( + withIdentifier: enumeratedItemIdentifier + ) + ) + + return + } + + // Observer does not care about new vs updated, so join + var allUpdatedMetadatas: [SendableItemMetadata] = [] + var allDeletedMetadatas: [SendableItemMetadata] = [] + + if let newMetadatas { + allUpdatedMetadatas += newMetadatas + } + + if let updatedMetadatas { + allUpdatedMetadatas += updatedMetadatas + } + + if let deletedMetadatas { + allDeletedMetadatas = deletedMetadatas + } + + let allFpItemDeletionsIdentifiers = Array( + allDeletedMetadatas.map { NSFileProviderItemIdentifier($0.ocId) }) + if !allFpItemDeletionsIdentifiers.isEmpty { + observer.didDeleteItems(withIdentifiers: allFpItemDeletionsIdentifiers) + } + + Task { [allUpdatedMetadatas, allDeletedMetadatas] in + do { + let updatedItems = try await allUpdatedMetadatas.toFileProviderItems( + account: account, remoteInterface: remoteInterface, dbManager: dbManager, log: self.logger.log + ) + + Task { @MainActor in + if !updatedItems.isEmpty { + observer.didUpdate(updatedItems) + } + + logger.info("Processed \(updatedItems.count) new or updated metadatas. \(allDeletedMetadatas.count) deleted metadatas.") + + observer.finishEnumeratingChanges(upTo: anchor, moreComing: false) + } + } catch let error as NSError { // This error can only mean a missing parent item identifier + guard handleInvalidParent else { + logger.info("Not handling invalid parent in change enumeration.") + observer.finishEnumeratingWithError(error) + return + } + + logger.info("Attempting handling invalid parent in change enumeration.") + + do { + let metadata = try await attemptInvalidParentRecovery( + error: error, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager + ) + var modifiedNewMetadatas = newMetadatas + modifiedNewMetadatas?.append(metadata) + completeChangesObserver( + observer, + anchor: anchor, + enumeratedItemIdentifier: enumeratedItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + newMetadatas: modifiedNewMetadatas, + updatedMetadatas: updatedMetadatas, + deletedMetadatas: deletedMetadatas, + handleInvalidParent: false + ) + } catch { + observer.finishEnumeratingWithError(error) + } + } + } + } + + private func attemptInvalidParentRecovery( + error: NSError, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager + ) async throws -> SendableItemMetadata { + logger.info("Attempting recovery from invalid parent identifier.") + // Try to recover from errors involving missing metadata for a parent + let userInfoKey = + FilesDatabaseManager.ErrorUserInfoKey.missingParentServerUrlAndFileName.rawValue + guard let urlToEnumerate = (error as NSError).userInfo[userInfoKey] as? String else { + logger.fault("No missing parent server url and filename in error user info.") + assertionFailure() + throw NSError() + } + + logger.info("Recovering from invalid parent identifier at \(urlToEnumerate)") + + let (metadatas, _, _, _, _, error) = await Enumerator.readServerUrl( + urlToEnumerate, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + depth: .target, + log: logger.log + ) + guard error == nil || error == .success, let metadata = metadatas?.first else { + logger.error( + """ + Problem retrieving parent for metadata. + Error: \(error?.errorDescription ?? "NONE") + Metadatas: \(metadatas?.count ?? -1) + """ + ) + + throw error?.fileProviderError ?? NSFileProviderError(.cannotSynchronize) + } + // Provide it to the caller method so it can ingest it into the database and fix future errs + return metadata + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift new file mode 100644 index 0000000000000..ed2fa9d814f42 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation + +struct EnumeratorPageResponse: Sendable, Codable { + let token: String? // Required by server to serve the next page of items + let index: Int // Needed to calculate the offset for the next paginated request + var total: Int? // Total item count, provided in the first non-offset paginated response + + init?(nkResponseData: AFDataResponse?, index: Int, log _: any FileProviderLogging) { + guard let headers = nkResponseData?.response?.allHeaderFields as? [String: String] else { + return nil + } + + let normalisedHeaders = + Dictionary(uniqueKeysWithValues: headers.map { ($0.key.lowercased(), $0.value) }) + guard Bool(normalisedHeaders["x-nc-paginate"]?.lowercased() ?? "false") == true, + let responsePaginateToken = normalisedHeaders["x-nc-paginate-token"] + else { + return nil + } + + self.index = index + token = responsePaginateToken + + if let responsePaginateTotal = normalisedHeaders["x-nc-paginate-total"] { + total = Int(responsePaginateTotal) + } else { + total = nil + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift new file mode 100644 index 0000000000000..92fb4130f2505 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import FileProvider +import Foundation +import RealmSwift + +/// +/// The custom `NSFileProviderEnumerationObserver` implementation to process materialized items enumerated by the system. +/// +public class MaterializedEnumerationObserver: NSObject, NSFileProviderEnumerationObserver { + let logger: FileProviderLogger + public let account: Account + let dbManager: FilesDatabaseManager + private let completionHandler: (_ materialized: Set, _ evicted: Set) -> Void + + /// + /// All materialized items enumerated by the system. + /// + private var enumeratedItems = Set() + + public required init(account: Account, dbManager: FilesDatabaseManager, log: any FileProviderLogging, completionHandler: @escaping (_ materialized: Set, _ evicted: Set) -> Void) { + self.account = account + self.dbManager = dbManager + logger = FileProviderLogger(category: "MaterializedEnumerationObserver", log: log) + self.completionHandler = completionHandler + super.init() + } + + public func didEnumerate(_ updatedItems: [NSFileProviderItemProtocol]) { + updatedItems + .map(\.itemIdentifier) + .forEach { enumeratedItems.insert($0) } + } + + public func finishEnumerating(upTo _: NSFileProviderPage?) { + logger.debug("Handling enumerated materialized items.") + handleEnumeratedItems(enumeratedItems, account: account, dbManager: dbManager, completionHandler: completionHandler) + } + + public func finishEnumeratingWithError(_ error: Error) { + logger.error("Finishing enumeration with error.", [.error: error]) + handleEnumeratedItems(enumeratedItems, account: account, dbManager: dbManager, completionHandler: completionHandler) + } + + func handleEnumeratedItems(_ identifiers: Set, account: Account, dbManager: FilesDatabaseManager, completionHandler: @escaping (_ materialized: Set, _ evicted: Set) -> Void) { + let metadataForMaterializedItems = dbManager.materialisedItemMetadatas(account: account.ncKitAccount) + var metadataForMaterializedItemsByIdentifier = [NSFileProviderItemIdentifier: SendableItemMetadata]() + var evictedItems = Set() + var stillMaterializedItems = Set() + + for metadata in metadataForMaterializedItems { + let identifier = NSFileProviderItemIdentifier(metadata.ocId) + metadataForMaterializedItemsByIdentifier[identifier] = metadata + evictedItems.insert(identifier) // Assume the item related to the metadata object was evicted until proven otherwise below. + } + + for enumeratedIdentifier in identifiers { + if evictedItems.contains(enumeratedIdentifier) { + evictedItems.remove(enumeratedIdentifier) // The enumerated item cannot be assumed as evicted any longer. + } else { + stillMaterializedItems.insert(enumeratedIdentifier) + + guard var metadata = if enumeratedIdentifier == .rootContainer { + dbManager.rootItemMetadata(account: account) + } else { + dbManager.itemMetadata(enumeratedIdentifier) + } else { + logger.error("No metadata for enumerated item found.", [.item: enumeratedIdentifier]) + continue + } + + if metadata.directory { + metadata.visitedDirectory = true + } else { + metadata.downloaded = true + } + + logger.info("Updating state for item to materialized.", [.item: enumeratedIdentifier, .name: metadata.fileName]) + dbManager.addItemMetadata(metadata) + } + } + + for evictedItemIdentifier in evictedItems { + guard var metadata = metadataForMaterializedItemsByIdentifier[evictedItemIdentifier] else { + logger.error("No metadata found for apparently evicted identifier.", [.item: evictedItemIdentifier]) + continue + } + + logger.info("Updating item state to dataless.", [.name: metadata.fileName, .item: evictedItemIdentifier]) + + metadata.downloaded = false + metadata.visitedDirectory = false + dbManager.addItemMetadata(metadata) + } + + completionHandler(stillMaterializedItems, evictedItems) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift new file mode 100644 index 0000000000000..3b8a26904ad18 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift @@ -0,0 +1,682 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit + +public let NotifyPushAuthenticatedNotificationName = Notification.Name("NotifyPushAuthenticated") + +public final class RemoteChangeObserver: NSObject, @unchecked Sendable { + // @unchecked Sendable is used because 'account' is mutable, but mutation is controlled and safe in this context. + public let remoteInterface: RemoteInterface + public let changeNotificationInterface: ChangeNotificationInterface + public let domain: NSFileProviderDomain? + public let dbManager: FilesDatabaseManager + public var account: Account + public var accountId: String { account.ncKitAccount } + + public var webSocketPingIntervalNanoseconds: UInt64 = 3 * 1_000_000_000 + public let webSocketReconfigureIntervalNanoseconds: UInt64 = 1 * 1_000_000_000 + public let webSocketPingFailLimit = 8 + public let webSocketAuthenticationFailLimit = 3 + + public var webSocketTaskActive: Bool { + webSocketTask != nil + } + + private let logger: FileProviderLogger + + private var workingSetCheckOngoing = false + private var invalidated = false + + private var webSocketUrlSession: URLSession? + private var webSocketTask: URLSessionWebSocketTask? + private var webSocketOperationQueue = OperationQueue() + private var webSocketPingTask: Task? + private(set) var webSocketPingFailCount = 0 + private(set) var webSocketAuthenticationFailCount = 0 + + private(set) var pollingTimer: Timer? + + let pollInterval: TimeInterval + + public var pollingActive: Bool { + pollingTimer != nil + } + + private(set) var networkReachability: NKTypeReachability = .unknown { + didSet { + if networkReachability == .notReachable { + logger.info("Network unreachable, stopping websocket and stopping polling") + stopPollingTimer() + resetWebSocket() + } else if oldValue == .notReachable { + logger.info("Network reachable, trying to reconnect to websocket") + reconnectWebSocket() + startWorkingSetCheck() + } + } + } + + public init( + account: Account, + remoteInterface: RemoteInterface, + changeNotificationInterface: ChangeNotificationInterface, + domain: NSFileProviderDomain?, + dbManager: FilesDatabaseManager, + pollInterval: TimeInterval = 60, + log: any FileProviderLogging + ) { + self.account = account + self.remoteInterface = remoteInterface + self.changeNotificationInterface = changeNotificationInterface + self.domain = domain + self.dbManager = dbManager + self.pollInterval = pollInterval + logger = FileProviderLogger(category: "RemoteChangeObserver", log: log) + super.init() + + // Authentication fixes require some type of user or external change. + // We don't want to reset the auth tries within reconnect web socket as this is called + // internally + webSocketAuthenticationFailCount = 0 + + Task { + reconnectWebSocket() + } + } + + private func startPollingTimer() { + guard !invalidated else { + logger.error("Starting polling timer while the current one is not invalidated yet!") + return + } + + Task { @MainActor in + pollingTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + self?.logger.info("Polling timer timeout, notifying change.") + self?.startWorkingSetCheck() + } + + logger.info("Starting polling timer.") + } + } + + private func stopPollingTimer() { + Task { + logger.info("Stopping polling timer.") + pollingTimer?.invalidate() + pollingTimer = nil + } + } + + public func invalidate() { + logger.debug("Invalidating.") + invalidated = true + resetWebSocket() + } + + private func reconnectWebSocket() { + logger.debug("Reconnecting web socket...") + stopPollingTimer() + resetWebSocket() + + guard networkReachability != .notReachable else { + logger.error("Network unreachable, will retry when reconnected.") + return + } + + guard webSocketAuthenticationFailCount < webSocketAuthenticationFailLimit else { + logger.error("Exceeded authentication failures for notify push websocket \(account.ncKitAccount), will poll instead.", [.account: account.ncKitAccount]) + startPollingTimer() + return + } + + Task { [weak self] in + try await Task.sleep(nanoseconds: self?.webSocketReconfigureIntervalNanoseconds ?? 0) + await self?.configureNotifyPush() + } + } + + public func resetWebSocket() { + logger.debug("Resetting web socket...") + webSocketTask?.cancel() + webSocketUrlSession = nil + webSocketTask = nil + webSocketOperationQueue.cancelAllOperations() + webSocketOperationQueue.isSuspended = true + webSocketPingTask?.cancel() + webSocketPingTask = nil + webSocketPingFailCount = 0 + } + + private func configureNotifyPush() async { + logger.debug("Configuring notify push...") + + guard !invalidated else { + logger.error("Attempt to configure notify push while being invalidated!") + return + } + + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities( + account: account, + options: .init(), + taskHandler: { task in + if let domain = self.domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: .rootContainer, + completionHandler: { _ in } + ) + } + } + ) + + guard error == .success else { + logger.error("Could not get capabilities: \(error.errorCode) \(error.errorDescription)", [.account: account.ncKitAccount]) + reconnectWebSocket() + return + } + + guard let capabilities, + let websocketEndpoint = capabilities.notifyPush?.endpoints?.websocket + else { + logger.error("Could not get notifyPush websocket \(account.ncKitAccount), polling.", [.account: account.ncKitAccount]) + startPollingTimer() + return + } + + guard let websocketEndpointUrl = URL(string: websocketEndpoint) else { + logger.error("Received notifyPush endpoint is invalid: \(websocketEndpoint)") + + return + } + webSocketOperationQueue.isSuspended = false + webSocketUrlSession = URLSession( + configuration: URLSessionConfiguration.default, + delegate: self, + delegateQueue: webSocketOperationQueue + ) + webSocketTask = webSocketUrlSession?.webSocketTask(with: websocketEndpointUrl) + webSocketTask?.resume() + logger.info("Successfully configured push notifications for \(account.ncKitAccount)", [.account: account.ncKitAccount]) + } + + func incrementWebSocketPingFailCount() { + webSocketPingFailCount += 1 + } + + func setNetworkReachability(_ typeReachability: NKTypeReachability) { + networkReachability = typeReachability + } + + private func authenticateWebSocket() async { + guard !invalidated else { + return + } + + do { + try await webSocketTask?.send(.string(account.username)) + try await webSocketTask?.send(.string(account.password)) + } catch { + logger.error("Error authenticating websocket.", [.account: account.ncKitAccount, .error: error]) + } + + readWebSocket() + } + + private func startNewWebSocketPingTask() { + guard !Task.isCancelled, !invalidated else { + return + } + + if let webSocketPingTask, !webSocketPingTask.isCancelled { + webSocketPingTask.cancel() + } + + let account = accountId + + webSocketPingTask = Task.detached(priority: .background) { + do { + try await Task.sleep(nanoseconds: self.webSocketPingIntervalNanoseconds) + } catch { + self.logger.error("Could not sleep websocket ping.", [.account: account, .error: error]) + } + + guard !Task.isCancelled else { + return + } + + Task { + self.pingWebSocket() + } + } + } + + private func pingWebSocket() { // Keep the socket connection alive + guard !invalidated else { + return + } + + guard networkReachability != .notReachable else { + logger.error("Not pinging because network is unreachable.", [.account: account.ncKitAccount]) + return + } + + webSocketTask?.sendPing { error in + Task { [weak self] in + guard let self else { + return + } + + guard await invalidated == false else { + return + } + + guard error == nil else { + logger.error("Websocket ping failed.", [.error: error]) + incrementWebSocketPingFailCount() + + if webSocketPingFailCount > webSocketPingFailLimit { + Task.detached(priority: .medium) { + self.reconnectWebSocket() + } + } else { + startNewWebSocketPingTask() + } + + return + } + + startNewWebSocketPingTask() + } + } + } + + private func readWebSocket() { + guard !invalidated else { + return + } + + webSocketTask?.receive { result in + Task { [weak self] in + guard let self else { + return + } + + switch result { + case .failure: + let accountId = accountId + logger.debug("Failed to read websocket.", [.account: accountId]) + // Do not reconnect here, delegate methods will handle reconnecting + case let .success(message): + switch message { + case let .data(data): + processWebsocket(data: data) + case let .string(string): + processWebsocket(string: string) + @unknown default: + logger.error("Unknown case encountered while reading websocket!") + } + + readWebSocket() + } + } + } + } + + private func processWebsocket(data: Data) { + guard !invalidated else { return } + guard let string = String(data: data, encoding: .utf8) else { + logger.error("Could parse websocket data for id: \(account.ncKitAccount)", [.account: account.ncKitAccount]) + return + } + processWebsocket(string: string) + } + + private func processWebsocket(string: String) { + logger.debug("Received websocket string: \(string)") + if string == "notify_file" { + logger.debug("Received file notification for \(account.ncKitAccount)", [.account: account.ncKitAccount]) + startWorkingSetCheck() + } else if string == "notify_activity" { + logger.debug("Ignoring activity notification: \(account.ncKitAccount)", [.account: account.ncKitAccount]) + } else if string == "notify_notification" { + logger.debug("Ignoring notification: \(account.ncKitAccount)", [.account: account.ncKitAccount]) + } else if string == "authenticated" { + logger.debug("Correctly authed websocket \(account.ncKitAccount), pinging", [.account: account.ncKitAccount]) + NotificationCenter.default.post( + name: NotifyPushAuthenticatedNotificationName, object: self + ) + startNewWebSocketPingTask() + } else if string == "err: Invalid credentials" { + logger.debug( + """ + Invalid creds for websocket for \(account.ncKitAccount), + reattempting auth. + """ + ) + webSocketAuthenticationFailCount += 1 + reconnectWebSocket() + } else { + logger.error("Received unknown string from websocket \(account.ncKitAccount): \(string)", [.account: account.ncKitAccount]) + } + } + + func replaceAccount(with account: Account) { + self.account = account + } + + func setWebSocketPingInterval(to nanoseconds: UInt64) { + webSocketPingIntervalNanoseconds = nanoseconds + } +} + +// MARK: - URLSessionWebSocketDelegate + +extension RemoteChangeObserver: URLSessionWebSocketDelegate { + public nonisolated func urlSession(_: URLSession, webSocketTask _: URLSessionWebSocketTask, didOpenWithProtocol _: String?) { + Task { + guard invalidated == false else { + return + } + + logger.debug("Websocket connected sending auth details", [.account: accountId]) + await authenticateWebSocket() + } + } + + public nonisolated func urlSession(_: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith _: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Task { + guard invalidated == false else { + return + } + + // If the task that closed is not the current active task, it means we have + // already initiated a reset and this is a stale callback. Ignore it. + guard webSocketTask === self.webSocketTask else { + logger.debug("An old websocket task closed, ignoring.") + return + } + + logger.debug("Socket connection closed: \(String(data: reason ?? Data(), encoding: .utf8) ?? "unknown reason"). Retrying websocket connection.", [.account: accountId]) + reconnectWebSocket() + } + } + + public nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) {} +} + +// MARK: - NextcloudKitDelegate methods + +extension RemoteChangeObserver: NextcloudKitDelegate { + public nonisolated func authenticationChallenge(_: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @Sendable @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + Task { [weak self] in + guard let self else { + return + } + + guard !invalidated else { + return + } + + let authMethod = challenge.protectionSpace.authenticationMethod + logger.debug("Received auth challenge with method: \(authMethod)") + + if authMethod == NSURLAuthenticationMethodHTTPBasic { + let credential = URLCredential(user: account.username, password: account.password, persistence: .forSession) + completionHandler(.useCredential, credential) + } else if authMethod == NSURLAuthenticationMethodServerTrust { + // TODO: Validate the server trust + guard let serverTrust = challenge.protectionSpace.serverTrust else { + logger.error("Received server trust auth challenge but no trust avail") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + let credential = URLCredential(trust: serverTrust) + completionHandler(.useCredential, credential) + } else { + logger.error("Unhandled auth method: \(authMethod)") + // Handle other authentication methods or cancel the challenge + completionHandler(.performDefaultHandling, nil) + } + } + } + + public nonisolated func networkReachabilityObserver(_ typeReachability: NKTypeReachability) { + Task { [weak self] in + guard let self else { + return + } + + setNetworkReachability(typeReachability) + } + } + + public nonisolated func downloadProgress( + _: Float, + totalBytes _: Int64, + totalBytesExpected _: Int64, + fileName _: String, + serverUrl _: String, + session _: URLSession, + task _: URLSessionTask + ) {} + + public nonisolated func uploadProgress( + _: Float, + totalBytes _: Int64, + totalBytesExpected _: Int64, + fileName _: String, + serverUrl _: String, + session _: URLSession, + task _: URLSessionTask + ) {} + + public nonisolated func downloadingFinish( + _: URLSession, + downloadTask _: URLSessionDownloadTask, + didFinishDownloadingTo _: URL + ) {} + + public nonisolated func downloadComplete( + fileName _: String, + serverUrl _: String, + etag _: String?, + date _: Date?, + dateLastModified _: Date?, + length _: Int64, + task _: URLSessionTask, + error _: NKError + ) {} + + public nonisolated func uploadComplete( + fileName _: String, + serverUrl _: String, + ocId _: String?, + etag _: String?, + date _: Date?, + size _: Int64, + task _: URLSessionTask, + error _: NKError + ) {} + + public nonisolated func request(_: Alamofire.DataRequest, didParseResponse _: Alamofire.AFDataResponse) {} + + /// + /// Dispatches the asynchronous working set check. + /// + /// - Parameters: + /// - completionHandler: An optional closure to call after the working set check completed. + /// + func startWorkingSetCheck(completionHandler: (@Sendable () -> Void)? = nil) { + guard !workingSetCheckOngoing, !invalidated else { + logger.error("Cancelling dispatch of working set check because it either is already ongoing or this is invalidated!") + return + } + + Task { + await checkWorkingSet() + completionHandler?() + } + } + + private func checkWorkingSet() async { + logger.debug("Checking working set...") + workingSetCheckOngoing = true + + defer { + logger.debug("Working set check no longer ongoing.") + workingSetCheckOngoing = false + } + + // Unlike when enumerating items we can't progressively enumerate items as we need to + // wait to see which items are truly deleted and which have just been moved elsewhere. + // Visited folders and downloaded files. Sort in terms of their remote URLs. + // This way we ensure we visit parent folders before their children. + let materialisedItems = dbManager + .materialisedItemMetadatas(account: account.ncKitAccount) + .sorted { $0.remotePath().count < $1.remotePath().count } + + var allNewMetadatas = [SendableItemMetadata]() + var allUpdatedMetadatas = [SendableItemMetadata]() + var allDeletedMetadatas = [SendableItemMetadata]() + var examinedItemIds = Set() + + for item in materialisedItems where !examinedItemIds.contains(item.ocId) { + guard !invalidated else { + return + } + + guard isLockFileName(item.fileName) == false else { + // Skip server requests for locally created lock files. + // They are not synchronized to the server for real. + // Thus they can be expected not to be found there. + // That would also cause their local deletion due to synchronization logic. + logger.debug("Skipping materialized item in working set check because the name hints a lock file.", [.item: item, .name: item.name]) + continue + } + + let itemRemoteUrl = item.remotePath() + + let (metadatas, newMetadatas, updatedMetadatas, deletedMetadatas, _, readError) = await Enumerator.readServerUrl( + itemRemoteUrl, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + depth: item.directory ? .targetAndDirectChildren : .target, + log: logger.log + ) + + guard !invalidated else { + return + } + + if readError?.errorCode == 404 { + allDeletedMetadatas.append(item) + examinedItemIds.insert(item.ocId) + materialisedItems + .filter { $0.serverUrl == itemRemoteUrl } + .forEach { + allDeletedMetadatas.append($0) + examinedItemIds.insert(item.ocId) + } + } else if let readError, readError != .success { + logger.info("Finished change enumeration of working set for user \(account.ncKitAccount) with error.", [.account: account.ncKitAccount, .error: readError]) + return + } else { + allDeletedMetadatas += deletedMetadatas ?? [] + allUpdatedMetadatas += updatedMetadatas ?? [] + allNewMetadatas += newMetadatas ?? [] + + // Just because we have read child directories metadata doesn't mean we need + // to in turn scan their children. This is not the case for files + var examinedChildFilesAndDeletedItems = Set() + if let metadatas, let target = metadatas.first { + examinedItemIds.insert(target.ocId) + + if metadatas.count > 1 { + examinedChildFilesAndDeletedItems.formUnion( + metadatas[1...].filter { !$0.directory }.map(\.ocId) + ) + } + + // If the target is not in the updated metadatas then neither it, nor + // any of its kids have changed. So skip examining all of them + if !allUpdatedMetadatas.contains(where: { $0.ocId == target.ocId }) { + logger.debug("Target \(itemRemoteUrl) has not changed. Skipping children") + let materialisedChildren = materialisedItems.filter { + $0.serverUrl.hasPrefix(itemRemoteUrl) + }.map(\.ocId) + examinedChildFilesAndDeletedItems.formUnion(materialisedChildren) + } + + // OPTIMIZATION: For any child directories returned in this enumeration, + // if they haven't changed (etag matches database), mark them as examined + // so we don't enumerate them separately later + if metadatas.count > 1 { + let childDirectories = metadatas[1...].filter(\.directory) + for childDir in childDirectories { + // Check if this directory is in our materialized items list + if let localItem = materialisedItems.first(where: { $0.ocId == childDir.ocId }), + localItem.etag == childDir.etag + { + // Directory hasn't changed, mark as examined to skip separate enumeration + logger.debug("Child directory \(childDir.fileName) etag unchanged (\(childDir.etag)), marking as examined") + examinedChildFilesAndDeletedItems.insert(childDir.ocId) + + // Also mark any materialized children of this directory as examined + let grandChildren = materialisedItems.filter { + $0.serverUrl.hasPrefix(localItem.remotePath()) + } + examinedChildFilesAndDeletedItems.formUnion(grandChildren.map(\.ocId)) + } + } + } + + if let deletedMetadataOcIds = deletedMetadatas?.map(\.ocId) { + examinedChildFilesAndDeletedItems.formUnion(deletedMetadataOcIds) + } + } + + examinedItemIds.formUnion(examinedChildFilesAndDeletedItems) + } + } + guard !invalidated else { return } + + // Run a check to ensure files deleted in one location are not updated in another + // (e.g. when moved) + // The recursive scan provides us with updated/deleted metadatas only on a folder by + // folder basis; so we need to check we are not simultaneously marking a moved file as + // deleted and updated + var checkedDeletedMetadatas = allDeletedMetadatas + + for updatedMetadata in allUpdatedMetadatas { + guard let matchingDeletedMetadataIdx = checkedDeletedMetadatas.firstIndex( + where: { $0.ocId == updatedMetadata.ocId } + ) else { continue } + checkedDeletedMetadatas.remove(at: matchingDeletedMetadataIdx) + } + + allDeletedMetadatas = checkedDeletedMetadatas + + for deletedMetadata in allDeletedMetadatas { + var deleteMarked = deletedMetadata + deleteMarked.deleted = true + deleteMarked.syncTime = Date() + dbManager.addItemMetadata(deleteMarked) + } + + logger.info("Finished change enumeration of working set. Examined item IDs: \(examinedItemIds), materialized item IDs: \(materialisedItems.map(\.ocId))") + + if allUpdatedMetadatas.isEmpty, allDeletedMetadatas.isEmpty { + logger.info("No changes found.") + } else { + changeNotificationInterface.notifyChange() + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift new file mode 100644 index 0000000000000..686b4ce1d6442 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation + +public extension FileManager { + /// + /// Return log directory specific to the file provider domain distinguished by the given identifier. + /// + /// If such directory does not exist yet, this attempts to create it implicitly. + /// + /// - Parameters: + /// - identifier: File provider domain identifier which is used to isolate log data for different file provider domains of the same extension. + /// + /// - Returns: A directory based on what the system returns for looking up standard directories. Likely in the sandbox containers of the file provider extension. Very unlikely to fail by returning `nil`. + /// + func fileProviderDomainLogDirectory(for identifier: NSFileProviderDomainIdentifier) -> URL? { + guard let applicationGroupContainer = applicationGroupContainer() else { + return nil + } + + let logsDirectory = applicationGroupContainer + .appendingPathComponent("File Provider Domains", isDirectory: true) + .appendingPathComponent(identifier.rawValue, isDirectory: true) + .appendingPathComponent("Logs", isDirectory: true) + + if fileExists(atPath: logsDirectory.path) == false { + do { + try createDirectory(at: logsDirectory, withIntermediateDirectories: true) + } catch { + return nil + } + } + + return logsDirectory + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift new file mode 100644 index 0000000000000..007fc1b98ffe2 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation + +public extension FileManager { + /// + /// Return the application support directory specific to the file provider domain distinguished by the given identifier. + /// + /// If such directory does not exist yet, this attempts to create it implicitly. + /// + /// - Parameters: + /// - identifier: File provider domain identifier which is used to isolate application support data for different file provider domains of the same extension. + /// + /// - Returns: A directory based on what the system returns for looking up standard directories. Likely in the sandbox containers of the file provider extension. Very unlikely to fail by returning `nil`. + /// + func fileProviderDomainSupportDirectory(for identifier: NSFileProviderDomainIdentifier) -> URL? { + guard let containerUrl = applicationGroupContainer() else { + return nil + } + + let supportDirectory = containerUrl + .appendingPathComponent("File Provider Domains", isDirectory: true) + .appendingPathComponent(identifier.rawValue, isDirectory: true) + + if fileExists(atPath: supportDirectory.path) == false { + do { + try createDirectory(at: supportDirectory, withIntermediateDirectories: true) + } catch { + return nil + } + } + + return supportDirectory + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+applicationGroupContainer.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+applicationGroupContainer.swift new file mode 100644 index 0000000000000..75111e546006e --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/FileManager+applicationGroupContainer.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +public extension FileManager { + /// + /// Resolve the location of the shared container for the app group of the file provider extension. + /// + /// - Returns: Container URL for the extension's app group or `nil`, if it could not be found. + /// + func applicationGroupContainer() -> URL? { + guard let infoDictionary = Bundle.main.infoDictionary else { + return nil + } + + guard let extensionDictionary = infoDictionary["NSExtension"] as? [String: Any] else { + return nil + } + + guard let appGroupIdentifier = extensionDictionary["NSExtensionFileProviderDocumentGroup"] as? String else { + return nil + } + + return containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift new file mode 100644 index 0000000000000..8bc58b8abeb5e --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit + +extension NKError { + static var noChangesErrorCode: Int { + -200 + } + + var isCouldntConnectError: Bool { + errorCode == -9999 || errorCode == -1001 || errorCode == -1004 || errorCode == -1005 + || errorCode == -1009 || errorCode == -1012 || errorCode == -1200 || errorCode == -1202 + || errorCode == 500 || errorCode == 503 || errorCode == 200 + } + + var isUnauthenticatedError: Bool { + errorCode == -1013 + } + + var isGoingOverQuotaError: Bool { + errorCode == 507 + } + + var isNotFoundError: Bool { + errorCode == 404 + } + + var isNoChangesError: Bool { + errorCode == NKError.noChangesErrorCode + } + + var isUnauthorizedError: Bool { + errorCode == 401 + } + + var matchesCollisionError: Bool { + errorCode == 405 + } + + /// + /// Derive an `NSFileProviderError` based on ``errorCode``. + /// + var fileProviderError: NSFileProviderError? { + if self == .success { + nil + } else if isNotFoundError { + NSFileProviderError(.noSuchItem) + } else if isCouldntConnectError { + // Provide something the file provider can do something with + NSFileProviderError(.serverUnreachable) + } else if isUnauthenticatedError || isUnauthorizedError { + NSFileProviderError(.notAuthenticated) + } else if isGoingOverQuotaError { + NSFileProviderError(.insufficientQuota) + } else if matchesCollisionError { + NSFileProviderError(.filenameCollision) + } else { + NSFileProviderError(.cannotSynchronize) + } + } + + func fileProviderError( + handlingNoSuchItemErrorUsingItemIdentifier identifier: NSFileProviderItemIdentifier + ) -> Error? { + guard fileProviderError?.code == .noSuchItem else { + return fileProviderError as Error? + } + return NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier) + } + + func fileProviderError( + handlingCollisionAgainstItemInRemotePath problemRemotePath: String, + dbManager: FilesDatabaseManager, + remoteInterface: RemoteInterface, + log: any FileProviderLogging + ) async -> Error? { + guard fileProviderError?.code == .filenameCollision else { + return fileProviderError as Error? + } + guard let collidingItemMetadata = dbManager.itemMetadata( + account: dbManager.account.ncKitAccount, locatedAtRemoteUrl: problemRemotePath + ), let collidingItem = await Item.storedItem( + identifier: .init(collidingItemMetadata.ocId), + account: dbManager.account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: log + ) else { + return NSFileProviderError(.filenameCollision) + } + return NSError.fileProviderErrorForCollision(with: collidingItem) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift new file mode 100644 index 0000000000000..73ff8e4bc5945 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit +import RealmSwift + +extension NKFile { + func fullUrlMatches(_ urlString: String) -> Bool { + var fileUrl = serverUrl + "/" + fileName + if fileUrl.last == "/" { // This is likely the root container, as it has no filename + fileUrl.removeLast() + } + return fileUrl == urlString + } + + func toItemMetadata(uploaded: Bool = true) -> SendableItemMetadata { + let creationDate = creationDate ?? date + let uploadDate = uploadDate ?? date + + let classFile = (contentType == "text/markdown" || contentType == "text/x-markdown") && classFile == NKTypeClassFile.unknow.rawValue + ? NKTypeClassFile.document.rawValue + : classFile + + // Support for finding the correct filename for e2ee files should go here + + // Don't ask me why, NextcloudKit renames and moves the root folder details + // Also don't ask me why, but, NextcloudKit marks the NKFile for this as not a directory + let rootServerUrl = urlBase + Account.webDavFilesUrlSuffix + userId + let rootRequiresFixup = serverUrl == rootServerUrl && fileName == NextcloudKit.shared.nkCommonInstance.rootFileName + let ocId = rootRequiresFixup ? NSFileProviderItemIdentifier.rootContainer.rawValue : ocId + let directory = rootRequiresFixup ? true : directory + let serverUrl = rootRequiresFixup ? rootServerUrl : serverUrl + let fileName = rootRequiresFixup ? NextcloudKit.shared.nkCommonInstance.rootFileName : fileName + + return SendableItemMetadata( + ocId: ocId, + account: account, + checksums: checksums, + classFile: classFile, + commentsUnread: commentsUnread, + contentType: contentType, + creationDate: creationDate as Date, + dataFingerprint: dataFingerprint, + date: date as Date, + directory: directory, + downloadURL: downloadURL, + e2eEncrypted: e2eEncrypted, + etag: etag, + favorite: favorite, + fileId: fileId, + fileName: fileName, + fileNameView: fileName, + hasPreview: hasPreview, + hidden: hidden, + iconName: iconName, + livePhotoFile: livePhotoFile, + mountType: mountType, + name: name, + note: note, + ownerId: ownerId, + ownerDisplayName: ownerDisplayName, + lock: lock, + lockOwner: lockOwner, + lockOwnerEditor: lockOwnerEditor, + lockOwnerType: lockOwnerType, + lockOwnerDisplayName: lockOwnerDisplayName, + lockTime: lockTime, + lockTimeOut: lockTimeOut, + lockToken: nil, // This is not available at this point and must be fetched from the local persistence later. + path: path, + permissions: permissions, + quotaUsedBytes: quotaUsedBytes, + quotaAvailableBytes: quotaAvailableBytes, + resourceType: resourceType, + richWorkspace: richWorkspace, + serverUrl: serverUrl, + sharePermissionsCollaborationServices: sharePermissionsCollaborationServices, + sharePermissionsCloudMesh: sharePermissionsCloudMesh, + shareType: shareType, + size: size, + tags: tags, + uploaded: uploaded, + trashbinFileName: trashbinFileName, + trashbinOriginalLocation: trashbinOriginalLocation, + trashbinDeletionTime: trashbinDeletionTime, + uploadDate: uploadDate as Date, + urlBase: urlBase, + user: user, + userId: userId + ) + } +} + +/// +/// Data container intended for use in combination with `concurrentChunkedForEach` to safely and concurrently convert a lot of metadata objects. +/// +private final actor DirectoryMetadataContainer: Sendable { + let root: SendableItemMetadata + var directories: [SendableItemMetadata] = [] + var files: [SendableItemMetadata] = [] + + init(for root: SendableItemMetadata) { + self.root = root + } + + /// + /// Insert a new item into the container. + /// + func add(_ item: SendableItemMetadata) { + files.append(item) + + if item.directory { + directories.append(item) + } + } + + /// + /// Return a tuple of the total current content. + /// + func content() -> (SendableItemMetadata, [SendableItemMetadata], [SendableItemMetadata]) { + (root, directories, files) + } +} + +extension [NKFile] { + /// + /// Determine whether the given `NKFile` is the metadata object for the read remote directory. + /// + func isDirectoryToRead(_ file: NKFile, directoryToRead: String) -> Bool { + if file.serverUrl == directoryToRead, file.fileName == NextcloudKit.shared.nkCommonInstance.rootFileName { + return true + } + + if file.directory, directoryToRead == "\(file.serverUrl)/\(file.fileName)" { + return true + } + + return false + } + + /// + /// Convert an array of `NKFile` to `SendableItemMetadata`. + /// + /// - Parameters: + /// - account: The account which the metadata belongs to. + /// - directoryToRead: The root path of the directory which this metadata comes from. This is required to distinguish the correct item for the metadata of the read directory itself from its children. + /// + /// - Returns: A tuple consisting of the metadata for the read directory itself (`root`), any child directories (`directories`) and separately any directly containted files (`files`). + /// + func toSendableDirectoryMetadata(account _: Account, directoryToRead: String) async -> (root: SendableItemMetadata, directories: [SendableItemMetadata], files: [SendableItemMetadata])? { + guard let root = first(where: { isDirectoryToRead($0, directoryToRead: directoryToRead) })?.toItemMetadata() else { + return nil + } + + let container = DirectoryMetadataContainer(for: root) + + if count > 1 { + await concurrentChunkedForEach { file in + guard isDirectoryToRead(file, directoryToRead: directoryToRead) == false else { + return + } + + await container.add(file.toItemMetadata()) + } + } + + return await container.content() + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift new file mode 100644 index 0000000000000..e9ad76588c8bb --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit + +extension NKRequestOptions { + convenience init(page: NSFileProviderPage?, offset: Int? = nil, count: Int? = nil) { + var token: String? = nil + if let page { + token = String(data: page.rawValue, encoding: .utf8) + } + self.init( + paginate: true, + paginateToken: token, + paginateOffset: offset, + paginateCount: count + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift new file mode 100644 index 0000000000000..19ca9c68cd3bc --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import NextcloudKit + +extension NKTrash { + func toItemMetadata(account: Account) -> SendableItemMetadata { + SendableItemMetadata( + ocId: ocId, + account: account.ncKitAccount, + classFile: classFile, + contentType: contentType, + creationDate: Date(), // Default as not set in original code + date: date, + directory: directory, + e2eEncrypted: false, // Default as not set in original code + etag: "", // Placeholder as not set in original code + fileId: fileId, + fileName: fileName, + fileNameView: trashbinFileName, + hasPreview: hasPreview, + iconName: iconName, + mountType: "", // Placeholder as not set in original code + ownerId: "", // Placeholder as not set in original code + ownerDisplayName: "", // Placeholder as not set in original code + path: "", // Placeholder as not set in original code + serverUrl: account.trashUrl, + sharePermissionsCollaborationServices: 0, // Default as not set in original code + sharePermissionsCloudMesh: [], // Default as not set in original code + size: size, + uploaded: true, + trashbinFileName: trashbinFileName, + trashbinOriginalLocation: trashbinOriginalLocation, + trashbinDeletionTime: trashbinDeletionTime, + urlBase: account.serverUrl, + user: account.username, + userId: account.id + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift new file mode 100644 index 0000000000000..189af09ee1b8b --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation + +extension Progress { + func setHandlersFromAfRequest(_ request: Request) { + cancellationHandler = { request.cancel() } + pausingHandler = { request.suspend() } + resumingHandler = { request.resume() } + } + + func copyCurrentStateToProgress(_ otherProgress: Progress, includeHandlers: Bool = false) { + if includeHandlers { + otherProgress.cancellationHandler = cancellationHandler + otherProgress.pausingHandler = pausingHandler + otherProgress.resumingHandler = resumingHandler + } + + otherProgress.totalUnitCount = totalUnitCount + otherProgress.completedUnitCount = completedUnitCount + otherProgress.estimatedTimeRemaining = estimatedTimeRemaining + otherProgress.localizedDescription = localizedAdditionalDescription + otherProgress.localizedAdditionalDescription = localizedAdditionalDescription + otherProgress.isCancellable = isCancellable + otherProgress.isPausable = isPausable + otherProgress.fileCompletedCount = fileCompletedCount + otherProgress.fileURL = fileURL + otherProgress.fileTotalCount = fileTotalCount + otherProgress.fileCompletedCount = fileCompletedCount + otherProgress.fileOperationKind = fileOperationKind + otherProgress.kind = kind + otherProgress.throughput = throughput + + for (key, object) in userInfo { + otherProgress.setUserInfoObject(object, forKey: key) + } + } + + func copyOfCurrentState(includeHandlers: Bool = false) -> Progress { + let newProgress = Progress() + copyCurrentStateToProgress(newProgress, includeHandlers: includeHandlers) + return newProgress + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift new file mode 100644 index 0000000000000..ef3b1fbfc2efa --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +private let defaultChunkSize = 64 + +extension RandomAccessCollection { + /// Chunks a collection into an array of its subsequences. + /// - Parameter size: The size of each chunk. + /// - Returns: An array of subsequences (e.g., ArraySlice). + func chunked(into size: Int = defaultChunkSize) -> [SubSequence] { + guard size > 0 else { return [] } // Avoid invalid chunk sizes + var chunks: [SubSequence] = [] + chunks.reserveCapacity(Int(count) / size + 1) + + var currentIndex = startIndex + while currentIndex < endIndex { + let nextIndex = index(currentIndex, offsetBy: size, limitedBy: endIndex) ?? endIndex + chunks.append(self[currentIndex ..< nextIndex]) + currentIndex = nextIndex + } + return chunks + } + + /// Chunks the collection and applies a transformation to each element. + func chunkedMap(into size: Int = defaultChunkSize, transform: (Element) -> T) -> [[T]] { + chunked(into: size).map { chunk in + chunk.map(transform) + } + } + + /// Performs an asynchronous `forEach` operation on the collection in concurrent chunks. + func concurrentChunkedForEach(into size: Int = defaultChunkSize, operation: @escaping @Sendable (Element) async -> Void) async where Element: Sendable { + await withTaskGroup(of: Void.self) { group in + for chunk in chunked(into: size) { + let chunkArray = Array(chunk) + group.addTask(operation: { @Sendable in + for element in chunkArray { + await operation(element) + } + }) + } + } + } + + /// Performs an asynchronous `compactMap` operation on the collection in concurrent chunks. + func concurrentChunkedCompactMap(into size: Int = defaultChunkSize, transform: @escaping @Sendable (Element) throws -> T?) async throws -> [T] where T: Sendable, Element: Sendable { + try await withThrowingTaskGroup(of: [T].self) { group in + var results = [T]() + // Reserving capacity is still a good optimization, though we can't know the exact final count. + results.reserveCapacity(Int(self.count)) + + for chunk in chunked(into: size) { + let chunkArray = Array(chunk) // Convert to Array to ensure Sendable + + group.addTask(operation: { @Sendable in + try chunkArray.compactMap { + try transform($0) + } + }) + } + + for try await chunkResult in group { + results += chunkResult + } + + return results + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift new file mode 100644 index 0000000000000..19dd9b94bb94d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Realm +import RealmSwift + +extension Results where Element: RemoteFileChunk { + func toUnmanagedResults() -> [RemoteFileChunk] { + map { RemoteFileChunk(value: $0) } + } +} + +extension Results where Element: RealmItemMetadata { + func toUnmanagedResults() -> [SendableItemMetadata] { + map { SendableItemMetadata(value: $0) } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift new file mode 100644 index 0000000000000..5aef53ef745b3 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +public extension Substring { + func toString() -> String { String(self) } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift new file mode 100644 index 0000000000000..5357886609bfc --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +private let invalidCharacters = CharacterSet(charactersIn: "<>:/\\|?*\" .") // Include " " and . +private let invalidCharacterReplacement = "_" +private let invalidCharacterReplacementSet = + CharacterSet(charactersIn: invalidCharacterReplacement) + +/// Sanitises a string by replacing invalid characters with underscores. +/// +/// This helper function takes a string and replaces any characters not allowed in filenames +/// with underscores. It also removes leading/trailing underscores and collapses multiple +/// consecutive underscores into a single underscore. +/// +/// - Parameter string: The string to sanitise. +/// - Returns: The sanitised string. +/// +func sanitise(string: String) -> String { + string + .components(separatedBy: invalidCharacters) + .joined(separator: invalidCharacterReplacement) + .trimmingCharacters(in: invalidCharacterReplacementSet) // Remove leading/trailing + .replacingOccurrences( // Replace multiple consecutive replacement chars + of: "\(invalidCharacterReplacement){2,}", + with: invalidCharacterReplacement, + options: .regularExpression + ) +} + +public extension URL { + func safeFilenameFromURLString( + defaultingTo _: String = "default_filename" + ) -> String { + let host = host ?? "" + let query = query ?? "" + + let sanitisedHost = sanitise(string: host) + var sanitisedPath = sanitise(string: path) + if sanitisedPath.hasPrefix("/") { + sanitisedPath.removeFirst() + } + let sanitisedQuery = sanitise(string: query) + let filename = "\(sanitisedHost)_\(sanitisedPath)_\(sanitisedQuery)" + return sanitise(string: filename) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/AuthenticationAttemptResultState.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/AuthenticationAttemptResultState.swift new file mode 100644 index 0000000000000..3d4b48be116c6 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/AuthenticationAttemptResultState.swift @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +public enum AuthenticationAttemptResultState: Int { + case authenticationError + case connectionError + case success +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift new file mode 100644 index 0000000000000..4961c8d7b5902 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +public protocol ChangeNotificationInterface: Sendable { + func notifyChange() +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/EnumerateDepth.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/EnumerateDepth.swift new file mode 100644 index 0000000000000..516042efd8fd3 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/EnumerateDepth.swift @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// +/// How far to traverse down the hierarchy. +/// +public enum EnumerateDepth: String { + /// + /// Only the item itself. + /// + case target = "0" + + /// + /// The item itself and its direct descendants. + /// + case targetAndDirectChildren = "1" + + /// + /// All the way down, even to the farthest descendant. + /// + case targetAndAllChildren = "infinity" +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift new file mode 100644 index 0000000000000..66497e32bd782 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation + +public final class FileProviderChangeNotificationInterface: ChangeNotificationInterface { + let domain: NSFileProviderDomain + let logger: FileProviderLogger + + required init(domain: NSFileProviderDomain, log: any FileProviderLogging) { + self.domain = domain + logger = FileProviderLogger(category: "FileProviderChangeNotificationInterface", log: log) + } + + public func notifyChange() { + Task { @MainActor in + if let manager = NSFileProviderManager(for: domain) { + do { + try await manager.signalEnumerator(for: .workingSet) + } catch { + self.logger.error("Could not signal enumerator.", [.domain: self.domain.identifier, .error: error]) + } + } + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift new file mode 100644 index 0000000000000..6e89fa53dd5fd --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit + +extension NextcloudKit: RemoteInterface { + public func setDelegate(_ delegate: any NextcloudKitDelegate) { + setup(delegate: delegate) + } + + public func createFolder( + remotePath: String, + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) { + await withCheckedContinuation { continuation in + createFolder( + serverUrlFileName: remotePath, + account: account.ncKitAccount, + options: options, + taskHandler: taskHandler + ) { account, ocId, date, _, error in + continuation.resume(returning: (account, ocId, date as NSDate?, error)) + } + } + } + + public func upload( + remotePath: String, + localPath: String, + creationDate: Date? = nil, + modificationDate: Date? = nil, + account: Account, + options: NKRequestOptions = .init(), + requestHandler: @escaping (UploadRequest) -> Void = { _ in }, + taskHandler: @escaping (URLSessionTask) -> Void = { _ in }, + progressHandler: @escaping (Progress) -> Void = { _ in } + ) async -> ( + account: String, + ocId: String?, + etag: String?, + date: NSDate?, + size: Int64, + response: HTTPURLResponse?, + remoteError: NKError + ) { + await withCheckedContinuation { continuation in + upload( + serverUrlFileName: remotePath, + fileNameLocalPath: localPath, + dateCreationFile: creationDate, + dateModificationFile: modificationDate, + account: account.ncKitAccount, + options: options, + requestHandler: requestHandler, + taskHandler: taskHandler, + progressHandler: progressHandler + ) { account, ocId, etag, date, size, response, nkError in + continuation.resume(returning: ( + account, + ocId, + etag, + date as NSDate?, + size, + response?.response, + nkError + )) + } + } + } + + public func chunkedUpload( + localPath: String, + remotePath: String, + remoteChunkStoreFolderName: String = UUID().uuidString, + chunkSize: Int, + remainingChunks: [RemoteFileChunk], + creationDate: Date? = nil, + modificationDate: Date? = nil, + account: Account, + options: NKRequestOptions = .init(), + currentNumChunksUpdateHandler: @escaping (_ num: Int) -> Void = { _ in }, + chunkCounter: @escaping (_ counter: Int) -> Void = { _ in }, + log: any FileProviderLogging, + chunkUploadStartHandler: @escaping (_ filesChunk: [RemoteFileChunk]) -> Void = { _ in }, + requestHandler: @escaping (_ request: UploadRequest) -> Void = { _ in }, + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + progressHandler: @escaping (Progress) -> Void = { _ in }, + chunkUploadCompleteHandler: @escaping (_ fileChunk: RemoteFileChunk) -> Void = { _ in } + ) async -> ( + account: String, + fileChunks: [RemoteFileChunk]?, + file: NKFile?, + nkError: NKError + ) { + let logger = FileProviderLogger(category: "NextcloudKit+RemoteInterface", log: log) + + guard let remoteUrl = URL(string: remotePath) else { + return ("", nil, nil, .urlError) + } + let localUrl = URL(fileURLWithPath: localPath) + + let fm = FileManager.default + let chunksOutputDirectoryUrl = + fm.temporaryDirectory.appendingPathComponent(remoteChunkStoreFolderName) + do { + try fm.createDirectory(at: chunksOutputDirectoryUrl, withIntermediateDirectories: true) + } catch { + logger.error( + """ + Could not create temporary directory for chunked files: \(error) + """ + ) + return ("", nil, nil, .urlError) + } + + var directory = localUrl.deletingLastPathComponent().path + if directory.last == "/" { + directory.removeLast() + } + let fileChunksOutputDirectory = chunksOutputDirectoryUrl.path + let fileName = localUrl.lastPathComponent + let destinationFileName = remoteUrl.lastPathComponent + guard let serverUrl = remoteUrl + .deletingLastPathComponent() + .absoluteString + .removingPercentEncoding + else { + logger.error( + "NCKit ext: Could not get server url from \(remotePath)" + ) + return ("", nil, nil, .urlError) + } + let fileChunks = remainingChunks.toNcKitChunks() + + logger.info( + """ + Beginning chunked upload of: \(localPath) + directory: \(directory) + fileChunksOutputDirectory: \(fileChunksOutputDirectory) + fileName: \(fileName) + destinationFileName: \(destinationFileName) + date: \(modificationDate?.debugDescription ?? "") + creationDate: \(creationDate?.debugDescription ?? "") + serverUrl: \(serverUrl) + chunkFolder: \(remoteChunkStoreFolderName) + filesChunk: \(fileChunks) + chunkSize: \(chunkSize) + """ + ) + + return await withCheckedContinuation { continuation in + uploadChunk( + directory: directory, + fileChunksOutputDirectory: fileChunksOutputDirectory, + fileName: fileName, + destinationFileName: destinationFileName, + date: modificationDate, + creationDate: creationDate, + serverUrl: serverUrl, + chunkFolder: remoteChunkStoreFolderName, + filesChunk: fileChunks, + chunkSize: chunkSize, + account: account.ncKitAccount, + options: options, + numChunks: currentNumChunksUpdateHandler, + counterChunk: chunkCounter, + start: { processedChunks in + let chunks = RemoteFileChunk.fromNcKitChunks( + processedChunks, remoteChunkStoreFolderName: remoteChunkStoreFolderName + ) + chunkUploadStartHandler(chunks) + }, + requestHandler: requestHandler, + taskHandler: taskHandler, + progressHandler: { totalBytesExpected, totalBytes, _ in + let currentProgress = Progress(totalUnitCount: totalBytesExpected) + currentProgress.completedUnitCount = totalBytes + progressHandler(currentProgress) + }, + uploaded: { uploadedChunk in + let chunk = RemoteFileChunk( + ncKitChunk: uploadedChunk, + remoteChunkStoreFolderName: remoteChunkStoreFolderName + ) + chunkUploadCompleteHandler(chunk) + } + ) { account, receivedChunks, file, error in + let chunks = RemoteFileChunk.fromNcKitChunks( + receivedChunks ?? [], remoteChunkStoreFolderName: remoteChunkStoreFolderName + ) + continuation.resume(returning: (account, chunks, file, error)) + } + } + } + + public func move( + remotePathSource: String, + remotePathDestination: String, + overwrite: Bool, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { + await withCheckedContinuation { continuation in + moveFileOrFolder( + serverUrlFileNameSource: remotePathSource, + serverUrlFileNameDestination: remotePathDestination, + overwrite: overwrite, + account: account.ncKitAccount, + options: options, + taskHandler: taskHandler + ) { account, data, error in + continuation.resume(returning: (account, data?.data, error)) + } + } + } + + public func enumerate( + remotePath: String, + depth: EnumerateDepth, + showHiddenFiles: Bool = false, + includeHiddenFiles: [String] = [], + requestBody: Data? = nil, + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> ( + account: String, files: [NKFile], data: AFDataResponse?, error: NKError + ) { + await withCheckedContinuation { continuation in + readFileOrFolder( + serverUrlFileName: remotePath, + depth: depth.rawValue, + showHiddenFiles: showHiddenFiles, + includeHiddenFiles: includeHiddenFiles, + requestBody: requestBody, + account: account.ncKitAccount, + options: options, + taskHandler: taskHandler + ) { account, files, data, error in + continuation.resume(returning: (account, files ?? [], data, error)) + } + } + } + + public func delete( + remotePath: String, + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + await withCheckedContinuation { continuation in + deleteFileOrFolder( + serverUrlFileName: remotePath, account: account.ncKitAccount + ) { account, response, error in + continuation.resume(returning: (account, response?.response, error)) + } + } + } + + public func lockUnlockFile(serverUrlFileName: String, type: NKLockType?, shouldLock: Bool, account: Account, options: NKRequestOptions, taskHandler: @escaping (URLSessionTask) -> Void) async throws -> NKLock? { + try await lockUnlockFile(serverUrlFileName: serverUrlFileName, type: type, shouldLock: shouldLock, account: account.ncKitAccount, options: options, taskHandler: taskHandler) + } + + public func restoreFromTrash( + filename: String, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { + let trashFileUrl = account.trashUrl + "/" + filename + let recoverFileUrl = account.trashRestoreUrl + "/" + filename + + return await move( + remotePathSource: trashFileUrl, + remotePathDestination: recoverFileUrl, + overwrite: true, + account: account, + options: options, + taskHandler: taskHandler + ) + } + + public func downloadThumbnail( + url: URL, + account: Account, + options: NKRequestOptions, + taskHandler: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { + await withCheckedContinuation { continuation in + downloadPreview( + url: url, account: account.ncKitAccount, options: options, taskHandler: taskHandler + ) { account, data, error in + continuation.resume(returning: (account, data?.data, error)) + } + } + } + + public func fetchCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: true + ) + let result = await withCheckedContinuation { continuation in + getCapabilities(account: account.ncKitAccount, options: options, taskHandler: taskHandler) { account, capabilities, responseData, error in + let capabilities: Capabilities? = { + guard let realData = responseData?.data else { return nil } + return Capabilities(data: realData) + }() + continuation.resume(returning: (account, capabilities, responseData?.data, error)) + } + } + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: false + ) + if let capabilities = result.1 { + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: account.ncKitAccount, capabilities: capabilities + ) + } + return result + } + + public func tryAuthenticationAttempt( + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> AuthenticationAttemptResultState { + // Test by trying to fetch user profile + let (_, _, _, error) = + await enumerate(remotePath: account.davFilesUrl + "/", depth: .target, account: account) + + if error != .success { + nkLog(error: "Error in auth check: \(error.errorDescription)") + } + + if error == .success { + return .success + } else if error.isCouldntConnectError { + return .connectionError + } else { + return .authenticationError + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift new file mode 100644 index 0000000000000..852237514907c --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit + +/// +/// Abstraction of the Nextcloud server APIs to call from the file provider extension. +/// +/// Usually, the shared `NextcloudKit` instance is conforming to this and provided as an argument. +/// NextcloudKit is not mockable as of writing, hence this protocol was defined to enable testing. +/// +public protocol RemoteInterface: Sendable { + func setDelegate(_ delegate: NextcloudKitDelegate) + + func createFolder( + remotePath: String, + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) + + func upload( + remotePath: String, + localPath: String, + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + requestHandler: @escaping (_ request: UploadRequest) -> Void, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void, + progressHandler: @escaping (_ progress: Progress) -> Void + ) async -> ( + account: String, + ocId: String?, + etag: String?, + date: NSDate?, + size: Int64, + response: HTTPURLResponse?, + remoteError: NKError + ) + + func chunkedUpload( + localPath: String, + remotePath: String, + remoteChunkStoreFolderName: String, + chunkSize: Int, + remainingChunks: [RemoteFileChunk], + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + currentNumChunksUpdateHandler: @escaping (_ num: Int) -> Void, + chunkCounter: @escaping (_ counter: Int) -> Void, + log: any FileProviderLogging, + chunkUploadStartHandler: @escaping (_ filesChunk: [RemoteFileChunk]) -> Void, + requestHandler: @escaping (_ request: UploadRequest) -> Void, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void, + progressHandler: @escaping (Progress) -> Void, + chunkUploadCompleteHandler: @escaping (_ fileChunk: RemoteFileChunk) -> Void + ) async -> ( + account: String, + fileChunks: [RemoteFileChunk]?, + file: NKFile?, + nkError: NKError + ) + + func move( + remotePathSource: String, + remotePathDestination: String, + overwrite: Bool, + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) + + func downloadAsync( + serverUrlFileName: Any, + fileNameLocalPath: String, + account: String, + options: NKRequestOptions, + requestHandler: @escaping (_ request: DownloadRequest) -> Void, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void, + progressHandler: @escaping (_ progress: Progress) -> Void + ) async -> ( + account: String, + etag: String?, + date: Date?, + length: Int64, + headers: [AnyHashable: any Sendable]?, + afError: AFError?, + nkError: NKError + ) + + func enumerate( + remotePath: String, + depth: EnumerateDepth, + showHiddenFiles: Bool, + includeHiddenFiles: [String], + requestBody: Data?, + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, files: [NKFile], data: AFDataResponse?, error: NKError) + + func delete( + remotePath: String, + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) + + func lockUnlockFile(serverUrlFileName: String, type: NKLockType?, shouldLock: Bool, account: Account, options: NKRequestOptions, taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void) async throws -> NKLock? + + func listingTrashAsync( + filename: String?, + showHiddenFiles: Bool, + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + items: [NKTrash]?, + responseData: AFDataResponse?, + error: NKError + ) + + func restoreFromTrash( + filename: String, + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) + + func downloadThumbnail( + url: URL, + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) + + func fetchCapabilities( + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) + + func getUserProfileAsync( + account: String, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + userProfile: NKUserProfile?, + responseData: AFDataResponse?, + error: NKError + ) + + func tryAuthenticationAttempt( + account: Account, + options: NKRequestOptions, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> AuthenticationAttemptResultState +} + +public extension RemoteInterface { + func currentCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.awaitFetchCompletion(forAccount: ncKitAccount) + + guard let lastRetrieval = await RetrievedCapabilitiesActor.shared.getCapabilities(for: ncKitAccount), lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval + else { + return await fetchCapabilities(account: account, options: options, taskHandler: taskHandler) + } + + return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) + } + + func supportsTrash( + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> Bool { + var remoteSupportsTrash = false + + let (_, capabilities, _, _) = await currentCapabilities( + account: account, options: .init(), taskHandler: { _ in } + ) + + if let filesCapabilities = capabilities?.files { + remoteSupportsTrash = filesCapabilities.undelete + } + + return remoteSupportsTrash + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Create.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Create.swift new file mode 100644 index 0000000000000..05f6ae3e1e3d8 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Create.swift @@ -0,0 +1,658 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit + +public extension Item { + /// + /// Create a new folder on the server. + /// + private static func createNewFolder( + itemTemplate: NSFileProviderItem?, + remotePath: String, + parentItemIdentifier: NSFileProviderItemIdentifier, + domain: NSFileProviderDomain? = nil, + account: Account, + remoteInterface: RemoteInterface, + progress _: Progress, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async -> (Item?, Error?) { + let logger = FileProviderLogger(category: "Item", log: log) + + let (_, _, _, createError) = await remoteInterface.createFolder( + remotePath: remotePath, account: account, options: .init(), taskHandler: { task in + if let domain, let itemTemplate { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: itemTemplate.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard createError == .success else { + logger.error( + """ + Could not create new folder at: \(remotePath), + received error: \(createError.errorCode) + \(createError.errorDescription) + """ + ) + return await (nil, createError.fileProviderError( + handlingCollisionAgainstItemInRemotePath: remotePath, + dbManager: dbManager, + remoteInterface: remoteInterface, + log: log + )) + } + + // Read contents after creation + let (_, files, _, readError) = await remoteInterface.enumerate( + remotePath: remotePath, + depth: .target, + showHiddenFiles: true, + includeHiddenFiles: [], + requestBody: nil, + account: account, + options: .init(), + taskHandler: { task in + if let domain, let itemTemplate { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: itemTemplate.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard readError == .success else { + logger.error( + """ + Could not read new folder at: \(remotePath), + received error: \(readError.errorCode) + \(readError.errorDescription) + """ + ) + return await (nil, readError.fileProviderError( + handlingCollisionAgainstItemInRemotePath: remotePath, + dbManager: dbManager, + remoteInterface: remoteInterface, + log: log + )) + } + + guard var (directory, _, _) = await files.toSendableDirectoryMetadata(account: account, directoryToRead: remotePath) else { + logger.error("Failed to resolve directory metadata on item conversion!") + return (nil, NSFileProviderError(.cannotSynchronize)) + } + + directory.downloaded = true + dbManager.addItemMetadata(directory) + + let fpItem = await Item( + metadata: directory, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ) + + return (fpItem, nil) + } + + private static func createNewFile( + remotePath: String, + localPath: String, + itemTemplate: NSFileProviderItem, + parentItemRemotePath: String, + domain: NSFileProviderDomain? = nil, + account: Account, + remoteInterface: RemoteInterface, + forcedChunkSize: Int?, + progress: Progress, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async -> (Item?, Error?) { + let logger = FileProviderLogger(category: "Item", log: log) + let chunkUploadId = + itemTemplate.itemIdentifier.rawValue.replacingOccurrences(of: "/", with: "") + let (ocId, _, etag, date, size, error) = await upload( + fileLocatedAt: localPath, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: account, + inChunksSized: forcedChunkSize, + usingChunkUploadId: chunkUploadId, + dbManager: dbManager, + creationDate: itemTemplate.creationDate as? Date, + modificationDate: itemTemplate.contentModificationDate as? Date, + log: log, + requestHandler: { progress.setHandlersFromAfRequest($0) }, + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: itemTemplate.itemIdentifier, + completionHandler: { _ in } + ) + } + }, + progressHandler: { $0.copyCurrentStateToProgress(progress) } + ) + + guard error == .success, let ocId else { + logger.error( + """ + Could not upload item with filename: \(itemTemplate.filename), + received error: \(error.errorCode) + \(error.errorDescription) + received ocId: \(ocId ?? "empty") + """ + ) + return await (nil, error.fileProviderError( + handlingCollisionAgainstItemInRemotePath: remotePath, + dbManager: dbManager, + remoteInterface: remoteInterface, + log: log + )) + } + + logger.info( + """ + Successfully uploaded item with identifier: \(ocId) + filename: \(itemTemplate.filename) + ocId: \(ocId) + etag: \(etag ?? "") + date: \(date ?? Date()) + size: \(Int(size ?? -1)), + account: \(account.ncKitAccount) + """ + ) + + if let expectedSize = itemTemplate.documentSize??.int64Value, size != expectedSize { + logger.info( + """ + Created item upload reported as successful, but there are differences between + the received file size (\(Int(size ?? -1))) + and the original file size (\(itemTemplate.documentSize??.int64Value ?? 0)) + """ + ) + } + + let newMetadata = SendableItemMetadata( + ocId: ocId, + account: account.ncKitAccount, + classFile: "", // Placeholder as not set in original code + contentType: itemTemplate.contentType?.preferredMIMEType ?? "", + creationDate: Date(), // Default as not set in original code + date: date ?? Date(), + directory: false, + e2eEncrypted: false, // Default as not set in original code + etag: etag ?? "", + fileId: "", // Placeholder as not set in original code + fileName: itemTemplate.filename, + fileNameView: itemTemplate.filename, + hasPreview: false, // Default as not set in original code + iconName: "", // Placeholder as not set in original code + mountType: "", // Placeholder as not set in original code + ownerId: "", // Placeholder as not set in original code + ownerDisplayName: "", // Placeholder as not set in original code + path: "", // Placeholder as not set in original code + serverUrl: parentItemRemotePath, + size: size ?? 0, + status: Status.normal.rawValue, + downloaded: true, + uploaded: true, + urlBase: account.serverUrl, + user: account.username, + userId: account.id + ) + + dbManager.addItemMetadata(newMetadata) + + let fpItem = await Item( + metadata: newMetadata, + parentItemIdentifier: itemTemplate.parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ) + + return (fpItem, nil) + } + + @discardableResult private static func createBundleOrPackageInternals( + rootItem: Item, + contents: URL, + remotePath: String, + domain: NSFileProviderDomain? = nil, + account: Account, + remoteInterface: RemoteInterface, + forcedChunkSize: Int?, + progress: Progress, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async throws -> Item? { + let logger = FileProviderLogger(category: "Item", log: log) + + logger.debug( + """ + Handling new bundle/package/internal directory at: \(contents.path) + """ + ) + let attributesToFetch: Set = [ + .isDirectoryKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey + ] + let fm = FileManager.default + guard let enumerator = fm.enumerator( + at: contents, includingPropertiesForKeys: Array(attributesToFetch) + ) else { + logger.error( + """ + Could not create enumerator for contents of bundle or package + at: \(contents.path) + """ + ) + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorResourceUnavailable) + } + + guard let enumeratorArray = enumerator.allObjects as? [URL] else { + logger.error( + """ + Could not create enumerator array for contents of bundle or package + at: \(contents.path) + """ + ) + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorResourceUnavailable) + } + + func remoteErrorToThrow(_ error: NKError) -> Error { + error.fileProviderError ?? NSFileProviderError(.cannotSynchronize) + } + + let contentsPath = contents.path + let privatePrefix = "/private" + let privateContentsPath = contentsPath.hasPrefix(privatePrefix) + var remoteDirectoriesPaths = [remotePath] + + // Add one more total unit count to signify final reconciliation of bundle creation process + progress.totalUnitCount = Int64(enumeratorArray.count) + 1 + + for childUrl in enumeratorArray { + var childUrlPath = childUrl.path + if childUrlPath.hasPrefix(privatePrefix), !privateContentsPath { + childUrlPath.removeFirst(privatePrefix.count) + } + let childRelativePath = childUrlPath.replacingOccurrences(of: contents.path, with: "") + let childRemoteUrl = remotePath + childRelativePath + let childUrlAttributes = try childUrl.resourceValues(forKeys: attributesToFetch) + + if childUrlAttributes.isDirectory ?? false { + logger.debug( + """ + Handling child bundle or package directory at: \(childUrlPath) + """ + ) + let (_, _, _, createError) = await remoteInterface.createFolder( + remotePath: childRemoteUrl, + account: account, + options: .init(), taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: rootItem.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + // As with the creating of the bundle's root folder, we do not want to abort on fail + // as we might have faced an error creating some other internal content and we want + // to retry all of its contents + guard createError == .success || createError.matchesCollisionError else { + logger.error( + """ + Could not create new bpi folder at: \(remotePath), + received error: \(createError.errorCode) + \(createError.errorDescription) + """ + ) + throw remoteErrorToThrow(createError) + } + remoteDirectoriesPaths.append(childRemoteUrl) + + } else { + logger.debug( + """ + Handling child bundle or package file at: \(childUrlPath) + """ + ) + let (_, _, _, _, _, error) = await upload( + fileLocatedAt: childUrlPath, + toRemotePath: childRemoteUrl, + usingRemoteInterface: remoteInterface, + withAccount: account, + inChunksSized: forcedChunkSize, + dbManager: dbManager, + creationDate: childUrlAttributes.creationDate, + modificationDate: childUrlAttributes.contentModificationDate, + log: log, + requestHandler: { progress.setHandlersFromAfRequest($0) }, + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: rootItem.itemIdentifier, + completionHandler: { _ in } + ) + } + }, + progressHandler: { _ in } + ) + + // Do not fail on existing item, just keep going + guard error == .success || error.matchesCollisionError else { + logger.error( + """ + Could not upload bpi file at: \(childUrlPath), + received error: \(error.errorCode) + \(error.errorDescription) + """ + ) + throw remoteErrorToThrow(error) + } + } + progress.completedUnitCount += 1 + } + + for remoteDirectoryPath in remoteDirectoriesPaths { + // After everything, check into what the final state is of each folder now + logger.debug("Reading bpi folder at: \(remoteDirectoryPath)") + + let (_, _, _, _, _, readError) = await Enumerator.readServerUrl( + remoteDirectoryPath, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: log + ) + + if let readError, readError != .success { + logger.error( + """ + Could not read bpi folder at: \(remotePath), + received error: \(readError.errorDescription) + """ + ) + throw remoteErrorToThrow(readError) + } + } + + guard let bundleRootMetadata = dbManager.itemMetadata( + account: account.ncKitAccount, locatedAtRemoteUrl: remotePath + ) else { + logger.error( + """ + Could not find directory metadata for bundle or package at: + \(remotePath) + of account: + \(account.ncKitAccount) + with contents located at: + \(contentsPath) + """ + ) + // Yes, it's weird to throw a "non-existent item" error during an item's creation. + // No, it's not the wrong solution. Thanks to the peculiar way we have to handle bundles + // things can happen as we are populating the bundle remotely and then checking it. + throw NSError.fileProviderErrorForNonExistentItem( + withIdentifier: rootItem.itemIdentifier + ) + } + + progress.completedUnitCount += 1 + + return await Item( + metadata: bundleRootMetadata, + parentItemIdentifier: rootItem.parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ) + } + + static func create( + basedOn itemTemplate: NSFileProviderItem, + fields _: NSFileProviderItemFields = NSFileProviderItemFields(), + contents url: URL?, + options: NSFileProviderCreateItemOptions = [], + request _: NSFileProviderRequest = NSFileProviderRequest(), + domain: NSFileProviderDomain? = nil, + account: Account, + remoteInterface: RemoteInterface, + ignoredFiles: IgnoredFilesMatcher? = nil, + forcedChunkSize: Int? = nil, + progress: Progress, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async -> (Item?, Error?) { + let logger = FileProviderLogger(category: "Item", log: log) + let tempId = itemTemplate.itemIdentifier.rawValue + + guard itemTemplate.contentType != .symbolicLink else { + logger.error( + "Cannot create item \(tempId), symbolic links not supported." + ) + return (nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + } + + if options.contains(.mayAlreadyExist) { + // TODO: This needs to be properly handled with a check in the db + logger.info( + """ + Not creating item: \(itemTemplate.itemIdentifier.rawValue) + as it may already exist + """ + ) + return (nil, NSFileProviderError(.cannotSynchronize)) + } + + let parentItemIdentifier = itemTemplate.parentItemIdentifier + var parentItemRemotePath: String + var parentItemRelativePath: String + + // TODO: Deduplicate + if parentItemIdentifier == .rootContainer { + parentItemRemotePath = account.davFilesUrl + parentItemRelativePath = "/" + } else { + guard let parentItemMetadata = dbManager.directoryMetadata( + ocId: parentItemIdentifier.rawValue + ) else { + logger.error( + """ + Not creating item: \(itemTemplate.itemIdentifier.rawValue), + could not find metadata for parentItemIdentifier: + \(parentItemIdentifier.rawValue) + """ + ) + return (nil, NSFileProviderError(.cannotSynchronize)) + } + parentItemRemotePath = parentItemMetadata.remotePath() + parentItemRelativePath = parentItemRemotePath.replacingOccurrences( + of: account.davFilesUrl, with: "" + ) + assert(parentItemRelativePath.starts(with: "/")) + } + + let itemTemplateIsFolder = itemTemplate.contentType?.conforms(to: .directory) ?? false + + guard !isLockFileName(itemTemplate.filename) || itemTemplateIsFolder else { + return await Item.createLockFile( + basedOn: itemTemplate, + parentItemIdentifier: parentItemIdentifier, + parentItemRemotePath: parentItemRemotePath, + progress: progress, + domain: domain, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: log + ) + } + + let relativePath = parentItemRelativePath + "/" + itemTemplate.filename + guard ignoredFiles == nil || ignoredFiles?.isExcluded(relativePath) == false else { + return await Item.createIgnored( + basedOn: itemTemplate, + parentItemRemotePath: parentItemRemotePath, + contents: url, + account: account, + remoteInterface: remoteInterface, + progress: progress, + dbManager: dbManager, + log: log + ) + } + + let fileNameLocalPath = url?.path ?? "" + let newServerUrlFileName = parentItemRemotePath + "/" + itemTemplate.filename + + logger.debug( + """ + About to upload item with identifier: \(tempId) + of type: \(itemTemplate.contentType?.identifier ?? "UNKNOWN") + (is folder: \(itemTemplateIsFolder ? "yes" : "no") + and filename: \(itemTemplate.filename) + to server url: \(newServerUrlFileName) + with contents located at: \(fileNameLocalPath) + """ + ) + + guard !itemTemplateIsFolder else { + let isBundleOrPackage = + itemTemplate.contentType?.conforms(to: .bundle) == true || + itemTemplate.contentType?.conforms(to: .package) == true + + var (item, error) = await Self.createNewFolder( + itemTemplate: itemTemplate, + remotePath: newServerUrlFileName, + parentItemIdentifier: parentItemIdentifier, + domain: domain, + account: account, + remoteInterface: remoteInterface, + progress: isBundleOrPackage ? Progress() : progress, + dbManager: dbManager, + log: log + ) + + guard isBundleOrPackage else { + return (item, error) + } + + // Ignore collision errors as we might have faced an error creating one of the bundle's + // internal files or folders and we want to retry all of its contents + let fpErrorCode = (error as? NSFileProviderError)?.code + guard error == nil || fpErrorCode == .filenameCollision else { + logger.error("Could not create item.", [.item: item?.itemIdentifier, .error: error]) + return (item, error) + } + + if item == nil { + logger.debug("Item is a bundle or package whose root folder already exists, ignoring errors. Fetching remote information, proceeding with creation of internal contents.") + let (metadatas, _, _, _, _, readError) = await Enumerator.readServerUrl( + newServerUrlFileName, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + domain: domain, + depth: .target, + log: log + ) + + if let readError, readError != .success { + logger.error("Could not read existing bundle or package folder.", [.error: readError, .url: newServerUrlFileName]) + return (nil, readError.fileProviderError) + } + guard let itemMetadata = metadatas?.first else { + logger.error("Could not create item for remotely-existing bundle or package. This should not happen.", [.item: tempId]) + + return ( + nil, + NSError.fileProviderErrorForNonExistentItem( + withIdentifier: itemTemplate.itemIdentifier + ) + ) + } + + item = await Item( + metadata: itemMetadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ) + } + + guard let item else { + logger.error("Could not create item for remotely-existing bundle or package as item is null. This should not happen!", [.item: tempId]) + return (nil, NSFileProviderError(.cannotSynchronize)) + } + + guard let url else { + logger.error("Could not create item as it is a bundle or package and no contents were provided.", [.item: tempId]) + return (nil, NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL)) + } + + // Bundles and packages are given to us as if they were files -- i.e. we don't get + // notified about internal changes. So we need to manually handle their internal + // contents + logger.debug("Handling bundle or package contents for item.", [.item: tempId]) + + do { + return try await (Self.createBundleOrPackageInternals( + rootItem: item, + contents: url, + remotePath: newServerUrlFileName, + domain: domain, + account: account, + remoteInterface: remoteInterface, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager, + log: log + ), nil) + } catch { + return (nil, error) + } + } + + return await Self.createNewFile( + remotePath: newServerUrlFileName, + localPath: fileNameLocalPath, + itemTemplate: itemTemplate, + parentItemRemotePath: parentItemRemotePath, + domain: domain, + account: account, + remoteInterface: remoteInterface, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager, + log: log + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift new file mode 100644 index 0000000000000..e955b709252af --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit + +public extension Item { + /// > Note: The trashing parameter does not affect whether the server will trash this or not. + /// That's out of our hands. Instead, this is used internally to properly handle the metadata + /// update automatically when we conduct a move of an item to the trash. + func delete( + trashing: Bool = false, + options: NSFileProviderDeleteItemOptions = [.recursive], + domain: NSFileProviderDomain? = nil, + ignoredFiles: IgnoredFilesMatcher? = nil, + dbManager: FilesDatabaseManager + ) async -> Error? { + let isEmptyDirOrIsFile = childItemCount == nil || childItemCount == 0 + + guard trashing || isEmptyDirOrIsFile || options.contains(.recursive) else { + return NSFileProviderError(.directoryNotEmpty) + } + + let ocId = itemIdentifier.rawValue + let relativePath = (metadata.remotePath()).replacingOccurrences(of: metadata.urlBase, with: "") + + guard metadata.isLockFileOfLocalOrigin == false else { + return await deleteLockFile(domain: domain, dbManager: dbManager) + } + + guard ignoredFiles == nil || ignoredFiles?.isExcluded(relativePath) == false else { + logger.info("File is in the ignore list. Will delete from local database with no remote effect.", [.item: itemIdentifier, .name: filename]) + dbManager.deleteItemMetadata(ocId: ocId) + return nil + } + + let serverFileNameUrl = metadata.remotePath() + + guard serverFileNameUrl != "" else { + return NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + } + + guard metadata.classFile != "lock", !isLockFileName(metadata.fileName) else { + return await deleteLockFile(domain: domain, dbManager: dbManager) + } + + let (_, _, error) = await remoteInterface.delete( + remotePath: serverFileNameUrl, + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard error == .success else { + logger.error("Could not delete item.", [.item: ocId, .url: serverFileNameUrl, .error: error]) + return error.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: itemIdentifier) + } + + logger.info("Successfully deleted item.", [.item: ocId, .url: serverFileNameUrl]) + + guard trashing else { + handleMetadataDeletion() + return nil + } + + return handleMetadataTrashModification() + } + + private func handleMetadataDeletion() { + let ocId = metadata.ocId + + if metadata.directory { + _ = dbManager.deleteDirectoryAndSubdirectoriesMetadata(ocId: ocId) + } else { + dbManager.deleteItemMetadata(ocId: ocId) + } + } + + // NOTE: the trashing metadata modification procedure here is rough. You SHOULD run a rescan of + // the trash in order to ensure you are getting a correct picture of the item's current remote + // state! This is important particularly for receiving the correct trash bin filename in case of + // there being a previous item in the trash with the same name, prompting the server to rename + // the newly-trashed target item + private func handleMetadataTrashModification() -> Error? { + let ocId = metadata.ocId + + if metadata.directory { + _ = dbManager.renameDirectoryAndPropagateToChildren( + ocId: ocId, + newServerUrl: account.trashUrl, + newFileName: filename + ) + } else { + dbManager.renameItemMetadata(ocId: ocId, newServerUrl: account.trashUrl, newFileName: filename) + } + + guard var metadata = dbManager.itemMetadata(ocId: ocId) else { + logger.error("Could not find item metadata! Cannot finish trashing procedure!", [.item: itemIdentifier, .name: filename]) + return NSFileProviderError(.cannotSynchronize) + } + + metadata.trashbinFileName = filename + metadata.trashbinDeletionTime = Date() + metadata.trashbinOriginalLocation = String(self.metadata.serverUrl + "/" + filename).replacingOccurrences(of: account.davFilesUrl + "/", with: "") + dbManager.addItemMetadata(metadata) + + return nil + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift new file mode 100644 index 0000000000000..0c62ce67c88be --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit + +public extension Item { + private func fetchDirectoryContents( + itemIdentifier: NSFileProviderItemIdentifier, + directoryLocalPath: String, + directoryRemotePath: String, + domain: NSFileProviderDomain?, + progress: Progress + ) async throws { + progress.totalUnitCount = 1 // Add 1 for final procedures + + // Download *everything* within this directory. What we do: + // 1. Enumerate the contents of the directory + // 2. Download everything within this directory + // 3. Detect child directories + // 4. Repeat 1 -> 3 for each child directory + var remoteDirectoryPaths = [directoryRemotePath] + while !remoteDirectoryPaths.isEmpty { + let remoteDirectoryPath = remoteDirectoryPaths.removeFirst() + let (metadatas, _, _, _, _, readError) = await Enumerator.readServerUrl( + remoteDirectoryPath, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: logger.log + ) + + if let readError, readError != .success { + logger.error("Could not enumerate directory contents.", [.name: metadata.fileName, .url: remoteDirectoryPath, .error: readError]) + + throw readError.fileProviderError( + handlingNoSuchItemErrorUsingItemIdentifier: itemIdentifier + ) ?? NSFileProviderError(.cannotSynchronize) + } + + guard var metadatas else { + logger.error("Could not fetch directory contents.", [.name: metadata.fileName, .url: remoteDirectoryPath]) + throw NSFileProviderError(.cannotSynchronize) + } + + if !metadatas.isEmpty { + metadatas.removeFirst() // Remove the dir itself + } + progress.totalUnitCount += Int64(metadatas.count) + + for var metadata in metadatas { + let remotePath = metadata.remotePath() + let relativePath = + remotePath.replacingOccurrences(of: directoryRemotePath, with: "") + let childLocalPath = directoryLocalPath + relativePath + + if metadata.directory { + remoteDirectoryPaths.append(remotePath) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: childLocalPath), + withIntermediateDirectories: true, + attributes: nil + ) + } else { + let identifier = NSFileProviderItemIdentifier(metadata.ocId) + + let (_, _, _, _, _, _, error) = await remoteInterface.downloadAsync( + serverUrlFileName: remotePath, + fileNameLocalPath: childLocalPath, + account: account.ncKitAccount, + options: .init(), + requestHandler: { progress.setHandlersFromAfRequest($0) }, + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register(task, forItemWithIdentifier: identifier, completionHandler: { _ in }) + } + }, + progressHandler: { _ in } + ) + + guard error == .success else { + logger.error("Could not acquire contents of item.", [.name: metadata.fileName, .url: remotePath, .error: error]) + metadata.status = Status.downloadError.rawValue + metadata.sessionError = error.errorDescription + dbManager.addItemMetadata(metadata) + throw error.fileProviderError( + handlingNoSuchItemErrorUsingItemIdentifier: itemIdentifier + ) ?? NSFileProviderError(.cannotSynchronize) + } + } + + metadata.status = Status.normal.rawValue + metadata.downloaded = true + // HACK: We were previously failing to correctly set the uploaded state to true for + // enumerated items. Fix it now to ensure we do not show "waiting for upload" when + // having downloaded incorrectly enumerated files + metadata.uploaded = true + metadata.sessionError = "" + dbManager.addItemMetadata(metadata) + + progress.completedUnitCount += 1 + } + } + + progress.completedUnitCount += 1 // Finish off + } + + func fetchContents( + domain: NSFileProviderDomain? = nil, + progress: Progress = .init(), + dbManager: FilesDatabaseManager + ) async -> (URL?, Item?, Error?) { + let ocId = itemIdentifier.rawValue + guard metadata.classFile != "lock", !isLockFileName(filename) else { + logger.info("System requested fetch of lock file, will just provide local contents URL if possible.", [.name: filename]) + + if let domain, let localUrl = await localUrlForContents(domain: domain) { + return (localUrl, self, nil) + } else if #available(macOS 13.0, *) { + logger.error("Could not get local contents URL for lock file, erroring.") + return (nil, self, NSFileProviderError(.excludedFromSync)) + } else { + logger.error("Could not get local contents URL for lock file, nilling.") + return (nil, self, nil) + } + } + + let serverUrlFileName = metadata.remotePath() + + logger.debug("Fetching item.", [.name: metadata.fileName, .url: serverUrlFileName]) + + let localPath = FileManager.default.temporaryDirectory.appendingPathComponent(metadata.ocId) + guard var updatedMetadata = dbManager.setStatusForItemMetadata(metadata, status: .downloading) else { + logger.error("Could not acquire updated metadata, unable to update item status to downloading.", [.item: itemIdentifier]) + + return ( + nil, + nil, + NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + ) + } + + let isDirectory = contentType.conforms(to: .directory) + if isDirectory { + logger.debug("is a directory, creating directory locally and fetching its contents.", [.item: ocId, .name: updatedMetadata.fileName]) + + do { + try FileManager.default.createDirectory( + at: localPath, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + logger.error("Could not create directory for item.", [.name: updatedMetadata.fileName, .error: error, .url: localPath]) + + updatedMetadata.status = Status.downloadError.rawValue + updatedMetadata.sessionError = error.localizedDescription + dbManager.addItemMetadata(updatedMetadata) + return (nil, nil, error) + } + + do { + try await fetchDirectoryContents( + itemIdentifier: itemIdentifier, + directoryLocalPath: localPath.path, + directoryRemotePath: serverUrlFileName, + domain: domain, + progress: progress + ) + } catch { + logger.error("Could not fetch directory contents.", [.item: ocId, .error: error]) + + updatedMetadata.status = Status.downloadError.rawValue + updatedMetadata.sessionError = error.localizedDescription + dbManager.addItemMetadata(updatedMetadata) + return (nil, nil, error) + } + + } else { + let (_, _, _, _, _, _, error) = await remoteInterface.downloadAsync( + serverUrlFileName: serverUrlFileName, + fileNameLocalPath: localPath.path, + account: account.ncKitAccount, + options: .init(), + requestHandler: { _ in }, + taskHandler: { _ in }, + progressHandler: { _ in } + ) + + if error != .success { + logger.error("Could not acquire contents of item.", [.item: ocId, .name: updatedMetadata.fileName, .error: error]) + + updatedMetadata.status = Status.downloadError.rawValue + updatedMetadata.sessionError = error.errorDescription + dbManager.addItemMetadata(updatedMetadata) + return (nil, nil, error.fileProviderError( + handlingNoSuchItemErrorUsingItemIdentifier: itemIdentifier + )) + } + } + + logger.debug("Acquired contents of item.", [.item: ocId, .name: updatedMetadata.fileName]) + + updatedMetadata.status = Status.normal.rawValue + updatedMetadata.downloaded = true + // HACK: We were previously failing to correctly set the uploaded state to true for + // enumerated items. Fix it now to ensure we do not show "waiting for upload" when + // having downloaded incorrectly enumerated files + updatedMetadata.uploaded = true + updatedMetadata.sessionError = "" + + dbManager.addItemMetadata(updatedMetadata) + + guard let parentItemIdentifier = await dbManager.parentItemIdentifierWithRemoteFallback( + fromMetadata: metadata, + remoteInterface: remoteInterface, + account: account + ) else { + logger.error("Could not find parent item id for file.", [.name: metadata.fileName]) + + return (nil, nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)) + } + + let fpItem = await Item( + metadata: updatedMetadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: logger.log + ) + + return (localPath, fpItem, nil) + } + + func fetchThumbnail(size: CGSize, domain: NSFileProviderDomain? = nil) async -> (Data?, Error?) { + guard let thumbnailUrl = metadata.thumbnailUrl(size: size) else { + logger.debug("Unknown thumbnail URL.", [.item: itemIdentifier, .name: filename]) + return (nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)) + } + + logger.debug("Fetching thumbnail.", [.name: filename, .url: thumbnailUrl]) + + let (_, data, error) = await remoteInterface.downloadThumbnail( + url: thumbnailUrl, account: account, options: .init(), taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + if error != .success { + logger.error("Could not acquire thumbnail.", [.item: itemIdentifier, .name: filename, .url: thumbnailUrl, .error: error]) + } + + return (data, error.fileProviderError( + handlingNoSuchItemErrorUsingItemIdentifier: itemIdentifier + )) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift new file mode 100644 index 0000000000000..8f30b6d77bbc1 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit + +extension Item { + static func createIgnored( + basedOn itemTemplate: NSFileProviderItem, + parentItemRemotePath: String, + contents _: URL?, + account: Account, + remoteInterface: RemoteInterface, + progress _: Progress, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async -> (Item?, Error?) { + let filename = itemTemplate.filename + let logger = FileProviderLogger(category: "Item", log: log) + + logger.info( + """ + File \(filename) is in the ignore list. + \(parentItemRemotePath + "/" + filename) + Will not propagate creation to server. + """ + ) + + let metadata = SendableItemMetadata( + ocId: itemTemplate.itemIdentifier.rawValue, + account: account.ncKitAccount, + classFile: NKTypeClassFile.unknow.rawValue, + contentType: itemTemplate.contentType?.preferredMIMEType ?? "", + creationDate: itemTemplate.creationDate as? Date ?? Date(), + date: itemTemplate.contentModificationDate as? Date ?? Date(), + directory: itemTemplate.contentType?.conforms(to: .directory) ?? false, + e2eEncrypted: false, + etag: "", + fileId: itemTemplate.itemIdentifier.rawValue, + fileName: itemTemplate.filename, + fileNameView: itemTemplate.filename, + hasPreview: false, + iconName: "", + mountType: "", + ownerId: account.id, + ownerDisplayName: "", + path: "", + serverUrl: parentItemRemotePath, + size: itemTemplate.documentSize??.int64Value ?? 0, + status: Status.normal.rawValue, + downloaded: true, + uploaded: false, + urlBase: account.serverUrl, + user: account.username, + userId: account.id + ) + + dbManager.addItemMetadata(metadata) + + let item = await Item( + metadata: metadata, + parentItemIdentifier: itemTemplate.parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ) + + if #available(macOS 13.0, *) { + return (item, NSFileProviderError(.excludedFromSync)) + } else { + return (item, nil) + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift new file mode 100644 index 0000000000000..4c8d13a4283d2 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider + +public extension Item { + func toggle(keepDownloadedIn domain: NSFileProviderDomain) async throws { + try await set(keepDownloaded: !keepDownloaded, domain: domain) + } + + func set(keepDownloaded: Bool, domain: NSFileProviderDomain) async throws { + _ = try dbManager.set(keepDownloaded: keepDownloaded, for: metadata) + + guard let manager = NSFileProviderManager(for: domain) else { + if #available(iOS 17.1, macOS 14.1, *) { + throw NSFileProviderError(.providerDomainNotFound) + } else { + let providerDomainNotFoundErrorCode = -2013 + throw NSError( + domain: NSFileProviderErrorDomain, + code: providerDomainNotFoundErrorCode, + userInfo: [NSLocalizedDescriptionKey: "Failed to get manager for domain."] + ) + } + } + + if #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) { + if keepDownloaded, !isDownloaded { + try await manager.requestDownloadForItem(withIdentifier: itemIdentifier) + } else { + try await manager.requestModification( + of: [.lastUsedDate], forItemWithIdentifier: itemIdentifier + ) + } + } else { + try await manager.signalEnumerator(for: .workingSet) + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift new file mode 100644 index 0000000000000..8c5b993ce84b1 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudCapabilitiesKit +import NextcloudKit + +extension Item { + /// + /// Shared capability assertion before dispatching (un)lock requests to the server. + /// + private static func assertRequiredCapabilities(domain: NSFileProviderDomain?, itemIdentifier: NSFileProviderItemIdentifier, account: Account, remoteInterface: RemoteInterface, logger: FileProviderLogger) async -> Bool { + let (_, capabilities, _, capabilitiesError) = await remoteInterface.currentCapabilities( + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard capabilitiesError == .success else { + logger.error("Request for capability assertion failed!") + return false + } + + guard let capabilities else { + logger.error("Capabilities to assert are nil!") + return false + } + + guard capabilities.files?.locking != nil else { + logger.error("Capability assertion failed because file locks are not supported!") + return false + } + + return true + } + + /// + /// Create a lock file in the local file provider extension database which is not synchronized to the server, if the server supports file locking. + /// The lock file itself is not uploaded and no error is reported intentionally. + /// + /// - Parameters: + /// - basedOn: Passed through as received from the file provider framework. + /// - parentItemIdentifier: Passed through as received from the file provider framework. + /// - parentItemRemotePath: Passed through as received from the file provider framework. + /// - progress: Passed through as received from the file provider framework. + /// - domain: File provider domain with which the background network task should be associated with. + /// - account: The Nextcloud account to use for interaction with the server. + /// - remoteInterface: The server API abstraction to use for calls. + /// - dbManager: The database manager to use for managing metadata. + /// + /// - Returns: Either the created `item` or an `error` but not both. In either case the other value is `nil`. To be passed to the completion handler provided by the file provider framework. + /// + static func createLockFile( + basedOn itemTemplate: NSFileProviderItem, + parentItemIdentifier: NSFileProviderItemIdentifier, + parentItemRemotePath: String, + progress: Progress, + domain: NSFileProviderDomain? = nil, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async -> (Item?, Error?) { + let logger = FileProviderLogger(category: "Item", log: log) + progress.totalUnitCount = 1 + + guard await assertRequiredCapabilities(domain: domain, itemIdentifier: itemTemplate.itemIdentifier, account: account, remoteInterface: remoteInterface, logger: logger) else { + logger.debug("Excluding lock file from synchronizing due to lack of server-side locking capability.", [.item: itemTemplate.itemIdentifier, .name: itemTemplate.filename]) + + let error = if #available(macOS 13.0, *) { + NSFileProviderError(.excludedFromSync) + } else { + NSFileProviderError(.cannotSynchronize) + } + + return (nil, error) + } + + logger.info("Item to create is a lock file. Will attempt to lock the associated file on the server.", [.name: itemTemplate.filename]) + + guard let targetFileName = originalFileName(fromLockFileName: itemTemplate.filename, dbManager: dbManager) else { + logger.error("Will not lock the target file because it could not be determined based on the lock file name.", [.name: itemTemplate.filename]) + + if #available(macOS 13.0, *) { + return (nil, NSFileProviderError(.excludedFromSync)) + } else { + return (nil, NSFileProviderError(.cannotSynchronize)) + } + } + + logger.debug("Derived target file name for lock file.", [.name: targetFileName]) + let targetFileRemotePath = parentItemRemotePath + "/" + targetFileName + + let metadata = SendableItemMetadata( + ocId: itemTemplate.itemIdentifier.rawValue, + account: account.ncKitAccount, + classFile: "lock", // Indicates this metadata is for a locked file + contentType: itemTemplate.contentType?.preferredMIMEType ?? "", + creationDate: itemTemplate.creationDate as? Date ?? Date(), + date: Date(), + directory: false, + e2eEncrypted: false, + etag: "", + fileId: itemTemplate.itemIdentifier.rawValue, + fileName: itemTemplate.filename, + fileNameView: itemTemplate.filename, + hasPreview: false, + iconName: "lockIcon", // Custom icon for locked items + isLockfileOfLocalOrigin: true, + mountType: "", + ownerId: account.id, + ownerDisplayName: "", + path: parentItemRemotePath + "/" + targetFileName, + serverUrl: parentItemRemotePath, + size: 0, + status: Status.normal.rawValue, + downloaded: true, + uploaded: false, + urlBase: account.serverUrl, + user: account.username, + userId: account.id + ) + + dbManager.addItemMetadata(metadata) + var errorToReturn: Error? + + do { + let lock = try await remoteInterface.lockUnlockFile(serverUrlFileName: targetFileRemotePath, type: .token, shouldLock: true, account: account, options: .init(), taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: itemTemplate.itemIdentifier, + completionHandler: { _ in } + ) + } + }) + + if let lock { + logger.info("Locked file and received lock, will update target item.", [.name: targetFileName, .lock: lock]) + + if let targetMetadata = dbManager.itemMetadatas.where({ $0.fileName.equals(targetFileName) }).where({ $0.serverUrl.equals(parentItemRemotePath) }).first { + try dbManager.ncDatabase().write { + targetMetadata.lock = true + targetMetadata.lockOwner = lock.owner + targetMetadata.lockOwnerDisplayName = lock.ownerDisplayName + targetMetadata.lockOwnerEditor = lock.ownerEditor + targetMetadata.lockOwnerType = lock.ownerType.rawValue + targetMetadata.lockTime = lock.time + targetMetadata.lockTimeOut = lock.timeOut + targetMetadata.lockToken = lock.token + } + } else { + logger.error("Failed to find target item for acquired lock.", [.lock: lock]) + } + } else { + logger.info("Locked file but did not receive lock information.", [.name: targetFileName]) + } + } catch { + logger.error("Failed to lock file \"\(targetFileName)\" which has lock file \"\(itemTemplate.filename)\".", [.error: error]) + + if let nkError = error as? NKError { + // Attempt to map a possible NKError to an NSFileProviderError. + errorToReturn = nkError.fileProviderError + } else { + // Return the error as it is. + errorToReturn = error + } + } + + progress.completedUnitCount = 1 + + return await ( + Item( + metadata: metadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: log + ), + errorToReturn + ) + } + + func modifyLockFile( + itemTarget: NSFileProviderItem, + baseVersion: NSFileProviderItemVersion = NSFileProviderItemVersion(), + changedFields: NSFileProviderItemFields, + contents newContents: URL?, + options: NSFileProviderModifyItemOptions = [], + request: NSFileProviderRequest = NSFileProviderRequest(), + ignoredFiles: IgnoredFilesMatcher? = nil, + domain: NSFileProviderDomain? = nil, + forcedChunkSize: Int? = nil, + progress: Progress = .init(), + dbManager: FilesDatabaseManager + ) async -> (Item?, Error?) { + logger.info("System requested modification of lock file. Marking as complete without syncing to server.", [.name: filename]) + + if isLockFileName(filename) == false { + logger.fault("Should not handle non-lock files here.", [.name: filename]) + } + + guard let modifiedItem = await modifyUnuploaded( + itemTarget: itemTarget, + baseVersion: baseVersion, + changedFields: changedFields, + contents: newContents, + options: options, + request: request, + ignoredFiles: ignoredFiles, + domain: domain, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager + ) else { + logger.info("Cannot modify lock file because received a nil modified item.", [.name: filename]) + return (nil, NSFileProviderError(.cannotSynchronize)) + } + + if !isLockFileName(modifiedItem.filename) { + logger.info("After modification, lock file: \(filename) is no longer a lock file (it is now named: \(modifiedItem.filename)) Will proceed with creating item on server (if possible).") + + return await modifiedItem.createUnuploaded( + itemTarget: itemTarget, + baseVersion: baseVersion, + changedFields: changedFields, + contents: newContents, + options: options, + request: request, + ignoredFiles: ignoredFiles, + domain: domain, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager + ) + } + + return (modifiedItem, nil) + } + + func deleteLockFile(domain: NSFileProviderDomain? = nil, dbManager: FilesDatabaseManager) async -> Error? { + guard await Self.assertRequiredCapabilities(domain: domain, itemIdentifier: itemIdentifier, account: account, remoteInterface: remoteInterface, logger: logger) else { + return nil + } + + dbManager.deleteItemMetadata(ocId: metadata.ocId) + + guard let originalFileName = originalFileName(fromLockFileName: metadata.fileName, dbManager: dbManager) else { + logger.error("Could not get original filename from lock file filename so will not unlock target file.", [.name: metadata.fileName]) + return nil + } + + let originalFileServerFileNameUrl = metadata.serverUrl + "/" + originalFileName + + do { + let lock = try await remoteInterface.lockUnlockFile( + serverUrlFileName: originalFileServerFileNameUrl, + type: .token, + shouldLock: false, + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + if let lock { + logger.info("Unlocked file and received lock.", [.name: originalFileName, .lock: lock]) + } else { + logger.info("Unlocked file but did not receive lock information.", [.name: originalFileName]) + } + + logger.info("Removing lock from locally stored target item.", [.name: originalFileName]) + + if let targetMetadata = dbManager.itemMetadatas.where({ $0.fileName.equals(originalFileName) }).where({ $0.serverUrl.equals(metadata.serverUrl) }).first { + try dbManager.ncDatabase().write { + targetMetadata.lock = false + targetMetadata.lockOwner = nil + targetMetadata.lockOwnerDisplayName = nil + targetMetadata.lockOwnerEditor = nil + targetMetadata.lockOwnerType = nil + targetMetadata.lockTime = nil + targetMetadata.lockTimeOut = nil + targetMetadata.lockToken = nil + } + } else { + logger.error("Failed to find target item for released lock.", [.lock: lock]) + } + } catch { + logger.error("Could not unlock item.", [.name: filename, .error: error]) + + if let error = error as? NKError { + return error.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: itemIdentifier) + } + } + + return nil + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift new file mode 100644 index 0000000000000..749aadf6a16b9 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift @@ -0,0 +1,796 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit + +public extension Item { + func move( + newFileName: String, + newRemotePath: String, + newParentItemIdentifier: NSFileProviderItemIdentifier, + newParentItemRemotePath: String, + domain: NSFileProviderDomain? = nil, + dbManager: FilesDatabaseManager + ) async -> (Item?, Error?) { + let ocId = itemIdentifier.rawValue + let isFolder = contentType.conforms(to: .directory) + let oldRemotePath = metadata.serverUrl + "/" + filename + let (_, _, moveError) = await remoteInterface.move( + remotePathSource: oldRemotePath, + remotePathDestination: newRemotePath, + overwrite: false, + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard moveError == .success else { + logger.error( + """ + Could not move file or folder: \(oldRemotePath) + to \(newRemotePath), + received error: \(moveError.errorCode) + \(moveError.errorDescription) + """ + ) + return await (nil, moveError.fileProviderError( + handlingCollisionAgainstItemInRemotePath: newRemotePath, + dbManager: dbManager, + remoteInterface: remoteInterface, + log: logger.log + )) + } + + if isFolder { + _ = dbManager.renameDirectoryAndPropagateToChildren( + ocId: ocId, + newServerUrl: newParentItemRemotePath, + newFileName: newFileName + ) + } else { + dbManager.renameItemMetadata( + ocId: ocId, + newServerUrl: newParentItemRemotePath, + newFileName: newFileName + ) + } + + guard let newMetadata = dbManager.itemMetadata(ocId: ocId) else { + logger.error( + """ + Could not acquire metadata of item with identifier: \(ocId), + cannot correctly inform of modification + """ + ) + return ( + nil, + NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + ) + } + + let modifiedItem = await Item( + metadata: newMetadata, + parentItemIdentifier: newParentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: logger.log + ) + return (modifiedItem, nil) + } + + private func modifyContents( + contents newContents: URL?, + remotePath: String, + newCreationDate: Date?, + newContentModificationDate: Date?, + forcedChunkSize: Int?, + domain: NSFileProviderDomain?, + progress: Progress, + dbManager: FilesDatabaseManager + ) async -> (Item?, Error?) { + let ocId = itemIdentifier.rawValue + + guard let newContents else { + logger.error("Cannot upload modified content because a nil URL was provided.", [.item: itemIdentifier]) + return (nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)) + } + + guard var metadata = dbManager.itemMetadata(ocId: ocId) else { + logger.error("Could not acquire metadata of item.", [.item: itemIdentifier]) + return ( + nil, + NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + ) + } + + guard let updatedMetadata = dbManager.setStatusForItemMetadata(metadata, status: .uploading) else { + logger.info("Could not acquire updated metadata of item. Unable to update item status to uploading.", [.item: itemIdentifier]) + return (nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)) + } + + var headers = [String: String]() + + if let token = metadata.lockToken { + headers["If"] = "<\(remotePath)> ()" + } + + let options = NKRequestOptions(customHeader: headers, queue: .global(qos: .utility)) + + let (_, _, etag, date, size, error) = await upload( + fileLocatedAt: newContents.path, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: account, + inChunksSized: forcedChunkSize, + usingChunkUploadId: metadata.chunkUploadId, + dbManager: dbManager, + creationDate: newCreationDate, + modificationDate: newContentModificationDate, + options: options, + log: logger.log, + requestHandler: { progress.setHandlersFromAfRequest($0) }, + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + }, + progressHandler: { $0.copyCurrentStateToProgress(progress) } + ) + + guard error == .success else { + logger.error( + """ + Could not upload item \(ocId) + with filename: \(filename), + received error: \(error.errorCode), + \(error.errorDescription) + """ + ) + + metadata.status = Status.uploadError.rawValue + metadata.sessionError = error.errorDescription + dbManager.addItemMetadata(metadata) + // Moving should be done before uploading and should catch collisions already, but, + // it is painless to check here too just in case + return await (nil, error.fileProviderError( + handlingCollisionAgainstItemInRemotePath: remotePath, + dbManager: dbManager, + remoteInterface: remoteInterface, + log: logger.log + )) + } + + logger.info( + """ + Successfully uploaded item with identifier: \(ocId) + and filename: \(filename) + """ + ) + + let contentAttributes = try? FileManager.default.attributesOfItem(atPath: newContents.path) + if let expectedSize = contentAttributes?[.size] as? Int64, size != expectedSize { + logger.info( + """ + Item content modification upload reported as successful, + but there are differences between the received file size (\(size ?? -1)) + and the original file size (\(documentSize?.int64Value ?? 0)) + """ + ) + } + + var newMetadata = + dbManager.setStatusForItemMetadata(updatedMetadata, status: .normal) ?? SendableItemMetadata(value: updatedMetadata) + + newMetadata.date = date ?? Date() + newMetadata.etag = etag ?? metadata.etag + newMetadata.ocId = ocId + newMetadata.size = size ?? 0 + newMetadata.session = "" + newMetadata.sessionError = "" + newMetadata.sessionTaskIdentifier = 0 + newMetadata.downloaded = true + newMetadata.uploaded = true + + dbManager.addItemMetadata(newMetadata) + + let modifiedItem = await Item( + metadata: newMetadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: logger.log + ) + + return (modifiedItem, nil) + } + + private func modifyBundleOrPackageContents( + contents newContents: URL?, + remotePath: String, + forcedChunkSize: Int?, + domain: NSFileProviderDomain?, + progress: Progress, + dbManager: FilesDatabaseManager + ) async throws -> Item? { + guard let contents = newContents else { + logger.error( + """ + Could not modify bundle or package contents as was provided nil contents url + for item with ocID \(itemIdentifier.rawValue) + (\(filename)) + """ + ) + throw NSFileProviderError(.cannotSynchronize) + } + + logger.debug( + """ + Handling modified bundle/package/internal directory at: + \(contents.path) + """ + ) + + func remoteErrorToThrow(_ error: NKError) -> Error { + error.fileProviderError ?? NSFileProviderError(.cannotSynchronize) + } + + // 1. Scan the remote contents of the bundle (recursively) + // 2. Create set of the found items + // 3. Upload new contents and get their paths post-upload + // 4. Delete remote items with paths not present in the new set + var allMetadatas = [SendableItemMetadata]() + var directoriesToRead = [remotePath] + while !directoriesToRead.isEmpty { + let remoteDirectoryPath = directoriesToRead.removeFirst() + let (metadatas, _, _, _, _, readError) = await Enumerator.readServerUrl( + remoteDirectoryPath, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: logger.log + ) + // Important note -- the enumerator will import found items' metadata into the database. + // This is important for when we want to start deleting stale items and want to avoid trying + // to delete stale items that have already been deleted because the parent folder and all of + // its contents have been nuked already + + if let readError { + logger.error( + """ + Could not read server url for item with ocID + \(itemIdentifier.rawValue) + (\(filename)), + received error: \(readError.errorDescription) + """ + ) + throw remoteErrorToThrow(readError) + } + guard var metadatas else { + logger.error( + """ + Could not read server url for item with ocID + \(itemIdentifier.rawValue) + (\(filename)), + received nil metadatas + """ + ) + throw NSFileProviderError(.serverUnreachable) + } + + if !metadatas.isEmpty { + metadatas.removeFirst() // Remove bundle itself + } + allMetadatas.append(contentsOf: metadatas) + + var childDirPaths = [String]() + for metadata in metadatas { + guard metadata.directory, + metadata.ocId != itemIdentifier.rawValue + else { continue } + childDirPaths.append(remoteDirectoryPath + "/" + metadata.fileName) + } + directoriesToRead.append(contentsOf: childDirPaths) + } + + var staleItems = [String: SendableItemMetadata]() // remote urls to metadata + for metadata in allMetadatas { + let remoteUrlPath = metadata.serverUrl + "/" + metadata.fileName + guard remoteUrlPath != remotePath else { continue } + staleItems[remoteUrlPath] = metadata + } + + let attributesToFetch: Set = [ + .isDirectoryKey, .fileSizeKey, .creationDateKey, .contentModificationDateKey + ] + let fm = FileManager.default + guard let enumerator = fm.enumerator( + at: contents, includingPropertiesForKeys: Array(attributesToFetch) + ) else { + logger.error( + """ + Could not create enumerator for contents of bundle or package + at: \(contents.path) + """ + ) + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorResourceUnavailable) + } + + guard let enumeratorArray = enumerator.allObjects as? [URL] else { + logger.error( + """ + Could not create enumerator array for contents of bundle or package + at: \(contents.path) + """ + ) + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorResourceUnavailable) + } + + // Add one more total unit count to signify final reconciliation of bundle modify process + progress.totalUnitCount = Int64(enumeratorArray.count) + 1 + + let contentsPath = contents.path + let privatePrefix = "/private" + let privateContentsPath = contentsPath.hasPrefix(privatePrefix) + var remoteDirectoriesPaths = [remotePath] + + for childUrl in enumeratorArray { + var childUrlPath = childUrl.path + if childUrlPath.hasPrefix(privatePrefix), !privateContentsPath { + childUrlPath.removeFirst(privatePrefix.count) + } + let childRelativePath = childUrlPath.replacingOccurrences(of: contents.path, with: "") + let childRemoteUrl = remotePath + childRelativePath + let childUrlAttributes = try childUrl.resourceValues(forKeys: attributesToFetch) + let childIsFolder = childUrlAttributes.isDirectory ?? false + + // Do not re-create directories + if childIsFolder, !staleItems.keys.contains(childRemoteUrl) { + logger.debug( + """ + Handling child bundle or package directory at: \(childUrlPath) + """ + ) + let (_, _, _, createError) = await remoteInterface.createFolder( + remotePath: childRemoteUrl, + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + // Don't error if there is a collision + guard createError == .success || createError.matchesCollisionError else { + logger.error( + """ + Could not create new bpi folder at: \(remotePath), + received error: \(createError.errorCode) + \(createError.errorDescription) + """ + ) + throw remoteErrorToThrow(createError) + } + remoteDirectoriesPaths.append(childRemoteUrl) + + } else if !childIsFolder { + logger.debug( + """ + Handling child bundle or package file at: \(childUrlPath) + """ + ) + let (_, _, _, _, _, error) = await upload( + fileLocatedAt: childUrlPath, + toRemotePath: childRemoteUrl, + usingRemoteInterface: remoteInterface, + withAccount: account, + inChunksSized: forcedChunkSize, + dbManager: dbManager, + creationDate: childUrlAttributes.creationDate, + modificationDate: childUrlAttributes.contentModificationDate, + log: logger.log, + requestHandler: { progress.setHandlersFromAfRequest($0) }, + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + }, + progressHandler: { _ in } + ) + + guard error == .success else { + logger.error( + """ + Could not upload bpi file at: \(childUrlPath), + received error: \(error.errorCode) + \(error.errorDescription) + """ + ) + throw remoteErrorToThrow(error) + } + } + staleItems.removeValue(forKey: childRemoteUrl) + progress.completedUnitCount += 1 + } + + for staleItem in staleItems { + let staleItemMetadata = staleItem.value + guard dbManager.itemMetadata(ocId: staleItemMetadata.ocId) != nil else { continue } + + let (_, _, deleteError) = await remoteInterface.delete( + remotePath: staleItem.key, + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: self.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard deleteError == .success else { + logger.error( + """ + Could not delete stale bpi item at: \(staleItem.key), + received error: \(deleteError.errorCode) + \(deleteError.errorDescription) + """ + ) + throw remoteErrorToThrow(deleteError) + } + + if staleItemMetadata.directory { + _ = dbManager.deleteDirectoryAndSubdirectoriesMetadata(ocId: staleItemMetadata.ocId) + } else { + dbManager.deleteItemMetadata(ocId: staleItemMetadata.ocId) + } + } + + for remoteDirectoryPath in remoteDirectoriesPaths { + // After everything, check into what the final state is of each folder now + let (_, _, _, _, _, readError) = await Enumerator.readServerUrl( + remoteDirectoryPath, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: logger.log + ) + + if let readError, readError != .success { + logger.error( + """ + Could not read new bpi folder at: \(remotePath), + received error: \(readError.errorDescription) + """ + ) + throw remoteErrorToThrow(readError) + } + } + + guard let bundleRootMetadata = dbManager.itemMetadata( + ocId: itemIdentifier.rawValue + ) else { + logger.error( + """ + Could not find directory metadata for bundle or package at: + \(contentsPath) + """ + ) + throw NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + } + + progress.completedUnitCount += 1 + + return await Item( + metadata: bundleRootMetadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: logger.log + ) + } + + func modify( + itemTarget: NSFileProviderItem, + baseVersion: NSFileProviderItemVersion = NSFileProviderItemVersion(), + changedFields: NSFileProviderItemFields, + contents newContents: URL?, + options: NSFileProviderModifyItemOptions = [], + request: NSFileProviderRequest = NSFileProviderRequest(), + ignoredFiles: IgnoredFilesMatcher? = nil, + domain: NSFileProviderDomain? = nil, + forcedChunkSize: Int? = nil, + progress: Progress = .init(), + dbManager: FilesDatabaseManager + ) async -> (Item?, Error?) { + // For your own good: don't use "self" below here, it'll save you pain debugging when you do + // refactors later on. Just use modifiedItem + var modifiedItem = self + + guard metadata.classFile != "lock", !isLockFileName(metadata.fileName) else { + return await modifiedItem.modifyLockFile( + itemTarget: itemTarget, + baseVersion: baseVersion, + changedFields: changedFields, + contents: newContents, + options: options, + request: request, + ignoredFiles: ignoredFiles, + domain: domain, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager + ) + } + + let relativePath = (metadata.serverUrl + "/" + metadata.fileName).replacingOccurrences(of: account.davFilesUrl, with: "") + + guard ignoredFiles == nil || ignoredFiles?.isExcluded(relativePath) == false else { + logger.info("File is in the ignore list. Will delete locally with no remote effect.", [.item: modifiedItem.itemIdentifier, .name: modifiedItem.filename]) + + guard let modifiedIgnored = await modifyUnuploaded( + itemTarget: itemTarget, + baseVersion: baseVersion, + changedFields: changedFields, + contents: newContents, + options: options, + request: request, + ignoredFiles: ignoredFiles, + domain: domain, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager + ) else { + logger.error("Unable to modify ignored file, got nil item: \(relativePath)") + return (nil, NSFileProviderError(.cannotSynchronize)) + } + + modifiedItem = modifiedIgnored + + if #available(macOS 13.0, *) { + return (modifiedItem, NSFileProviderError(.excludedFromSync)) + } else { + return (modifiedItem, nil) + } + } + + // We are handling an item that is available locally but not on the server -- so create it + // This can happen when a previously ignored file is no longer ignored + if !modifiedItem.isUploaded, modifiedItem.isDownloaded, modifiedItem.metadata.etag == "" { + return await modifiedItem.createUnuploaded( + itemTarget: itemTarget, + baseVersion: baseVersion, + changedFields: changedFields, + contents: newContents, + options: options, + request: request, + ignoredFiles: ignoredFiles, + domain: domain, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager + ) + } + + guard itemTarget.itemIdentifier == modifiedItem.itemIdentifier else { + logger.error("Could not modify item, different identifier to the item the modification was targeting (\(itemTarget.itemIdentifier.rawValue)).", [.item: modifiedItem]) + + return (nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)) + } + + let newParentItemIdentifier = itemTarget.parentItemIdentifier + let isFolder = modifiedItem.contentType.conforms(to: .directory) + let bundleOrPackage = modifiedItem.contentType.conforms(to: .bundle) || modifiedItem.contentType.conforms(to: .package) + + if options.contains(.mayAlreadyExist) { + // TODO: This needs to be properly handled with a check in the db + logger.info("Modification for item may already exist.", [.item: modifiedItem]) + } + + var newParentItemRemoteUrl: String + + // The target parent should already be present in our database. The system will have synced + // remote changes and then, upon user interaction, will try to modify the item. + // That is, if the parent item has changed at all (it might not have) + if newParentItemIdentifier == .rootContainer { + newParentItemRemoteUrl = account.davFilesUrl + } else if newParentItemIdentifier == .trashContainer { + newParentItemRemoteUrl = account.trashUrl + } else { + guard let parentItemMetadata = dbManager.directoryMetadata(ocId: newParentItemIdentifier.rawValue) else { + logger.error("Not modifying item, could not find metadata for target parentItemIdentifier \"\(newParentItemIdentifier.rawValue)\"!", [.item: modifiedItem]) + return ( + nil, + NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + ) + } + + newParentItemRemoteUrl = parentItemMetadata.serverUrl + "/" + parentItemMetadata.fileName + } + + let newServerUrlFileName = newParentItemRemoteUrl + "/" + itemTarget.filename + + logger.debug("About to modify item.", [.item: modifiedItem]) + + if changedFields.contains(.parentItemIdentifier) + && newParentItemIdentifier == .trashContainer + && modifiedItem.metadata.isTrashed + { + if changedFields.contains(.filename) { + logger.error("Tried to modify filename of already trashed item. This is not supported.", [.item: modifiedItem]) + } + + logger.info("Tried to trash item that is in fact already trashed.", [.item: modifiedItem]) + + return (modifiedItem, nil) + } else if changedFields.contains(.parentItemIdentifier) && newParentItemIdentifier == .trashContainer { + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities( + account: account, options: .init(), taskHandler: { _ in } + ) + guard let capabilities, error == .success else { + logger.error("Could not acquire capabilities during item move to trash, won't proceed.", [.item: modifiedItem, .error: error]) + return (nil, error.fileProviderError) + } + guard capabilities.files?.undelete == true else { + logger.error("Cannot delete item as server does not support trashing.", [.item: modifiedItem]) + return (nil, NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)) + } + + // We can't just move files into the trash, we need to issue a deletion; let's handle it + // Rename the item if necessary before doing the trashing procedures + if changedFields.contains(.filename) { + let currentParentItemRemotePath = modifiedItem.metadata.serverUrl + let preTrashingRenamedRemotePath = + currentParentItemRemotePath + "/" + itemTarget.filename + let (renameModifiedItem, renameError) = await modifiedItem.move( + newFileName: itemTarget.filename, + newRemotePath: preTrashingRenamedRemotePath, + newParentItemIdentifier: modifiedItem.parentItemIdentifier, + newParentItemRemotePath: currentParentItemRemotePath, + dbManager: dbManager + ) + + guard renameError == nil, let renameModifiedItem else { + logger.error("Could not rename pre-trash item.", [.item: modifiedItem.itemIdentifier, .error: error]) + return (nil, renameError) + } + + modifiedItem = renameModifiedItem + } + + let (trashedItem, trashingError) = await Self.trash( + modifiedItem, account: account, dbManager: dbManager, domain: domain, log: logger.log + ) + guard trashingError == nil else { return (modifiedItem, trashingError) } + modifiedItem = trashedItem + } else if changedFields.contains(.filename) || changedFields.contains(.parentItemIdentifier) { + // Recover the item first + if modifiedItem.parentItemIdentifier != itemTarget.parentItemIdentifier && + modifiedItem.parentItemIdentifier == .trashContainer && + modifiedItem.metadata.isTrashed + { + let (restoredItem, restoreError) = await Self.restoreFromTrash( + modifiedItem, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + domain: domain, + log: logger.log + ) + guard restoreError == nil else { + return (modifiedItem, restoreError) + } + modifiedItem = restoredItem + } + + // Maybe during the untrashing the item's intended modifications were complete. + // If not the case, or the item modification does not involve untrashing, move/rename. + if (changedFields.contains(.filename) && modifiedItem.filename != itemTarget.filename) || + (changedFields.contains(.parentItemIdentifier) && + modifiedItem.parentItemIdentifier != itemTarget.parentItemIdentifier) + { + let (renameModifiedItem, renameError) = await modifiedItem.move( + newFileName: itemTarget.filename, + newRemotePath: newServerUrlFileName, + newParentItemIdentifier: newParentItemIdentifier, + newParentItemRemotePath: newParentItemRemoteUrl, + dbManager: dbManager + ) + + guard renameError == nil, let renameModifiedItem else { + return (nil, renameError) + } + + modifiedItem = renameModifiedItem + } + } + + guard !isFolder || bundleOrPackage else { + logger.debug("System requested modification for folder of something other than folder name. This is not supported.", [.item: modifiedItem]) + return (modifiedItem, nil) + } + + guard newParentItemIdentifier != .trashContainer else { + logger.debug("System requested modification of item in trash. This is not supported.", [.item: modifiedItem]) + return (modifiedItem, nil) + } + + if changedFields.contains(.contents) { + logger.debug("Item content modified.", [.item: modifiedItem]) + + let newCreationDate = itemTarget.creationDate ?? creationDate + let newContentModificationDate = + itemTarget.contentModificationDate ?? contentModificationDate + var contentModifiedItem: Item? + var contentError: Error? + + if bundleOrPackage { + do { + contentModifiedItem = try await modifiedItem.modifyBundleOrPackageContents( + contents: newContents, + remotePath: newServerUrlFileName, + forcedChunkSize: forcedChunkSize, + domain: domain, + progress: progress, + dbManager: dbManager + ) + } catch { + contentError = error + } + } else { + (contentModifiedItem, contentError) = await modifiedItem.modifyContents( + contents: newContents, + remotePath: newServerUrlFileName, + newCreationDate: newCreationDate, + newContentModificationDate: newContentModificationDate, + forcedChunkSize: forcedChunkSize, + domain: domain, + progress: progress, + dbManager: dbManager + ) + } + + guard contentError == nil, let contentModifiedItem else { + logger.error("Could not modify contents.", [.item: modifiedItem, .error: contentError]) + return (nil, contentError) + } + + modifiedItem = contentModifiedItem + } + + logger.debug("All modifications processed.", [.item: modifiedItem]) + return (modifiedItem, nil) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift new file mode 100644 index 0000000000000..63cc4c01ea45f --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit + +extension Item { + // Note: When handling trashing, the server handles filename conflicts for us + static func trash( + _ modifiedItem: Item, + account: Account, + dbManager: FilesDatabaseManager, + domain: NSFileProviderDomain?, + log: any FileProviderLogging + ) async -> (Item, Error?) { + let logger = FileProviderLogger(category: "Item", log: log) + + let deleteError = await modifiedItem.delete(trashing: true, domain: domain, dbManager: dbManager) + + guard deleteError == nil else { + logger.error("Error attempting to move item into trash.", [.name: modifiedItem.filename, .error: deleteError]) + return (modifiedItem, deleteError) + } + + let ocId = modifiedItem.itemIdentifier.rawValue + guard let dirtyMetadata = dbManager.itemMetadata(ocId: ocId) else { + logger.error( + """ + Could not correctly process trashing results, dirty metadata not found. + \(modifiedItem.filename) \(ocId) + """ + ) + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) + } + let dirtyChildren = dbManager.childItems(directoryMetadata: dirtyMetadata) + let dirtyItem = await Item( + metadata: dirtyMetadata, + parentItemIdentifier: .trashContainer, + account: account, + remoteInterface: modifiedItem.remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: modifiedItem.remoteInterface.supportsTrash(account: account), + log: log + ) + + // The server may have renamed the trashed file so we need to scan the entire trash + let (_, files, _, error) = await modifiedItem.remoteInterface.listingTrashAsync( + filename: nil, + showHiddenFiles: true, + account: account.ncKitAccount, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: modifiedItem.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard error == .success else { + logger.error("Received error from post-trashing remote scan.", [.error: error]) + + return (dirtyItem, error.fileProviderError) + } + + guard let targetItemNKTrash = files?.first( + // It seems the server likes to return a fileId as the ocId for trash files, so let's + // check for the fileId too + where: { $0.ocId == modifiedItem.metadata.ocId || + $0.fileId == modifiedItem.metadata.fileId + } + ) + else { + logger.error("Did not find trashed item in trash, asking for a rescan.", [.item: modifiedItem]) + + if #available(macOS 11.3, *) { + return (dirtyItem, NSFileProviderError(.unsyncedEdits)) + } else { + return (dirtyItem, NSFileProviderError(.syncAnchorExpired)) + } + } + + var postDeleteMetadata = targetItemNKTrash.toItemMetadata(account: account) + postDeleteMetadata.ocId = modifiedItem.itemIdentifier.rawValue + dbManager.addItemMetadata(postDeleteMetadata) + + let postDeleteItem = await Item( + metadata: postDeleteMetadata, + parentItemIdentifier: .trashContainer, + account: account, + remoteInterface: modifiedItem.remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: modifiedItem.remoteInterface.supportsTrash(account: account), + log: log + ) + + // Now we can directly update info on the child items + var (_, childFiles, _, childError) = await modifiedItem.remoteInterface.enumerate( + remotePath: postDeleteMetadata.serverUrl + "/" + postDeleteMetadata.fileName, + depth: EnumerateDepth.targetAndAllChildren, // Just do it in one go + showHiddenFiles: true, + includeHiddenFiles: [], + requestBody: nil, + account: account, + options: .init(), + taskHandler: { task in + if let domain { + NSFileProviderManager(for: domain)?.register( + task, + forItemWithIdentifier: modifiedItem.itemIdentifier, + completionHandler: { _ in } + ) + } + } + ) + + guard error == .success else { + logger.error("Received error or files from post-trashing child items remote scan.", [.error: error]) + return (postDeleteItem, childError.fileProviderError) + } + + // Update state of child files + childFiles.removeFirst() // This is the target path, already scanned + for file in childFiles { + var metadata = file.toItemMetadata() + guard let original = dirtyChildren + .filter({ $0.ocId == metadata.ocId || $0.fileId == metadata.fileId }) + .first + else { + logger.info( + """ + Skipping post-trash child item metadata: \(metadata.fileName) + Could not find matching existing item in database, cannot do ocId correction + """ + ) + continue + } + metadata.ocId = original.ocId // Give original id back + dbManager.addItemMetadata(metadata) + logger.info("Note: that was a post-trash child item metadata") + } + + return (postDeleteItem, nil) + } + + // Note: When restoring from the trash, the server handles filename conflicts for us + static func restoreFromTrash( + _ modifiedItem: Item, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + domain _: NSFileProviderDomain?, + log: any FileProviderLogging + ) async -> (Item, Error?) { + let logger = FileProviderLogger(category: "Item", log: log) + + func finaliseRestore(target: NKFile) async -> (Item, Error?) { + let restoredItemMetadata = target.toItemMetadata() + guard let parentItemIdentifier = await dbManager.parentItemIdentifierWithRemoteFallback( + fromMetadata: restoredItemMetadata, + remoteInterface: remoteInterface, + account: account + ) else { + logger.error("Could not find parent item identifier for \(originalLocation)") + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) + } + + if restoredItemMetadata.directory { + _ = dbManager.renameDirectoryAndPropagateToChildren( + ocId: restoredItemMetadata.ocId, + newServerUrl: restoredItemMetadata.serverUrl, + newFileName: restoredItemMetadata.fileName + ) + } + dbManager.addItemMetadata(restoredItemMetadata) + + return await (Item( + metadata: restoredItemMetadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: modifiedItem.remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: modifiedItem.remoteInterface.supportsTrash(account: account), + log: log + ), nil) + } + + let (_, _, restoreError) = await modifiedItem.remoteInterface.restoreFromTrash( + filename: modifiedItem.metadata.fileName, + account: account, + options: .init(), + taskHandler: { _ in } + ) + guard restoreError == .success else { + logger.error( + """ + Could not restore item \(modifiedItem.filename) from trash + Received error: \(restoreError.errorDescription) + """ + ) + return (modifiedItem, restoreError.fileProviderError) + } + guard modifiedItem.metadata.trashbinOriginalLocation != "" else { + logger.error( + """ + Could not scan restored item \(modifiedItem.filename). + The trashed file's original location is invalid. + """ + ) + if #available(macOS 11.3, *) { + return (modifiedItem, NSFileProviderError(.unsyncedEdits)) + } + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) + } + let originalLocation = + account.davFilesUrl + "/" + modifiedItem.metadata.trashbinOriginalLocation + + let (_, files, _, enumerateError) = await modifiedItem.remoteInterface.enumerate( + remotePath: originalLocation, + depth: .target, + showHiddenFiles: true, + includeHiddenFiles: [], + requestBody: nil, + account: account, + options: .init(), + taskHandler: { _ in } + ) + guard enumerateError == .success, !files.isEmpty, let target = files.first else { + logger.error( + """ + Could not scan restored state of file \(originalLocation) + Received error: \(enumerateError.errorDescription) + Files: \(files.count) + """ + ) + if #available(macOS 11.3, *) { + return (modifiedItem, NSFileProviderError(.unsyncedEdits)) + } + return (modifiedItem, enumerateError.fileProviderError) + } + + guard target.ocId == modifiedItem.itemIdentifier.rawValue else { + logger.info( + """ + Restored item \(originalLocation) + does not match \(modifiedItem.filename) + (it is likely that when restoring from the trash, there was another identical item). + """ + ) + + guard let finalSlashIndex = originalLocation.lastIndex(of: "/") else { + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) + } + var parentDirectoryRemotePath = originalLocation + parentDirectoryRemotePath.removeSubrange(finalSlashIndex ..< originalLocation.endIndex) + + logger.info( + """ + Scanning parent folder at \(parentDirectoryRemotePath) for current + state of item restored from trash. + """ + ) + + let (_, files, _, folderScanError) = await modifiedItem.remoteInterface.enumerate( + remotePath: parentDirectoryRemotePath, + depth: .targetAndDirectChildren, + showHiddenFiles: true, + includeHiddenFiles: [], + requestBody: nil, + account: account, + options: .init(), + taskHandler: { _ in } + ) + + guard folderScanError == .success else { + logger.error( + """ + Scanning parent folder at \(parentDirectoryRemotePath) + returned error: \(folderScanError.errorDescription) + """ + ) + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) + } + + guard let actualTarget = files.first( + where: { $0.ocId == modifiedItem.itemIdentifier.rawValue } + ) else { + logger.error( + """ + Scanning parent folder at \(parentDirectoryRemotePath) + finished successfully but the target item restored from trash not found. + """ + ) + return (modifiedItem, NSFileProviderError(.cannotSynchronize)) + } + + return await finaliseRestore(target: actualTarget) + } + + return await finaliseRestore(target: target) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift new file mode 100644 index 0000000000000..4ebe872a97fa3 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider + +extension Item { + // Creates a file that was previously unuploaded (e.g. a previously ignored/lock file) on server + func createUnuploaded( + itemTarget: NSFileProviderItem, + baseVersion _: NSFileProviderItemVersion = NSFileProviderItemVersion(), + changedFields _: NSFileProviderItemFields, + contents newContents: URL?, + options _: NSFileProviderModifyItemOptions = [], + request _: NSFileProviderRequest = NSFileProviderRequest(), + ignoredFiles: IgnoredFilesMatcher? = nil, + domain: NSFileProviderDomain? = nil, + forcedChunkSize: Int? = nil, + progress: Progress = .init(), + dbManager: FilesDatabaseManager + ) async -> (Item?, Error?) { + guard newContents != nil || domain != nil else { + logger.error( + """ + Unable to upload modified item that was previously unuploaded. + filename: \(filename) + either the domain is nil, the provided contents are nil, or both. + """ + ) + return (nil, NSFileProviderError(.cannotSynchronize)) + } + let modifiedItem = self + var contentsLocation = newContents + if contentsLocation == nil { + // TODO: Find a way to test me + assert(domain != nil) + guard let domain, let localUrl = await localUrlForContents(domain: domain) else { + logger.error( + """ + Unable to upload modified item that was previously unuploaded. + filename: \(modifiedItem.filename) + local url for contents could not be acquired. + """ + ) + return (nil, NSFileProviderError(.cannotSynchronize)) + } + contentsLocation = localUrl + } + return await Self.create( + basedOn: itemTarget, + contents: contentsLocation, + domain: domain, + account: account, + remoteInterface: remoteInterface, + ignoredFiles: ignoredFiles, + forcedChunkSize: forcedChunkSize, + progress: progress, + dbManager: dbManager, + log: logger.log + ) + } + + // Just modifies metadata + func modifyUnuploaded( + itemTarget: NSFileProviderItem, + baseVersion _: NSFileProviderItemVersion = NSFileProviderItemVersion(), + changedFields: NSFileProviderItemFields, + contents newContents: URL?, + options _: NSFileProviderModifyItemOptions = [], + request _: NSFileProviderRequest = NSFileProviderRequest(), + ignoredFiles _: IgnoredFilesMatcher? = nil, + domain _: NSFileProviderDomain? = nil, + forcedChunkSize _: Int? = nil, + progress _: Progress = .init(), + dbManager: FilesDatabaseManager + ) async -> Item? { + var modifiedParentItemIdentifier = parentItemIdentifier + var modifiedMetadata = metadata + + if changedFields.contains(.filename) { + modifiedMetadata.fileName = itemTarget.filename + if !isLockFileName(modifiedMetadata.fileName) { + modifiedMetadata.classFile = "" + // Do the actual upload at the end, not yet + } + } + if changedFields.contains(.contents), + let newSize = try? newContents?.resourceValues(forKeys: [.fileSizeKey]).fileSize + { + modifiedMetadata.size = Int64(newSize) + } + if changedFields.contains(.parentItemIdentifier) { + guard let parentMetadata = dbManager.itemMetadata( + ocId: itemTarget.parentItemIdentifier.rawValue + ) else { + logger.error( + """ + Unable to find new parent item identifier during unuploaded item modification. + Filename: \(filename) + """ + ) + return nil + } + modifiedMetadata.serverUrl = parentMetadata.serverUrl + "/" + parentMetadata.fileName + modifiedParentItemIdentifier = .init(parentMetadata.ocId) + } + if changedFields.contains(.creationDate), + let newCreationDate = itemTarget.creationDate, + let newCreationDate + { + modifiedMetadata.creationDate = newCreationDate + } + if changedFields.contains(.contentModificationDate), + let newModificationDate = itemTarget.contentModificationDate, + let newModificationDate + { + modifiedMetadata.date = newModificationDate + } + + return await Item( + metadata: modifiedMetadata, + parentItemIdentifier: modifiedParentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: account), + log: logger.log + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item.swift new file mode 100644 index 0000000000000..c96a0e88868fe --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -0,0 +1,440 @@ +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import NextcloudKit +import UniformTypeIdentifiers + +/// +/// Data model implementation for file provider items as defined by the file provider framework and `NSFileProviderItemProtocol`. +/// +public final class Item: NSObject, NSFileProviderItem, Sendable { + public enum FileProviderItemTransferError: Error { + case downloadError + case uploadError + } + + public let dbManager: FilesDatabaseManager + public let metadata: SendableItemMetadata + public let parentItemIdentifier: NSFileProviderItemIdentifier + public let account: Account + public let remoteInterface: RemoteInterface + + private let remoteSupportsTrash: Bool + + public var itemIdentifier: NSFileProviderItemIdentifier { + NSFileProviderItemIdentifier(metadata.ocId) + } + + public var capabilities: NSFileProviderItemCapabilities { + var capabilities: NSFileProviderItemCapabilities = [] + let permissions = metadata.permissions.uppercased() + + if permissions.contains("G"), metadata.directory { // Readable + capabilities.insert(.allowsContentEnumerating) + } else if permissions.contains("G") { + capabilities.insert(.allowsReading) + } + + if metadata.lock == false || (metadata.lock == true && metadata.lockOwnerType == NKLockType.token.rawValue && metadata.ownerId == metadata.lockOwner && metadata.lockToken != nil) { + if permissions.contains("D") { // Deletable + capabilities.insert(.allowsDeleting) + } + if remoteSupportsTrash, !isLockFileName(filename) { + capabilities.insert(.allowsTrashing) + } + if permissions.contains("W"), !metadata.directory { // Updateable (file) + capabilities.insert(.allowsWriting) + } + if permissions.contains("NV") { // Updateable, renameable, moveable + capabilities.formUnion([.allowsRenaming, .allowsReparenting]) + + if metadata.directory { + capabilities.insert(.allowsAddingSubItems) + } + } + if permissions.contains("CK"), metadata.directory { // Folder not changeable but adding sub-files & -folders + capabilities.insert(.allowsWriting) + } + } + + // .allowsEvicting deprecated on macOS 13.0+, use contentPolicy instead + if #unavailable(macOS 13.0), !metadata.keepDownloaded { + capabilities.insert(.allowsEvicting) + } + #if os(macOS) + if #available(macOS 11.3, *) { + capabilities.insert(.allowsExcludingFromSync) + } + #endif + return capabilities + } + + public var itemVersion: NSFileProviderItemVersion { + NSFileProviderItemVersion( + contentVersion: metadata.etag.data(using: .utf8)!, + metadataVersion: metadata.etag.data(using: .utf8)! + ) + } + + public var filename: String { + metadata.isTrashed && !metadata.trashbinFileName.isEmpty ? + metadata.trashbinFileName : !metadata.fileName.isEmpty ? + metadata.fileName : "unnamed file" + } + + public var contentType: UTType { + if itemIdentifier == .rootContainer || (metadata.contentType.isEmpty && metadata.directory) { + return .folder + } else if metadata.contentType == "httpd/unix-directory", metadata.directory { + let filenameComponents = filename.components(separatedBy: ".") + + if filenameComponents.count > 1, let ext = filenameComponents.last { + return UTType(filenameExtension: ext, conformingTo: .directory) ?? .folder + } + + return .folder + } else if !metadata.contentType.isEmpty, let type = UTType(metadata.contentType) { + return type + } + + let filenameExtension = filename.components(separatedBy: ".").last ?? "" + + return UTType(filenameExtension: filenameExtension) ?? .content + } + + public var documentSize: NSNumber? { + NSNumber(value: metadata.size) + } + + public var creationDate: Date? { + metadata.creationDate as Date + } + + public var lastUsedDate: Date? { + metadata.date as Date + } + + public var contentModificationDate: Date? { + metadata.date as Date + } + + public var isDownloaded: Bool { + metadata.directory || metadata.downloaded + } + + public var isDownloading: Bool { + metadata.isDownload + } + + public var downloadingError: Error? { + if metadata.status == Status.downloadError.rawValue { + return FileProviderItemTransferError.downloadError + } + return nil + } + + public var isUploaded: Bool { + metadata.uploaded + } + + public var isUploading: Bool { + metadata.isUpload + } + + public var uploadingError: Error? { + if metadata.status == Status.uploadError.rawValue { + FileProviderItemTransferError.uploadError + } else { + nil + } + } + + public var isShared: Bool { + false // !metadata.shareType.isEmpty // Interim solution to counteract Finder misleadingly displaying shared items with an iCloud branded banner. + } + + public var isSharedByCurrentUser: Bool { + false // isShared && metadata.ownerId == account.id // Interim solution to counteract Finder misleadingly displaying shared items with an iCloud branded banner. + } + + public var ownerNameComponents: PersonNameComponents? { + guard isShared, !isSharedByCurrentUser else { return nil } + let formatter = PersonNameComponentsFormatter() + return formatter.personNameComponents(from: metadata.ownerDisplayName) + } + + public var childItemCount: NSNumber? { + if metadata.directory { + NSNumber(integerLiteral: dbManager.childItemCount(directoryMetadata: metadata)) + } else { + nil + } + } + + public var fileSystemFlags: NSFileProviderFileSystemFlags { + if metadata.isLockFileOfLocalOrigin { + return [ + .hidden, + .userReadable, + .userWritable + ] + } + + if metadata.lock, metadata.lockOwnerType != NKLockType.user.rawValue || metadata.lockOwner != account.username, metadata.lockTimeOut ?? Date() > Date() { + return [ + .userReadable + ] + } + + return [ + .userReadable, + .userWritable + ] + } + + public var userInfo: [AnyHashable: Any]? { + var userInfoDict = [AnyHashable: Any]() + if metadata.lock { + // Can be used to display lock/unlock context menu entries for FPUIActions + // Note that only files, not folders, should be lockable/unlockable + userInfoDict["locked"] = metadata.lock + } + if #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) { + userInfoDict["displayKeepDownloaded"] = !metadata.keepDownloaded + userInfoDict["displayAllowAutoEvicting"] = metadata.keepDownloaded + userInfoDict["displayEvict"] = metadata.downloaded && !metadata.keepDownloaded + } else { + userInfoDict["displayEvict"] = metadata.downloaded + } + // https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html + if metadata.permissions.uppercased().contains("R"), // Shareable + ![.rootContainer, .trashContainer].contains(itemIdentifier) + { + userInfoDict["displayShare"] = true + } + return userInfoDict + } + + @available(macOS 13.0, iOS 16.0, visionOS 1.0, *) + public var contentPolicy: NSFileProviderContentPolicy { + #if os(macOS) + if metadata.keepDownloaded { + return .downloadEagerlyAndKeepDownloaded // Unavailable in iOS. + } + #endif + + return .inherited + } + + public var keepDownloaded: Bool { + guard #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) else { return false } + return metadata.keepDownloaded + } + + /// + /// Factory method to create a root container item. + /// + /// - Returns: A file provider item for the root container of the given account. + /// + public static func rootContainer( + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool, + log: any FileProviderLogging + ) -> Item { + let metadata = SendableItemMetadata( + ocId: NSFileProviderItemIdentifier.rootContainer.rawValue, + account: account.ncKitAccount, + classFile: NKTypeClassFile.directory.rawValue, + contentType: "", // Placeholder as not set in original code + creationDate: Date(), // Default as not set in original code + directory: true, + e2eEncrypted: false, // Default as not set in original code + etag: "", // Placeholder as not set in original code + fileId: "", // Placeholder as not set in original code + fileName: "/", + fileNameView: "/", + hasPreview: false, // Default as not set in original code + iconName: "", // Placeholder as not set in original code + mountType: "", // Placeholder as not set in original code + ownerId: "", // Placeholder as not set in original code + ownerDisplayName: "", // Placeholder as not set in original code + path: "", // Placeholder as not set in original code + serverUrl: account.davFilesUrl, + size: 0, // Default as not set in original code + uploaded: true, + urlBase: "", // Placeholder as not set in original code + user: "", // Placeholder as not set in original code + userId: "" // Placeholder as not set in original code + ) + return Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: log + ) + } + + public static func trashContainer( + remoteInterface: RemoteInterface, + account: Account, + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool, + log: any FileProviderLogging + ) -> Item { + let metadata = SendableItemMetadata( + ocId: NSFileProviderItemIdentifier.trashContainer.rawValue, + account: account.ncKitAccount, + classFile: NKTypeClassFile.directory.rawValue, + contentType: "", // Placeholder as not set in original code + creationDate: Date(), // Default as not set in original code + directory: true, + e2eEncrypted: false, // Default as not set in original code + etag: "", // Placeholder as not set in original code + fileId: "", // Placeholder as not set in original code + fileName: "Trash", + fileNameView: "Trash", + hasPreview: false, // Default as not set in original code + iconName: "", // Placeholder as not set in original code + mountType: "", // Placeholder as not set in original code + ownerId: "", // Placeholder as not set in original code + ownerDisplayName: "", // Placeholder as not set in original code + path: "", // Placeholder as not set in original code + serverUrl: account.trashUrl, + size: 0, // Default as not set in original code + uploaded: true, + urlBase: "", // Placeholder as not set in original code + user: "", // Placeholder as not set in original code + userId: "" // Placeholder as not set in original code + ) + return Item( + metadata: metadata, + parentItemIdentifier: .trashContainer, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: log + ) + } + + let logger: FileProviderLogger + + public required init( + metadata: SendableItemMetadata, + parentItemIdentifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + remoteSupportsTrash: Bool, + log: any FileProviderLogging + ) { + self.metadata = metadata + self.parentItemIdentifier = parentItemIdentifier + self.account = account + logger = FileProviderLogger(category: "Item", log: log) + self.remoteInterface = remoteInterface + self.dbManager = dbManager + self.remoteSupportsTrash = remoteSupportsTrash + super.init() + } + + public static func storedItem( + identifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager, + log: any FileProviderLogging + ) async -> Item? { + // resolve the given identifier to a record in the model + + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: account) + + guard identifier != .rootContainer else { + return Item.rootContainer( + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: log + ) + } + guard identifier != .trashContainer else { + return Item.trashContainer( + remoteInterface: remoteInterface, + account: account, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: log + ) + } + + guard let metadata = dbManager.itemMetadata(identifier) else { + return nil + } + + let parentItemIdentifier: NSFileProviderItemIdentifier? = if metadata.isTrashed { + .trashContainer + } else { + await dbManager.parentItemIdentifierWithRemoteFallback( + fromMetadata: metadata, + remoteInterface: remoteInterface, + account: account + ) + } + + guard let parentItemIdentifier else { + return nil + } + + return Item( + metadata: metadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: log + ) + } + + public func localUrlForContents(domain: NSFileProviderDomain) async -> URL? { + guard isDownloaded else { + logger.error("Unable to get local URL for item contents. Item is not materialised.", [.name: filename]) + + return nil + } + + guard let manager = NSFileProviderManager(for: domain), let fileUrl = try? await manager.getUserVisibleURL(for: itemIdentifier) else { + logger.error("Unable to get manager or user visible url for item. Cannot provide local URL for contents.", [.name: filename]) + + return nil + } + + let fm = FileManager.default + let tempLocation = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let coordinator = NSFileCoordinator() + var readData: Data? + + coordinator.coordinate(readingItemAt: fileUrl, options: [], error: nil) { readURL in + readData = try? Data(contentsOf: readURL) + } + + guard let readData else { + return nil + } + + do { + try readData.write(to: tempLocation) + } catch { + logger.error("Unable to write file item contents to temporary URL.", [.name: filename, .error: error]) + } + + return tempLocation + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift new file mode 100644 index 0000000000000..487154684fc9b --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit +import os + +/// +/// A logging facility designed for file provider extensions. It writes JSON lines to a file. +/// +/// Do not use this directly but create ``FileProviderLogger`` instances based on this instead. +/// +/// Due to the dependency on the `NSFileProviderDomainIdentifier`, This needs to be intialized by the type implementing the `NSFileProviderReplicatedExtension`. +/// An instance would not be abled to resolve the file provider domain it is used by. +/// +/// In general, there should be only one instance for every process. +/// +/// Messages with debug level are written only for debug configuration builds. +/// +/// Debug configuration builds also write to the unified logging system as an alternative to view logged messages. +/// +/// > To Do: Consider using macros for the calls so the calling functions and files can be recorded, too! +/// +public actor FileProviderLog: FileProviderLogging { + /// + /// The file provider domain identifier for creating new log files during rotation. + /// + let domainIdentifier: NSFileProviderDomainIdentifier + + /// + /// JSON encoder for ``FileProviderLogMessage`` values. + /// + let encoder: JSONEncoder + + /// + /// The current file location to write messages to. + /// + var file: URL? + + /// + /// The file manager to use for file system operations. + /// + let fileManager: FileManager + + /// + /// Used for the file name part of the log files. + /// + let fileDateFormatter: DateFormatter + + /// + /// Used for for the date strings in encoded ``FileProviderLogMessage``. + /// + let messageDateFormatter: DateFormatter + + /// + /// The handle used for writing to the file located by ``url``. + /// + var handle: FileHandle? + + /// + /// The fallback logger. + /// + /// This is important in case the actual log file could be created or written to. + /// + let logger: Logger + + /// + /// The logs directory where log files are stored. + /// + let logsDirectory: URL? + + /// + /// Maximum log file size in bytes (100 MB). + /// + let maxLogFileSize: Int64 = 100 * 1024 * 1024 + + /// + /// The subsystem string to be used as with the unified logging system. + /// + let subsystem: String + + /// + /// Initialize a new log file abstraction. + /// + /// - Parameters: + /// - fileProviderDomainIdentifier: The raw string value of the file provider domain which this file provider extension process is managing. + /// + public init(fileProviderDomainIdentifier identifier: NSFileProviderDomainIdentifier) { + domainIdentifier = identifier + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + fileManager = FileManager.default + + fileDateFormatter = DateFormatter() + fileDateFormatter.locale = Locale(identifier: "en_US_POSIX") + fileDateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + + messageDateFormatter = DateFormatter() + messageDateFormatter.locale = Locale(identifier: "en_US_POSIX") + messageDateFormatter.dateFormat = "yyyy.MM.dd HH:mm:ss.SSS" + + subsystem = Bundle.main.bundleIdentifier ?? "" + logger = Logger(subsystem: subsystem, category: "FileProviderLog") + + guard let logsDirectory = fileManager.fileProviderDomainLogDirectory(for: identifier) else { + logger.error("Failed to get URL for file provider domain logs!") + file = nil + handle = nil + self.logsDirectory = nil + return + } + + self.logsDirectory = logsDirectory + } + + /// + /// Rotates the log file by creating a new one when the current file becomes too large. + /// + func rotateLogFileIfNeeded() { + guard let logsDirectory else { + logger.error("Cancelling log file rotation due to the lack of a logs directory!") + return + } + + do { + if let currentFile = file { + let fileAttributes = try fileManager.attributesOfItem(atPath: currentFile.path) + + if let fileSize = fileAttributes[.size] as? Int64, fileSize >= maxLogFileSize { + // Close current handle + handle?.closeFile() + logger.debug("Closed current log file at \"\(currentFile.path, privacy: .public)\" because it exceeds the size limit.") + + file = nil + handle = nil + } + } + } catch { + // swiftformat:disable:next redundantSelf + logger.error("Failed to close open log file at \"\(self.file?.path ?? "nil")\": \(error.localizedDescription, privacy: .public)") + } + + guard handle == nil else { + // Already have an active handle which was not closed previously, stick with that file. + return + } + + let creationDate = Date() + let formattedDate = fileDateFormatter.string(from: creationDate) + let processIdentifier = ProcessInfo.processInfo.processIdentifier + let name = "\(formattedDate) (\(processIdentifier)).jsonl" + let newFile = logsDirectory.appendingPathComponent(name, isDirectory: false) + + if fileManager.createFile(atPath: newFile.path, contents: nil) == false { + logger.error("Failed to create new log file at: \"\(newFile.path)\".") + return + } else { + logger.debug("Created new log file at: \"\(newFile.path)\".") + } + + do { + file = newFile + handle = try FileHandle(forWritingTo: newFile) + logger.debug("Opened new log file for writing at: \"\(newFile.path)\".") + } catch { + logger.error("Failed to open new log file at \"\(newFile.path)\" for writing: \(error.localizedDescription, privacy: .public)") + } + + // Clean up old log files (older than 24 hours) + cleanupOldLogFiles() + } + + /// + /// Removes log files that are older than 24 hours, excluding the current active log file. + /// + private func cleanupOldLogFiles() { + guard let logsDirectory else { + logger.error("Cannot cleanup old log files: logs directory is nil") + return + } + + do { + let contents = try fileManager.contentsOfDirectory(at: logsDirectory, includingPropertiesForKeys: [.creationDateKey], options: [.skipsHiddenFiles]) + let currentTime = Date() + let twentyFourHoursAgo = currentTime.addingTimeInterval(-24 * 60 * 60) + + for fileURL in contents { + // Skip if this is the current active log file + if let currentFile = file, fileURL == currentFile { + continue + } + + // Only process .jsonl files + guard fileURL.pathExtension == "jsonl" else { + continue + } + + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.creationDateKey]) + + if let creationDate = resourceValues.creationDate, creationDate < twentyFourHoursAgo { + try fileManager.removeItem(at: fileURL) + logger.debug("Deleted old log file: \"\(fileURL.path, privacy: .public)\" (created: \(creationDate, privacy: .public))") + } + } catch { + logger.error("Failed to delete old log file at \"\(fileURL.path)\": \(error.localizedDescription, privacy: .public)") + } + } + } catch { + logger.error("Failed to enumerate log files for cleanup: \(error.localizedDescription, privacy: .public)") + } + } + + private func writeToUnifiedLoggingSystem(level: OSLogType, message: String, details: [FileProviderLogDetailKey: (any Sendable)?]) { + if details.isEmpty { + logger.log(level: level, "\(message, privacy: .public)") + return + } + + let sortedKeys = details.keys.sorted() + + let detailDescriptions: [String] = sortedKeys.compactMap { key in + let valueDescription: String = switch details[key] { + case let account as Account: + account.ncKitAccount + case let date as Date: + messageDateFormatter.string(from: date) + case let error as NSError: + error.debugDescription + case let lock as NKLock: + lock.token ?? "nil" + case let item as NSFileProviderItem: + item.itemIdentifier.rawValue + case let identifier as NSFileProviderItemIdentifier: + identifier.rawValue + case let url as URL: + url.absoluteString + case let text as String: + text + case let request as NSFileProviderRequest: + "requestingExecutable: \(request.requestingExecutable?.path ?? "nil"), isFileViewerRequest: \(request.isFileViewerRequest), isSystemRequest: \(request.isSystemRequest)" + case nil: + "nil" + default: + "" + } + + return "- \(key.rawValue): \(valueDescription)" + } + + logger.log(level: level, "\(message, privacy: .public)\n\n\(detailDescriptions.joined(separator: "\n"), privacy: .public)") + } + + public func write(category: String, level: OSLogType, message: String, details: [FileProviderLogDetailKey: (any Sendable)?]) { + #if DEBUG + + writeToUnifiedLoggingSystem(level: level, message: message, details: details) + + #else + + if level == .debug { + return // We want debug messages only in debug builds. + } + + #endif + + rotateLogFileIfNeeded() // Check if log file needs rotation before writing anything. + + guard let handle else { // Continue only when a file handle is available. + return + } + + let levelDescription = switch level { + case .debug: + "debug" + case .info: + "info" + case .default: + "default" + case .error: + "error" + case .fault: + "fault" + default: + "default" + } + + let date = Date() + let formattedDate = messageDateFormatter.string(from: date) + let entry = FileProviderLogMessage(category: category, date: formattedDate, details: details, level: levelDescription, message: message) + + do { + let object = try encoder.encode(entry) + try handle.write(contentsOf: object) + try handle.write(contentsOf: "\n".data(using: .utf8)!) + try handle.synchronize() + } catch { + logger.error("Failed to encode and write message: \(message, privacy: .public)!") + return + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift new file mode 100644 index 0000000000000..265e4cfc8000d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit + +/// +/// An enum that can represent any JSON value and is `Encodable`. +/// +/// > To Do: Add custom encodings for: +/// > - `RealmItemMetadata` +/// +public enum FileProviderLogDetail: Encodable { + /// + /// RFC 3339 formatter for the dates. + /// + static let dateFormatter = { + let formatter = DateFormatter() + + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + + return formatter + }() + + /// + /// The represented detail value is a date but encoded as a formatted `String` in JSON. + /// + case date(Date) + + /// + /// The represented detail value is a string in JSON. + /// + case string(String) + + /// + /// The represented detail value is an integer in JSON. + /// + case int(Int) + + /// + /// The represented detail value is a double in JSON. + /// + case double(Double) + + /// + /// The represented detail value is a boolean in JSON. + /// + case bool(Bool) + + /// + /// The represented detail value is an array in JSON. + /// + case array([FileProviderLogDetail]) + + /// + /// The represented detail value is a dictionary in JSON. + /// + case dictionary([String: FileProviderLogDetail]) + + /// + /// The represented detail value is `null` in JSON. + /// + case null + + var value: Any { + switch self { + case let .date(v): + Self.dateFormatter.string(from: v) + case let .string(v): + v + case let .int(v): + v + case let .double(v): + v + case let .bool(v): + v + case let .array(v): + v.map(\.value) + case let .dictionary(v): + v.mapValues { $0.value } + case .null: + NSNull() + } + } + + /// + /// Attempt to create a detail value based on any given type. + /// + init(_ anyOptional: Any?) { + if let someValue = anyOptional { + if someValue.self is String { + self = .string(someValue as! String) + } else if let date = someValue as? Date { + self = .date(date) + } else if let url = someValue as? URL { + self = .string(url.absoluteString) + } else if let account = someValue as? Account { + self = .string(account.ncKitAccount) + } else if let error = someValue as? NSFileProviderError { + self = .string("NSFileProviderError.Code: \(error.code)") + } else if let error = someValue as? NSError { + self = .dictionary([ + "code": .int(error.code), + "domain": .string(error.domain), + "localizedDescription": .string(error.localizedDescription) + ]) + } else if let error = someValue as? Error { + self = .string(error.localizedDescription) + } else if let fileProviderDomainIdentifier = someValue as? NSFileProviderDomainIdentifier { + self = .string(fileProviderDomainIdentifier.rawValue) + } else if let item = someValue as? NSFileProviderItemProtocol { + self = .string(item.itemIdentifier.rawValue) + } else if let fileProviderItemIdentifier = someValue as? NSFileProviderItemIdentifier { + self = .string(fileProviderItemIdentifier.rawValue) + } else if let metadata = someValue as? SendableItemMetadata { + self = .dictionary([ + "account": .string(metadata.account), + "contentType": .string(metadata.contentType), + "creationDate": .date(metadata.creationDate), + "date": .date(metadata.date), + "deleted": .bool(metadata.deleted), + "directory": .bool(metadata.directory), + "downloaded": .bool(metadata.downloaded), + "eTag": .string(metadata.etag), + "keepDownloaded": .bool(metadata.keepDownloaded), + "lock": .bool(metadata.lock), + "lockTimeOut": metadata.lockTimeOut != nil ? .date(metadata.lockTimeOut!) : .string("nil"), + "lockOwner": metadata.lockOwner != nil ? .string(metadata.lockOwner!) : .null, + "name": .string(metadata.fileName), + "ocId": .string(metadata.ocId), + "permissions": .string(metadata.permissions), + "serverUrl": .string(metadata.serverUrl), + "size": .int(Int(metadata.size)), + "syncTime": .date(metadata.syncTime), + "trashbinFileName": .string(metadata.trashbinFileName), + "uploaded": .bool(metadata.uploaded), + "visitedDirectory": .bool(metadata.visitedDirectory) + ]) + } else if let request = someValue as? NSFileProviderRequest { + self = .dictionary([ + "requestingExecutable": .string(request.requestingExecutable?.path ?? "nil"), + "isFileViewerRequest": .bool(request.isFileViewerRequest), + "isSystemRequest": .bool(request.isSystemRequest) + ]) + } else if let lock = someValue as? NKLock { + self = .dictionary([ + "owner": .string(lock.owner), + "ownerDisplayName": .string(lock.ownerDisplayName), + "ownerEditor": lock.ownerEditor == nil ? .null : .string(lock.ownerEditor!), + "ownerType": .int(lock.ownerType.rawValue), + "time": lock.time == nil ? .null : .date(lock.time!), + "timeOut": lock.timeOut == nil ? .null : .date(lock.timeOut!), + "token": lock.token == nil ? .null : .string(lock.token!) + ]) + } else { + self = .string("Unsupported log detail type: \(String(describing: someValue.self))") + } + } else { + self = .null + } + } + + // MARK: Encodable + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case let .date(v): + try container.encode(v) + case let .string(v): + try container.encode(v) + case let .int(v): + try container.encode(v) + case let .double(v): + try container.encode(v) + case let .bool(v): + try container.encode(v) + case let .array(v): + try container.encode(v) + case let .dictionary(v): + try container.encode(v) + case .null: + try container.encodeNil() + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift new file mode 100644 index 0000000000000..caa657d1e28c8 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +/// +/// A predefined set of detail keys to avoid having multiple keys for the same type of information accidentally while still leaving the possibility to define arbitrary keys. +/// +public enum FileProviderLogDetailKey: String, Sendable { + /// + /// The identifier for an account. + /// + case account + + /// + /// The raw value of an `NSFileProviderDomainIdentifier`. + /// + case domain + + /// + /// The original and underlying error. + /// + /// Use this for any `Error` or `NSError`, the logging system will extract relevant values in a central place automatically. + /// + case error + + /// + /// HTTP entity tag. + /// + /// See [Wikipedia](https://en.wikipedia.org/wiki/HTTP_ETag) for further information. + /// + case eTag + + /// + /// The raw value of an `NSFileProviderItemIdentifier`. + /// Also known and used as `ocId`. + /// + case item + + /// + /// An `NKLock` as provided by NextcloudKit when a file system item is locked on the server. + /// + case lock + + /// + /// The name of a file or directory in the file system. + /// + case name + + /// + /// An `NSFileProviderRequest`. + /// + case request + + /// + /// The last time item metadata was synchronized with the server. + /// + case syncTime + + /// + /// Any relevant URL, in example in context of a network request. + /// + case url +} + +extension FileProviderLogDetailKey: Comparable { + public static func < (lhs: FileProviderLogDetailKey, rhs: FileProviderLogDetailKey) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift new file mode 100644 index 0000000000000..22f8e17ecd87b --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +/// +/// A data model for the rich JSON object to be written into the JSON lines log files. +/// +public struct FileProviderLogMessage: Encodable { + /// + /// As used with `Logger` of the `os` framework. + /// + public let category: String + + /// + /// Time of the message to write. + /// + public let date: String + + /// + /// An optional dictionary of additional metadata related to the message. + /// + /// This is intended to improve the filter possibilities in logs by structuring the messages in more detail. + /// By providing contextual identifiers, the generic stream of messages can be filtered centered around individual subjects like file provider items or similar. + /// + public let details: [String: FileProviderLogDetail?] + + /// + /// Textual representation of the associated `OSLogType`. + /// + public let level: String + + /// + /// The actual text for the entry. + /// + public let message: String + + /// + /// Custom initializer to support arbitrary types as detail values. + /// + init(category: String, date: String, details: [FileProviderLogDetailKey: Any?], level: String, message: String) { + self.category = category + self.date = date + + var transformedDetails = [String: FileProviderLogDetail?]() + + for key in details.keys { + transformedDetails[key.rawValue] = FileProviderLogDetail(details[key] as Any?) + } + + self.details = transformedDetails + self.level = level + self.message = message + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift new file mode 100644 index 0000000000000..72c939fde9ccb --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import os + +/// +/// A proxy type to be used by logging types. +/// +/// - Automatically augments messages with the once configured category. +/// - Translates the designated methods to the log level argument. +/// - Provides synchronous methods to dispatch log messages to the underlying actor. +/// +/// This must be instantiated per instance of calling types. +/// Statically or lazily defining a single instance of this for all instances of a calling type is not possible due to the dependency on ``FileProviderLog``. +/// The latter needs to be provided as an argument, passed down the call stack from the `NSFileProviderReplicatedExtension` implementation. +/// +public struct FileProviderLogger: Sendable { + /// + /// The category string to be used as with the unified logging system. + /// + let category: String + + /// + /// The file logging system object. + /// + let log: any FileProviderLogging + + /// + /// Create a new logger which is supposed to be used by individual types and their instances. + /// + public init(category: String, log: any FileProviderLogging) { + self.category = category + self.log = log + } + + /// + /// Dispatch a task to write a message with the level `OSLogType.debug`. + /// + /// - Parameters: + /// - message: The main text message of the entry in the logs. + /// - details: Additional contextual data. + /// + public func debug(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { + Task { + await log.write(category: category, level: .debug, message: message, details: details) + } + } + + /// + /// Dispatch a task to write a message with the level `OSLogType.info`. + /// + /// - Parameters: + /// - message: The main text message of the entry in the logs. + /// - details: Additional contextual data. + /// + public func info(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { + Task { + await log.write(category: category, level: .info, message: message, details: details) + } + } + + /// + /// Dispatch a task to write a message with the level `OSLogType.error`. + /// + /// - Parameters: + /// - message: The main text message of the entry in the logs. + /// - details: Additional contextual data. + /// + public func error(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { + Task { + await log.write(category: category, level: .error, message: message, details: details) + } + } + + /// + /// Dispatch a task to write a message with the level `OSLogType.fault`. + /// + /// - Parameters: + /// - message: The main text message of the entry in the logs. + /// - details: Additional contextual data. + /// + public func fault(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { + Task { + await log.write(category: category, level: .fault, message: message, details: details) + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift new file mode 100644 index 0000000000000..7870750ab2ec3 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import os + +/// +/// Requirements for a log implementation which enables a mock object for tests. +/// +public protocol FileProviderLogging: Actor { + /// + /// Write a message to the unified logging system and current log file. + /// + /// Usually, you do not need or want to use this but the methods provided by ``FileProviderLogger`` instead. + /// + func write(category: String, level: OSLogType, message: String, details: [FileProviderLogDetailKey: (any Sendable)?]) +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift new file mode 100644 index 0000000000000..bf702103c2f5f --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation + +/// +/// Used as a semantic mapping for ``ItemMetadata/status``. +/// +public enum Status: Int { + case downloadError = -4 + case downloading = -3 + case inDownload = -2 + + case normal = 0 + + case inUpload = 2 + case uploading = 3 + case uploadError = 4 +} + +/// +/// Requirements for the data model implementations of file provider items. +/// +public protocol ItemMetadata: Equatable { + var ocId: String { get set } + var account: String { get set } + var checksums: String { get set } + var chunkUploadId: String? { get set } + var classFile: String { get set } + var commentsUnread: Bool { get set } + var contentType: String { get set } + var creationDate: Date { get set } + var dataFingerprint: String { get set } + var date: Date { get set } + var syncTime: Date { get set } + var deleted: Bool { get set } + var directory: Bool { get set } + var downloadURL: String { get set } + var e2eEncrypted: Bool { get set } + var etag: String { get set } + var favorite: Bool { get set } + var fileId: String { get set } + var fileName: String { get set } // What the file's real file name is + var fileNameView: String { get set } // What the user sees (usually same as fileName) + var hasPreview: Bool { get set } + var hidden: Bool { get set } + var iconName: String { get set } + var iconUrl: String { get set } + + /// + /// This is a lock file which was created on the local device and not introduced through synchronization with the server. + /// + var isLockFileOfLocalOrigin: Bool { get set } + + var mountType: String { get set } + var name: String { get set } // for unifiedSearch is the provider.id + var note: String { get set } + var ownerId: String { get set } + var ownerDisplayName: String { get set } + var livePhotoFile: String? { get set } + var lock: Bool { get set } + var lockOwner: String? { get set } + var lockOwnerEditor: String? { get set } + var lockOwnerType: Int? { get set } + var lockOwnerDisplayName: String? { get set } + var lockTime: Date? { get set } // Time the file was locked + var lockTimeOut: Date? { get set } // Time the file's lock will expire + var lockToken: String? { get set } + var path: String { get set } + var permissions: String { get set } + var shareType: [Int] { get set } + var quotaUsedBytes: Int64 { get set } + var quotaAvailableBytes: Int64 { get set } + var resourceType: String { get set } + var richWorkspace: String? { get set } + var serverUrl: String { get set } // For parent folder! Retrieve the full remote url via .remotePath() + var session: String? { get set } + var sessionError: String? { get set } + var sessionTaskIdentifier: Int? { get set } + var sharePermissionsCollaborationServices: Int { get set } + // TODO: Find a way to compare these two below in remote state check + var sharePermissionsCloudMesh: [String] { get set } + var size: Int64 { get set } + var status: Int { get set } + var tags: [String] { get set } + var downloaded: Bool { get set } + var uploaded: Bool { get set } + var keepDownloaded: Bool { get set } + var visitedDirectory: Bool { get set } + var trashbinFileName: String { get set } + var trashbinOriginalLocation: String { get set } + var trashbinDeletionTime: Date { get set } + var uploadDate: Date { get set } + var urlBase: String { get set } + var user: String { get set } // The user who owns the file (Nextcloud username) + var userId: String { get set } // The user who owns the file (backend user id) + // (relevant for alt. backends like LDAP) +} + +public extension ItemMetadata { + var livePhoto: Bool { + livePhotoFile != nil && livePhotoFile?.isEmpty == false + } + + var isDownloadUpload: Bool { + status == Status.inDownload.rawValue || status == Status.downloading.rawValue + || status == Status.inUpload.rawValue || status == Status.uploading.rawValue + } + + var isDownload: Bool { + status == Status.inDownload.rawValue || status == Status.downloading.rawValue + } + + var isUpload: Bool { + status == Status.inUpload.rawValue || status == Status.uploading.rawValue + } + + var isTrashed: Bool { + serverUrl.hasPrefix(urlBase + Account.webDavTrashUrlSuffix + userId + "/trash") + } + + mutating func apply(fileName: String) { + self.fileName = fileName + fileNameView = fileName + name = fileName + } + + mutating func apply(account: Account) { + self.account = account.ncKitAccount + user = account.username + userId = account.id + urlBase = account.serverUrl + } + + func isInSameDatabaseStoreableRemoteState(_ comparingMetadata: any ItemMetadata) + -> Bool + { + comparingMetadata.etag == etag + && comparingMetadata.fileNameView == fileNameView + && comparingMetadata.date == date + && comparingMetadata.permissions == permissions + && comparingMetadata.hasPreview == hasPreview + && comparingMetadata.note == note + && comparingMetadata.lock == lock + && comparingMetadata.sharePermissionsCollaborationServices + == sharePermissionsCollaborationServices + && comparingMetadata.favorite == favorite + } + + /// Returns false if the user is lokced out of the file. I.e. The file is locked but by someone else + func canUnlock(as user: String) -> Bool { + !lock || (lockOwner == user && lockOwnerType == 0) + } + + func thumbnailUrl(size: CGSize) -> URL? { + guard hasPreview else { + return nil + } + guard #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) else { + return URL( + string: "\(urlBase.urlEncoded ?? "")/index.php/core/preview?fileId=\(fileId)&x=\(size.width)&y=\(size.height)&a=true" + ) + } + return URL(string: urlBase.urlEncoded ?? "")? + .appending(components: "index.php", "core", "preview") + .appending(queryItems: [ + .init(name: "fileId", value: fileId), + .init(name: "x", value: "\(size.width)"), + .init(name: "y", value: "\(size.height)"), + .init(name: "a", value: "true") + ]) + } + + func remotePath() -> String { + if ocId == NSFileProviderItemIdentifier.rootContainer.rawValue { + // For the root container the fileName is defined by NextcloudKit.shared.nkCommonInstance.rootFileName. + // --> appending the fileName to that is not correct, as it most likely won't exist. + return serverUrl + } + + return "\(serverUrl)/\(fileName)" + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift new file mode 100644 index 0000000000000..dd63bb52a768d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import RealmSwift + +/// +/// Realm data model for a file provider item as stored in the extension's own database for metadata. +/// +/// > Warning: **Do not pass instances across the boundaries of different concurrency domains because they are not sendable!** +/// Use ``SendableItemMetadata`` as a representation instead. +/// +class RealmItemMetadata: Object, ItemMetadata { + @Persisted(primaryKey: true) var ocId: String + @Persisted var account = "" + @Persisted var checksums = "" + @Persisted var chunkUploadId: String? + @Persisted var classFile = "" + @Persisted var commentsUnread: Bool = false + @Persisted var contentType = "" + @Persisted var creationDate = Date() + @Persisted var dataFingerprint = "" + @Persisted var date = Date() + @Persisted var syncTime = Date() + @Persisted var deleted = false + @Persisted var directory: Bool = false + @Persisted var downloadURL = "" + @Persisted var e2eEncrypted: Bool = false + @Persisted var etag = "" + @Persisted var favorite: Bool = false + @Persisted var fileId = "" + @Persisted var fileName = "" // What the file's real file name is + @Persisted var fileNameView = "" // What the user sees (usually same as fileName) + @Persisted var hasPreview: Bool = false + @Persisted var hidden = false + @Persisted var iconName = "" + @Persisted var iconUrl = "" + @Persisted var isLockFileOfLocalOrigin: Bool = false + @Persisted var livePhotoFile: String? + @Persisted var mountType = "" + @Persisted var name = "" // for unifiedSearch is the provider.id + @Persisted var note = "" + @Persisted var ownerId = "" + @Persisted var ownerDisplayName = "" + @Persisted var lock: Bool = false + @Persisted var lockOwner: String? + @Persisted var lockOwnerEditor: String? + @Persisted var lockOwnerType: Int? + @Persisted var lockOwnerDisplayName: String? + @Persisted var lockTime: Date? // Time the file was locked + @Persisted var lockTimeOut: Date? // Time the file's lock will expire + @Persisted var lockToken: String? // Token identifier for token-based locks + @Persisted var path = "" + @Persisted var permissions = "" + @Persisted var quotaUsedBytes: Int64 = 0 + @Persisted var quotaAvailableBytes: Int64 = 0 + @Persisted var resourceType = "" + @Persisted var richWorkspace: String? + @Persisted var serverUrl = "" // For parent folder! Build remote url by adding fileName + @Persisted var session: String? + @Persisted var sessionError: String? + @Persisted var sessionTaskIdentifier: Int? + @Persisted var storedShareType = List() + var shareType: [Int] { + get { storedShareType.map(\.self) } + set { + storedShareType = List() + storedShareType.append(objectsIn: newValue) + } + } + + @Persisted var sharePermissionsCollaborationServices: Int = 0 + // TODO: Find a way to compare these two below in remote state check + @Persisted var storedSharePermissionsCloudMesh = List() + var sharePermissionsCloudMesh: [String] { + get { storedSharePermissionsCloudMesh.map(\.self) } + set { + storedSharePermissionsCloudMesh = List() + storedSharePermissionsCloudMesh.append(objectsIn: newValue) + } + } + + @Persisted var size: Int64 = 0 + @Persisted var status: Int = 0 + @Persisted var storedTags = List() + var tags: [String] { + get { storedTags.map(\.self) } + set { + storedTags = List() + storedTags.append(objectsIn: newValue) + } + } + + @Persisted var downloaded = false + @Persisted var uploaded = false + @Persisted var keepDownloaded = false + @Persisted var visitedDirectory = false + @Persisted var trashbinFileName = "" + @Persisted var trashbinOriginalLocation = "" + @Persisted var trashbinDeletionTime = Date() + @Persisted var uploadDate = Date() + @Persisted var urlBase = "" + @Persisted var user = "" // The user who owns the file (Nextcloud username) + @Persisted var userId = "" // The user who owns the file (backend user id) + // (relevant for alt. backends like LDAP) + + override func isEqual(_ object: Any?) -> Bool { + if let object = object as? RealmItemMetadata { + return fileId == object.fileId && account == object.account && path == object.path + && fileName == object.fileName + } + + return false + } + + convenience init(value: any ItemMetadata) { + self.init() + ocId = value.ocId + account = value.account + checksums = value.checksums + chunkUploadId = value.chunkUploadId + classFile = value.classFile + commentsUnread = value.commentsUnread + contentType = value.contentType + creationDate = value.creationDate + dataFingerprint = value.dataFingerprint + date = value.date + syncTime = value.syncTime + deleted = value.deleted + directory = value.directory + downloadURL = value.downloadURL + e2eEncrypted = value.e2eEncrypted + etag = value.etag + favorite = value.favorite + fileId = value.fileId + fileName = value.fileName + fileNameView = value.fileNameView + hasPreview = value.hasPreview + hidden = value.hidden + iconName = value.iconName + iconUrl = value.iconUrl + isLockFileOfLocalOrigin = value.isLockFileOfLocalOrigin + livePhotoFile = value.livePhotoFile + mountType = value.mountType + name = value.name + note = value.note + ownerId = value.ownerId + ownerDisplayName = value.ownerDisplayName + lock = value.lock + lockOwner = value.lockOwner + lockOwnerEditor = value.lockOwnerEditor + lockOwnerType = value.lockOwnerType + lockOwnerDisplayName = value.lockOwnerDisplayName + lockTime = value.lockTime + lockTimeOut = value.lockTimeOut + lockToken = value.lockToken + path = value.path + permissions = value.permissions + quotaUsedBytes = value.quotaUsedBytes + quotaAvailableBytes = value.quotaAvailableBytes + resourceType = value.resourceType + richWorkspace = value.richWorkspace + serverUrl = value.serverUrl + session = value.session + sessionError = value.sessionError + sessionTaskIdentifier = value.sessionTaskIdentifier + sharePermissionsCollaborationServices = value.sharePermissionsCollaborationServices + sharePermissionsCloudMesh = value.sharePermissionsCloudMesh + size = value.size + status = value.status + shareType = value.shareType + tags = value.tags + downloaded = value.downloaded + uploaded = value.uploaded + keepDownloaded = value.keepDownloaded + visitedDirectory = value.visitedDirectory + trashbinFileName = value.trashbinFileName + trashbinOriginalLocation = value.trashbinOriginalLocation + trashbinDeletionTime = value.trashbinDeletionTime + uploadDate = value.uploadDate + urlBase = value.urlBase + user = value.user + userId = value.userId + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift new file mode 100644 index 0000000000000..50836365b1aee --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import NextcloudKit +import RealmSwift + +public class RemoteFileChunk: Object { + @Persisted public var fileName: String + @Persisted public var size: Int64 + @Persisted public var remoteChunkStoreFolderName: String + + public static func fromNcKitChunks( + _ chunks: [(fileName: String, size: Int64)], remoteChunkStoreFolderName: String + ) -> [RemoteFileChunk] { + chunks.map { + RemoteFileChunk(ncKitChunk: $0, remoteChunkStoreFolderName: remoteChunkStoreFolderName) + } + } + + public convenience init( + ncKitChunk: (fileName: String, size: Int64), remoteChunkStoreFolderName: String + ) { + self.init( + fileName: ncKitChunk.fileName, + size: ncKitChunk.size, + remoteChunkStoreFolderName: remoteChunkStoreFolderName + ) + } + + public convenience init(fileName: String, size: Int64, remoteChunkStoreFolderName: String) { + self.init() + self.fileName = fileName + self.size = size + self.remoteChunkStoreFolderName = remoteChunkStoreFolderName + } + + func toNcKitChunk() -> (fileName: String, size: Int64) { + (fileName, size) + } +} + +extension [RemoteFileChunk] { + func toNcKitChunks() -> [(fileName: String, size: Int64)] { + map { ($0.fileName, $0.size) } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift new file mode 100644 index 0000000000000..24da9e1b490ab --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +extension [SendableItemMetadata] { + /// + /// Concurrently compact chunks of an array of ``SendableItemMetadata`` to an array of ``Item``. + /// + func toFileProviderItems(account: Account, remoteInterface: RemoteInterface, dbManager: FilesDatabaseManager, log: any FileProviderLogging) async throws -> [Item] { + let logger = FileProviderLogger(category: "toFileProviderItems", log: log) + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: account) + + let result: [Item] = try await concurrentChunkedCompactMap { (itemMetadata: SendableItemMetadata) -> Item? in + guard !itemMetadata.e2eEncrypted else { + logger.info("Skipping encrypted metadata in enumeration.", [.item: itemMetadata.ocId, .name: itemMetadata.fileName]) + return nil + } + + guard !isLockFileName(itemMetadata.fileName) else { + logger.info("Skipping remote lock file item metadata in enumeration.", [.item: itemMetadata.ocId, .name: itemMetadata.fileName]) + return nil + } + + guard let parentItemIdentifier = dbManager.parentItemIdentifierFromMetadata(itemMetadata) else { + logger.error("Could not get valid parentItemIdentifier for item by ocId.", [.item: itemMetadata.ocId, .name: itemMetadata.fileName]) + let targetUrl = itemMetadata.serverUrl + throw FilesDatabaseManager.parentMetadataNotFoundError(itemUrl: targetUrl) + } + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: log + ) + + logger.debug("Will enumerate item.", [.item: itemMetadata.ocId, .name: itemMetadata.fileName]) + + return item + } + + return result + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift new file mode 100644 index 0000000000000..de81f4d480a4a --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +/// +/// Value type representation for `RealmItemMetadata`. +/// +/// > Warning: Realm objects are inherently unsendable and not thread-safe. +/// **Do not hand them across the boundaries of different concurrency domains!** +/// Ensure that this representation is the only one passed around and always completely abstract Realm to upper layer calling code. +/// +public struct SendableItemMetadata: ItemMetadata, Sendable { + public var ocId: String + public var account: String + public var checksums: String + public var chunkUploadId: String? + public var classFile: String + public var commentsUnread: Bool + public var contentType: String + public var creationDate: Date + public var dataFingerprint: String + public var date: Date + public var syncTime: Date + public var deleted: Bool + public var directory: Bool + public var downloadURL: String + public var e2eEncrypted: Bool + public var etag: String + public var favorite: Bool + public var fileId: String + public var fileName: String + public var fileNameView: String + public var hasPreview: Bool + public var hidden: Bool + public var iconName: String + public var iconUrl: String + public var isLockFileOfLocalOrigin: Bool + public var mountType: String + public var name: String + public var note: String + public var ownerId: String + public var ownerDisplayName: String + public var livePhotoFile: String? + public var lock: Bool + public var lockOwner: String? + public var lockOwnerEditor: String? + public var lockOwnerType: Int? + public var lockOwnerDisplayName: String? + public var lockTime: Date? + public var lockTimeOut: Date? + public var lockToken: String? + public var path: String + public var permissions: String + public var quotaUsedBytes: Int64 + public var quotaAvailableBytes: Int64 + public var resourceType: String + public var richWorkspace: String? + public var serverUrl: String + public var session: String? + public var sessionError: String? + public var sessionTaskIdentifier: Int? + public var sharePermissionsCollaborationServices: Int + public var sharePermissionsCloudMesh: [String] + public var shareType: [Int] + public var size: Int64 + public var status: Int + public var tags: [String] + public var downloaded: Bool + public var uploaded: Bool + public var keepDownloaded: Bool + public var visitedDirectory: Bool + public var trashbinFileName: String + public var trashbinOriginalLocation: String + public var trashbinDeletionTime: Date + public var uploadDate: Date + public var urlBase: String + public var user: String + public var userId: String + + public init( + ocId: String, + account: String, + checksums: String = "", + chunkUploadId: String? = nil, + classFile: String, + commentsUnread: Bool = false, + contentType: String, + creationDate: Date, + dataFingerprint: String = "", + date: Date = Date(), + syncTime: Date = Date(), + deleted: Bool = false, + directory: Bool, + downloadURL: String = "", + e2eEncrypted: Bool, + etag: String, + favorite: Bool = false, + fileId: String, + fileName: String, + fileNameView: String, + hasPreview: Bool = false, + hidden: Bool = false, + iconName: String = "", + iconUrl: String = "", + isLockfileOfLocalOrigin: Bool = false, + livePhotoFile: String? = nil, + mountType: String = "", + name: String = "", + note: String = "", + ownerId: String, + ownerDisplayName: String, + lock: Bool = false, + lockOwner: String? = nil, + lockOwnerEditor: String? = nil, + lockOwnerType: Int? = nil, + lockOwnerDisplayName: String? = nil, + lockTime: Date? = nil, + lockTimeOut: Date? = nil, + lockToken: String? = nil, + path: String, + permissions: String = "RGDNVW", + quotaUsedBytes: Int64 = 0, + quotaAvailableBytes: Int64 = 0, + resourceType: String = "", + richWorkspace: String? = nil, + serverUrl: String, + session: String? = nil, + sessionError: String? = nil, + sessionTaskIdentifier: Int? = nil, + sharePermissionsCollaborationServices: Int = 0, + sharePermissionsCloudMesh: [String] = [], + shareType: [Int] = [], + size: Int64, + status: Int = 0, + tags: [String] = [], + downloaded: Bool = false, + uploaded: Bool = false, + keepDownloaded: Bool = false, + visitedDirectory: Bool = false, + trashbinFileName: String = "", + trashbinOriginalLocation: String = "", + trashbinDeletionTime: Date = Date(), + uploadDate: Date = Date(), + urlBase: String, + user: String, + userId: String + ) { + self.ocId = ocId + self.account = account + self.checksums = checksums + self.chunkUploadId = chunkUploadId + self.classFile = classFile + self.commentsUnread = commentsUnread + self.contentType = contentType + self.creationDate = creationDate + self.dataFingerprint = dataFingerprint + self.date = date + self.syncTime = syncTime + self.deleted = deleted + self.directory = directory + self.downloadURL = downloadURL + self.e2eEncrypted = e2eEncrypted + self.etag = etag + self.favorite = favorite + self.fileId = fileId + self.fileName = fileName + self.fileNameView = fileNameView + self.hasPreview = hasPreview + self.hidden = hidden + self.iconName = iconName + self.iconUrl = iconUrl + isLockFileOfLocalOrigin = isLockfileOfLocalOrigin + self.livePhotoFile = livePhotoFile + self.mountType = mountType + self.name = name + self.note = note + self.ownerId = ownerId + self.ownerDisplayName = ownerDisplayName + self.lock = lock + self.lockOwner = lockOwner + self.lockOwnerEditor = lockOwnerEditor + self.lockOwnerType = lockOwnerType + self.lockOwnerDisplayName = lockOwnerDisplayName + self.lockTime = lockTime + self.lockTimeOut = lockTimeOut + self.lockToken = lockToken + self.path = path + self.permissions = permissions + self.quotaUsedBytes = quotaUsedBytes + self.quotaAvailableBytes = quotaAvailableBytes + self.resourceType = resourceType + self.richWorkspace = richWorkspace + self.serverUrl = serverUrl + self.session = session + self.sessionError = sessionError + self.sessionTaskIdentifier = sessionTaskIdentifier + self.sharePermissionsCollaborationServices = sharePermissionsCollaborationServices + self.sharePermissionsCloudMesh = sharePermissionsCloudMesh + self.shareType = shareType + self.size = size + self.status = status + self.tags = tags + self.downloaded = downloaded + self.uploaded = uploaded + self.keepDownloaded = keepDownloaded + self.visitedDirectory = visitedDirectory + self.trashbinFileName = trashbinFileName + self.trashbinOriginalLocation = trashbinOriginalLocation + self.trashbinDeletionTime = trashbinDeletionTime + self.uploadDate = uploadDate + self.urlBase = urlBase + self.user = user + self.userId = userId + } + + init(value: any ItemMetadata) { + ocId = value.ocId + account = value.account + checksums = value.checksums + chunkUploadId = value.chunkUploadId + classFile = value.classFile + commentsUnread = value.commentsUnread + contentType = value.contentType + creationDate = value.creationDate + dataFingerprint = value.dataFingerprint + date = value.date + syncTime = value.syncTime + deleted = value.deleted + directory = value.directory + downloadURL = value.downloadURL + e2eEncrypted = value.e2eEncrypted + etag = value.etag + favorite = value.favorite + fileId = value.fileId + fileName = value.fileName + fileNameView = value.fileNameView + hasPreview = value.hasPreview + hidden = value.hidden + iconName = value.iconName + iconUrl = value.iconUrl + isLockFileOfLocalOrigin = value.isLockFileOfLocalOrigin + livePhotoFile = value.livePhotoFile + mountType = value.mountType + name = value.name + note = value.note + ownerId = value.ownerId + ownerDisplayName = value.ownerDisplayName + lock = value.lock + lockOwner = value.lockOwner + lockOwnerEditor = value.lockOwnerEditor + lockOwnerType = value.lockOwnerType + lockOwnerDisplayName = value.lockOwnerDisplayName + lockTime = value.lockTime + lockTimeOut = value.lockTimeOut + lockToken = value.lockToken + path = value.path + permissions = value.permissions + quotaUsedBytes = value.quotaUsedBytes + quotaAvailableBytes = value.quotaAvailableBytes + resourceType = value.resourceType + richWorkspace = value.richWorkspace + serverUrl = value.serverUrl + session = value.session + sessionError = value.sessionError + sessionTaskIdentifier = value.sessionTaskIdentifier + sharePermissionsCollaborationServices = value.sharePermissionsCollaborationServices + sharePermissionsCloudMesh = value.sharePermissionsCloudMesh + shareType = value.shareType + size = value.size + status = value.status + downloaded = value.downloaded + uploaded = value.uploaded + keepDownloaded = value.keepDownloaded + visitedDirectory = value.visitedDirectory + tags = value.tags + trashbinFileName = value.trashbinFileName + trashbinOriginalLocation = value.trashbinOriginalLocation + trashbinDeletionTime = value.trashbinDeletionTime + uploadDate = value.uploadDate + urlBase = value.urlBase + user = value.user + userId = value.userId + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift new file mode 100644 index 0000000000000..33ea02bd45197 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +public enum ShareType: Int { + case user = 0 + case group = 1 + case publicLink = 3 + case email = 4 + case federatedCloudShare = 6 + case team = 7 + case talkConversation = 10 +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Account.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Account.swift new file mode 100644 index 0000000000000..6f891465c2a5e --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Account.swift @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +let AccountDictUsernameKey = "usernameKey" +let AccountDictIdKey = "idKey" +let AccountDictPasswordKey = "passwordKey" +let AccountDictNcKitAccountKey = "ncKitAccountKey" +let AccountDictServerUrlKey = "serverUrlKey" +let AccountDictDavFilesUrlKey = "davFilesUrlKey" +let AccountDictTrashUrlKey = "trashUrlKey" +let AccountDictTrashRestoreUrlKey = "trashRestoreUrlKey" +let AccountDictFileNameKey = "fileNameKey" + +/// +/// Ephemeral data model which provides account information associated with a file provider domain. +/// +public struct Account: CustomStringConvertible, Equatable, Sendable { + public static let webDavFilesUrlSuffix = "/remote.php/dav/files/" + public static let webDavTrashUrlSuffix = "/remote.php/dav/trashbin/" + + public let username: String + public let id: String + public let password: String + public let ncKitAccount: String + public let serverUrl: String + public let davFilesUrl: String + public let trashUrl: String + public let trashRestoreUrl: String + public let fileName: String + + public var description: String { + ncKitAccount // Custom textual representation to avoid password leakage. + } + + public static func ncKitAccountString(from username: String, serverUrl: String) -> String { + username + " " + serverUrl + } + + public init(user: String, id: String, serverUrl: String, password: String) { + username = user + self.id = id + self.password = password + ncKitAccount = Self.ncKitAccountString(from: user, serverUrl: serverUrl) + self.serverUrl = serverUrl + davFilesUrl = serverUrl + Self.webDavFilesUrlSuffix + id + trashUrl = serverUrl + Self.webDavTrashUrlSuffix + id + "/trash" + trashRestoreUrl = serverUrl + Self.webDavTrashUrlSuffix + id + "/restore" + + let sanitisedUrl = (URL(string: serverUrl)?.safeFilenameFromURLString() ?? "unknown") + fileName = sanitise(string: id) + "_" + sanitisedUrl + } + + public init?(dictionary: [String: String]) { + guard let username = dictionary[AccountDictUsernameKey], + let id = dictionary[AccountDictIdKey], + let password = dictionary[AccountDictPasswordKey], + let ncKitAccount = dictionary[AccountDictNcKitAccountKey], + let serverUrl = dictionary[AccountDictServerUrlKey], + let davFilesUrl = dictionary[AccountDictDavFilesUrlKey], + let trashUrl = dictionary[AccountDictTrashUrlKey], + let trashRestoreUrl = dictionary[AccountDictTrashRestoreUrlKey], + let fileName = dictionary[AccountDictFileNameKey] + else { + return nil + } + + self.username = username + self.id = id + self.password = password + self.ncKitAccount = ncKitAccount + self.serverUrl = serverUrl + self.davFilesUrl = davFilesUrl + self.trashUrl = trashUrl + self.trashRestoreUrl = trashRestoreUrl + self.fileName = fileName + } + + public func dictionary() -> [String: String] { + [ + AccountDictUsernameKey: username, + AccountDictIdKey: id, + AccountDictPasswordKey: password, + AccountDictNcKitAccountKey: ncKitAccount, + AccountDictServerUrlKey: serverUrl, + AccountDictDavFilesUrlKey: davFilesUrl, + AccountDictTrashUrlKey: trashUrl, + AccountDictTrashRestoreUrlKey: trashRestoreUrl, + AccountDictFileNameKey: fileName + ] + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift new file mode 100644 index 0000000000000..40611c320717d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation + +public class IgnoredFilesMatcher { + private let regexes: [NSRegularExpression] + + private static func patternToRegex(_ pattern: String, wildcardsMatchSlash: Bool) -> String { + let trimmed = pattern.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { return "a^" } + + var regex = "" + var i = trimmed.startIndex + + let hasSlash = trimmed.contains("/") + + while i < trimmed.endIndex { + let c = trimmed[i] + switch c { + case "*": + let next = trimmed.index(after: i) + if next < trimmed.endIndex, trimmed[next] == "*" { + regex.append(wildcardsMatchSlash ? ".*" : ".*") + i = trimmed.index(after: next) + } else { + regex.append(wildcardsMatchSlash ? ".*" : "[^/]*") + i = next + } + case "?": + regex.append(wildcardsMatchSlash ? "." : "[^/]") + i = trimmed.index(after: i) + case ".", "[", "]", "(", ")", "{", "}", "+", "^", "$", "|", "\\": + regex.append("\\\(c)") + i = trimmed.index(after: i) + default: + regex.append(c) + i = trimmed.index(after: i) + } + } + + return hasSlash ? "^\(regex)$" : "(^|/)" + regex + "$" + } + + public init(ignoreList: [String], wildcardsMatchSlash: Bool = false) { + regexes = ignoreList + .map { Self.patternToRegex($0, wildcardsMatchSlash: wildcardsMatchSlash) } + .compactMap { try? NSRegularExpression(pattern: $0, options: [.caseInsensitive]) } + } + + func isExcluded(_ relativePath: String) -> Bool { + for regex in regexes { + let range = NSRange(location: 0, length: relativePath.utf16.count) + if regex.firstMatch(in: relativePath, options: [], range: range) != nil { + return true + } + } + return false + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift new file mode 100644 index 0000000000000..99904a78023fb --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import OSLog + +/// +/// Determine whether the given filename is a lock file as created by certain applications like Microsoft Office or LibreOffice. +/// +/// - Parameters: +/// - filename: The filename to check. +/// +/// - Returns: `true` if the filename is a lock file, `false` otherwise. +/// +public func isLockFileName(_ filename: String) -> Bool { + // Microsoft Office lock files + filename.hasPrefix("~$") || + // LibreOffice lock files + (filename.hasPrefix(".~lock.") && filename.hasSuffix("#")) +} + +/// +/// Parse the original file name contained in a lock filename. +/// +/// - Example for Microsoft Office: `MyDoc.docx` is extracted from `~$MyDoc.docx`. +/// - Example for LibreOffice: `MyDoc.odt` is extracted from `.~lock.MyDoc.odt#`. +/// - Filename with less than 8 characters like `Test.docx` will result in a lock file named `~$Test.docx`. +/// - Filename with more than 8 characters like `Document.docx` will result in a lock file named `~$cument.docx`. +/// - Filename sandbox-style temporary naming like `Welcome123456.doc.sb-d215eb53-IBAwfU`. +/// +/// - Returns: Either the original file name parsed from the given lock file name or `nil`, if it is not a recognized lock file format. +/// +public func originalFileName(fromLockFileName lockFilename: String, dbManager: FilesDatabaseManager) -> String? { + let logger = FileProviderLogger(category: "LocalFiles", log: dbManager.logger.log) + var targetFileSuffix = lockFilename + + if lockFilename.hasPrefix("~$") { + let index = lockFilename.index(lockFilename.startIndex, offsetBy: 2) + targetFileSuffix = String(lockFilename[index...]) + } + + if lockFilename.hasPrefix(".~lock."), lockFilename.hasSuffix("#") { + let start = lockFilename.index(lockFilename.startIndex, offsetBy: 7) + let end = lockFilename.index(before: lockFilename.endIndex) + targetFileSuffix = String(lockFilename[start ..< end]) + } + + if let sbRange = lockFilename.range(of: ".sb-") { + targetFileSuffix = String(lockFilename[.. = [] + private var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] + + private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] + + func getCapabilities(for account: String) -> (capabilities: Capabilities, retrievedAt: Date)? { + data[account] + } + + func setCapabilities(forAccount account: String, capabilities: Capabilities, retrievedAt: Date = Date()) { + data[account] = (capabilities: capabilities, retrievedAt: retrievedAt) + } + + func setOngoingFetch(forAccount account: String, ongoing: Bool) { + if ongoing { + ongoingFetches.insert(account) + } else { + ongoingFetches.remove(account) + // If there are any continuations waiting for this account, resume them. + if let continuations = ongoingFetchContinuations.removeValue(forKey: account) { + continuations.forEach { $0.resume() } + } + } + } + + func awaitFetchCompletion(forAccount account: String) async { + guard ongoingFetches.contains(account) else { return } + + // If a fetch is ongoing, create a continuation and store it. + await withCheckedContinuation { continuation in + var existingContinuations = ongoingFetchContinuations[account, default: []] + existingContinuations.append(continuation) + ongoingFetchContinuations[account] = existingContinuations + } + } + + func reset() { + ongoingFetches = [] + ongoingFetchContinuations = [:] + data = [:] + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift new file mode 100644 index 0000000000000..e941222f60521 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudKit + +/// +/// Stateless function to fetch thumbnails from the server. +/// +/// > To Do: This needs to become part of the type implementing `NSFileProviderReplicatedExtension` once it is moved from the desktop client into this package. +/// +public func fetchThumbnails( + for itemIdentifiers: [NSFileProviderItemIdentifier], + requestedSize size: CGSize, + account: Account, + usingRemoteInterface remoteInterface: RemoteInterface, + andDatabase dbManager: FilesDatabaseManager, + perThumbnailCompletionHandler: @Sendable @escaping ( + NSFileProviderItemIdentifier, + Data?, + Error? + ) -> Void, + log: any FileProviderLogging, + completionHandler: @Sendable @escaping (Error?) -> Void +) -> Progress { + let logger = FileProviderLogger(category: "fetchThumbnails", log: log) + let progress = Progress(totalUnitCount: Int64(itemIdentifiers.count)) + + @Sendable func finishCurrent() { + progress.completedUnitCount += 1 + + if progress.completedUnitCount == progress.totalUnitCount { + completionHandler(nil) + } + } + + for itemIdentifier in itemIdentifiers { + Task { + guard let item = await Item.storedItem( + identifier: itemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + log: logger.log + ) else { + logger.error("Could not find item, unable to download thumbnail!", [.item: itemIdentifier.rawValue]) + + perThumbnailCompletionHandler( + itemIdentifier, + nil, + NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier) + ) + finishCurrent() + return + } + + let (data, error) = await item.fetchThumbnail(size: size) + perThumbnailCompletionHandler(itemIdentifier, data, error) + finishCurrent() + } + } + + return progress +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Upload.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Upload.swift new file mode 100644 index 0000000000000..bd5d7e657a6b7 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Utilities/Upload.swift @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation +import NextcloudCapabilitiesKit +import NextcloudKit +import RealmSwift + +let defaultFileChunkSize = 104_857_600 // 100 MiB + +func upload( + fileLocatedAt localFilePath: String, + toRemotePath remotePath: String, + usingRemoteInterface remoteInterface: RemoteInterface, + withAccount account: Account, + inChunksSized chunkSize: Int? = nil, + usingChunkUploadId chunkUploadId: String? = UUID().uuidString, + dbManager: FilesDatabaseManager, + creationDate: Date? = nil, + modificationDate: Date? = nil, + options: NKRequestOptions = .init(queue: .global(qos: .utility)), + log: any FileProviderLogging, + requestHandler: @escaping (UploadRequest) -> Void = { _ in }, + taskHandler: @Sendable @escaping (URLSessionTask) -> Void = { _ in }, + progressHandler: @escaping (Progress) -> Void = { _ in }, + chunkUploadCompleteHandler: @escaping (_ fileChunk: RemoteFileChunk) -> Void = { _ in } +) async -> ( + ocId: String?, + chunks: [RemoteFileChunk]?, + etag: String?, + date: Date?, + size: Int64?, + remoteError: NKError +) { + let uploadLogger = FileProviderLogger(category: "upload", log: log) + + let fileSize = + (try? FileManager.default.attributesOfItem(atPath: localFilePath)[.size] as? Int64) ?? 0 + + let chunkSize = await { + if let chunkSize { + uploadLogger.info("Using provided chunkSize: \(chunkSize)") + return chunkSize + } + + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities(account: account, options: options, taskHandler: taskHandler) + + guard error == .success, + let capabilities, + let serverChunkSize = capabilities.files?.chunkedUpload?.maxChunkSize, + serverChunkSize > 0 + else { + uploadLogger.info( + """ + Received nil capabilities data. + Received error: \(error.errorDescription) + Capabilities nil: \(capabilities == nil ? "YES" : "NO") + (if capabilities are not nil the server may just not provide chunk size data). + Using default file chunk size: \(defaultFileChunkSize) + """ + ) + return defaultFileChunkSize + } + uploadLogger.info( + """ + Received file chunk size from server: \(serverChunkSize) + """ + ) + return Int(serverChunkSize) + }() + + guard fileSize > chunkSize else { + let (_, ocId, etag, date, size, _, remoteError) = await remoteInterface.upload( + remotePath: remotePath, + localPath: localFilePath, + creationDate: creationDate, + modificationDate: modificationDate, + account: account, + options: options, + requestHandler: requestHandler, + taskHandler: taskHandler, + progressHandler: progressHandler + ) + + return (ocId, nil, etag, date as? Date, size, remoteError) + } + + let chunkUploadId = chunkUploadId ?? UUID().uuidString + + uploadLogger.info( + """ + Performing chunked upload to \(remotePath) + localFilePath: \(localFilePath) + remoteChunkStoreFolderName: \(chunkUploadId) + chunkSize: \(chunkSize) + """ + ) + + let remainingChunks = dbManager + .ncDatabase() + .objects(RemoteFileChunk.self) + .where { $0.remoteChunkStoreFolderName == chunkUploadId } + .toUnmanagedResults() + + let (_, chunks, file, nkError) = await remoteInterface.chunkedUpload( + localPath: localFilePath, + remotePath: remotePath, + remoteChunkStoreFolderName: chunkUploadId, + chunkSize: chunkSize, + remainingChunks: remainingChunks, + creationDate: creationDate, + modificationDate: modificationDate, + account: account, + options: options, + currentNumChunksUpdateHandler: { _ in }, + chunkCounter: { currentChunk in + uploadLogger.info( + """ + \(localFilePath) current chunk: \(currentChunk) + """ + ) + }, + log: log, + chunkUploadStartHandler: { chunks in + uploadLogger.info("\(localFilePath) chunked upload starting...") + + // Do not add chunks to database if we have done this already + guard remainingChunks.isEmpty else { return } + + let db = dbManager.ncDatabase() + do { + try db.write { db.add(chunks.map { RemoteFileChunk(value: $0) }) } + } catch { + uploadLogger.error("Could not write chunks to db, won't be able to resume upload if transfer stops.") + } + }, + requestHandler: requestHandler, + taskHandler: taskHandler, + progressHandler: progressHandler, + chunkUploadCompleteHandler: { chunk in + uploadLogger.info( + "\(localFilePath) chunk \(chunk.fileName) done" + ) + let db = dbManager.ncDatabase() + do { + try db.write { + db + .objects(RemoteFileChunk.self) + .where { + $0.remoteChunkStoreFolderName == chunkUploadId && + $0.fileName == chunk.fileName + } + .forEach { db.delete($0) } + } + } catch { + uploadLogger.error("Could not delete chunks in db, won't resume upload correctly if transfer stops.", [.error: error]) + } + + chunkUploadCompleteHandler(chunk) + } + ) + + uploadLogger.info("\(localFilePath) successfully uploaded in chunks") + + return (file?.ocId, chunks, file?.etag, file?.date, file?.size, nkError) +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift new file mode 100644 index 0000000000000..65124516fba02 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import NextcloudFileProviderKit +import os + +public actor FileProviderLogMock: FileProviderLogging { + let logger: Logger + + public init() { + logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FileProviderLogMock") + } + + public func write(category _: String, level _: OSLogType, message: String, details _: [FileProviderLogDetailKey: (any Sendable)?]) { + logger.debug("\(message, privacy: .public)") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift new file mode 100644 index 0000000000000..708517fb66c89 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation +import NextcloudCapabilitiesKit +@testable import NextcloudFileProviderKit +import NextcloudKit + +public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { + public typealias FetchCapabilitiesHandler = @Sendable (Account, NKRequestOptions, @Sendable @escaping (URLSessionTask) -> Void) async -> FetchResult + + public let fetchCapabilitiesHandler: FetchCapabilitiesHandler? + + public init(fetchCapabilitiesHandler: FetchCapabilitiesHandler?) { + self.fetchCapabilitiesHandler = fetchCapabilitiesHandler + } + + public func setDelegate(_: any NextcloudKitDelegate) {} + + public func createFolder( + remotePath _: String, + account _: Account, + options _: NKRequestOptions, + taskHandler _: @escaping (URLSessionTask) -> Void + ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) { + ("", nil, nil, .invalidResponseError) + } + + public func upload( + remotePath _: String, + localPath _: String, + creationDate _: Date?, + modificationDate _: Date?, + account _: Account, + options _: NKRequestOptions, + requestHandler _: @escaping (UploadRequest) -> Void, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void, + progressHandler _: @escaping (Progress) -> Void + ) async -> ( + account: String, + ocId: String?, + etag: String?, + date: NSDate?, + size: Int64, + response: HTTPURLResponse?, + remoteError: NKError + ) { ("", nil, nil, nil, 0, nil, .invalidResponseError) } + + public func chunkedUpload( + localPath _: String, + remotePath _: String, + remoteChunkStoreFolderName _: String, + chunkSize _: Int, + remainingChunks _: [RemoteFileChunk], + creationDate _: Date?, + modificationDate _: Date?, + account _: Account, + options _: NKRequestOptions, + currentNumChunksUpdateHandler _: @escaping (Int) -> Void, + chunkCounter _: @escaping (Int) -> Void, + log _: any FileProviderLogging, + chunkUploadStartHandler _: @escaping ([RemoteFileChunk]) -> Void, + requestHandler _: @escaping (UploadRequest) -> Void, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void, + progressHandler _: @escaping (Progress) -> Void, + chunkUploadCompleteHandler _: @escaping (RemoteFileChunk) -> Void + ) async -> ( + account: String, + fileChunks: [RemoteFileChunk]?, + file: NKFile?, + nkError: NKError + ) { + ("", nil, nil, .invalidResponseError) + } + + public func move( + remotePathSource _: String, + remotePathDestination _: String, + overwrite _: Bool, + account _: Account, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + public func downloadAsync( + serverUrlFileName _: Any, + fileNameLocalPath _: String, + account _: String, + options _: NKRequestOptions, + requestHandler _: @escaping (_ request: DownloadRequest) -> Void, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void, + progressHandler _: @escaping (_ progress: Progress) -> Void + ) async -> ( + account: String, + etag: String?, + date: Date?, + length: Int64, + headers: [AnyHashable: any Sendable]?, + afError: AFError?, + nkError: NKError + ) { + ("", nil, nil, 0, nil, nil, .invalidResponseError) + } + + public func enumerate( + remotePath _: String, + depth _: EnumerateDepth, + showHiddenFiles _: Bool, + includeHiddenFiles _: [String], + requestBody _: Data?, + account _: Account, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void + ) async -> (account: String, files: [NKFile], data: AFDataResponse?, error: NKError) { + ("", [], nil, .invalidResponseError) + } + + public func delete( + remotePath _: String, + account _: Account, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + ("", nil, .invalidResponseError) + } + + public func lockUnlockFile(serverUrlFileName _: String, type _: NKLockType?, shouldLock _: Bool, account _: Account, options _: NKRequestOptions, taskHandler _: @Sendable @escaping (URLSessionTask) -> Void) async throws -> NKLock? { + throw NKError.invalidResponseError + } + + public func listingTrashAsync( + filename _: String?, + showHiddenFiles _: Bool, + account _: String, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + items: [NKTrash]?, + responseData: AFDataResponse?, + error: NKError + ) { + ("", [], nil, .invalidResponseError) + } + + public func restoreFromTrash( + filename _: String, + account _: Account, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + public func downloadThumbnail( + url _: URL, + account _: Account, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } + + public func getUserProfileAsync( + account _: String, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + userProfile: NKUserProfile?, + responseData: AFDataResponse?, + error: NKError + ) { + ("", nil, nil, .invalidResponseError) + } + + public func tryAuthenticationAttempt( + account _: Account, options _: NKRequestOptions, taskHandler _: @Sendable @escaping (URLSessionTask) -> Void + ) async -> AuthenticationAttemptResultState { .connectionError } + + public typealias FetchResult = (account: String, capabilities: Capabilities?, data: Data?, error: NKError) + + public func fetchCapabilities( + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> FetchResult { + let ncKitAccount = account.ncKitAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: ncKitAccount, ongoing: true + ) + var response: FetchResult + if let handler = fetchCapabilitiesHandler { + response = await handler(account, options, taskHandler) + if let caps = response.capabilities { + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: ncKitAccount, capabilities: caps, retrievedAt: Date() + ) + } + } else { + print("Error: fetchCapabilitiesHandler not set in TestableRemoteInterface") + response = (account.ncKitAccount, nil, nil, .invalidResponseError) + } + await RetrievedCapabilitiesActor.shared.setOngoingFetch( + forAccount: account.ncKitAccount, ongoing: false + ) + return response + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/Item+Init.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/Item+Init.swift new file mode 100644 index 0000000000000..eb2f15422dd3b --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/Item+Init.swift @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks + +public extension Item { + convenience init( + metadata: SendableItemMetadata, + parentItemIdentifier: NSFileProviderItemIdentifier, + account: Account, + remoteInterface: RemoteInterface, + dbManager: FilesDatabaseManager + ) { + self.init( + metadata: metadata, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/ItemMetadata+Init.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/ItemMetadata+Init.swift new file mode 100644 index 0000000000000..f12ca38526091 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/ItemMetadata+Init.swift @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import NextcloudFileProviderKit + +public extension SendableItemMetadata { + init(ocId: String, fileName: String, account: Account) { + self.init( + ocId: ocId, + account: account.ncKitAccount, + classFile: "", + contentType: "", + creationDate: Date(), + directory: false, + e2eEncrypted: false, + etag: "", + fileId: "", + fileName: fileName, + fileNameView: fileName, + hasPreview: false, + iconName: "", + mountType: "", + name: fileName, + ownerId: "", + ownerDisplayName: "", + path: "", + serverUrl: account.davFilesUrl, + size: 0, + urlBase: account.serverUrl, + user: account.username, + userId: account.id + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeNotificationInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeNotificationInterface.swift new file mode 100644 index 0000000000000..e5c5afa393e55 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeNotificationInterface.swift @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import NextcloudFileProviderKit + +public final class MockChangeNotificationInterface: ChangeNotificationInterface { + public typealias ChangeHandler = @Sendable () -> Void + + let changeHandler: ChangeHandler? + + public init(changeHandler: ChangeHandler? = nil) { + self.changeHandler = changeHandler + } + + public func notifyChange() { + changeHandler?() + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift new file mode 100644 index 0000000000000..3581a95c7d3d0 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation + +public class MockChangeObserver: NSObject, NSFileProviderChangeObserver { + public var changedItems: [any NSFileProviderItemProtocol] = [] + public var deletedItemIdentifiers: [NSFileProviderItemIdentifier] = [] + var error: Error? + var isComplete = false + var enumerator: NSFileProviderEnumerator + + public init(enumerator: NSFileProviderEnumerator) { + self.enumerator = enumerator + } + + public func didUpdate(_ changedItems: [any NSFileProviderItemProtocol]) { + self.changedItems.append(contentsOf: changedItems) + } + + public func didDeleteItems(withIdentifiers deletedItemIdentifiers: [NSFileProviderItemIdentifier]) { + self.deletedItemIdentifiers.append(contentsOf: deletedItemIdentifiers) + } + + public func finishEnumeratingChanges(upTo _: NSFileProviderSyncAnchor, moreComing _: Bool) { + isComplete = true + } + + public func finishEnumeratingWithError(_ error: Error) { + self.error = error + isComplete = true + } + + public func enumerateChanges(from anchor: NSFileProviderSyncAnchor = + .init( + ISO8601DateFormatter() + .string(from: Date(timeIntervalSince1970: 1)) + .data(using: .utf8)! + ) + ) async throws { + enumerator.enumerateChanges?(for: self, from: anchor) + while !isComplete { + try await Task.sleep(nanoseconds: 1_000_000) + } + if let error { + throw error + } + } + + public func reset() { + changedItems = [] + deletedItemIdentifiers = [] + error = nil + isComplete = false + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockEnumerationObserver.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockEnumerationObserver.swift new file mode 100644 index 0000000000000..3f07453f2a733 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockEnumerationObserver.swift @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation + +public class MockEnumerationObserver: NSObject, NSFileProviderEnumerationObserver { + public var items: [NSFileProviderItem] = [] + public var observedPages: [NSFileProviderPage] = [] + public private(set) var error: Error? + public private(set) var page: NSFileProviderPage? + private var isComplete = false + private var currentPageComplete = false + private var enumerator: NSFileProviderEnumerator + + public init(enumerator: NSFileProviderEnumerator) { + self.enumerator = enumerator + } + + public func didEnumerate(_ items: [NSFileProviderItem]) { + self.items.append(contentsOf: items) + } + + public func finishEnumerating(upTo nextPage: NSFileProviderPage?) { + page = nextPage + isComplete = page == nil + currentPageComplete = true + } + + public func finishEnumeratingWithError(_ error: Error) { + self.error = error + isComplete = true + currentPageComplete = true + } + + public func enumerateItems() async throws { + isComplete = false + currentPageComplete = false + observedPages = [] + page = NSFileProviderPage.initialPageSortedByName as NSFileProviderPage + + while let page, !isComplete { + try await enumerateItemsPage(page: page) + observedPages.append(page) + } + } + + public func enumerateItemsPage(page: NSFileProviderPage) async throws { + enumerator.enumerateItems(for: self, startingAt: page) + while !currentPageComplete { + try await Task.sleep(nanoseconds: 1_000_000) + } + if let error { + throw error + } + currentPageComplete = false + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockEnumerator.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockEnumerator.swift new file mode 100644 index 0000000000000..67aadd9becc0a --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockEnumerator.swift @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks + +public class MockEnumerator: NSObject, NSFileProviderEnumerator { + let account: Account + let dbManager: FilesDatabaseManager + let remoteInterface: MockRemoteInterface + public var enumeratorItems: [SendableItemMetadata] = [] + + public init( + account: Account, dbManager: FilesDatabaseManager, remoteInterface: MockRemoteInterface + ) { + self.account = account + self.dbManager = dbManager + self.remoteInterface = remoteInterface + } + + public func enumerateItems( + for observer: any NSFileProviderEnumerationObserver, startingAt _: NSFileProviderPage + ) { + let remoteSupportsTrash = remoteInterface.directMockCapabilities()?.files?.undelete ?? false + var items: [Item] = [] + for item in enumeratorItems { + guard let parentItemIdentifier = dbManager.parentItemIdentifierFromMetadata(item) else { + print("Could not get parent item identifier for \(item)") + continue + } + let item = Item( + metadata: item, + parentItemIdentifier: parentItemIdentifier, + account: account, + remoteInterface: remoteInterface, + dbManager: dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: FileProviderLogMock() + ) + items.append(item) + } + observer.didEnumerate(items) + observer.finishEnumerating(upTo: nil) + } + + public func invalidate() { + print("MockEnumerator invalidated") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockNotifyPushServer.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockNotifyPushServer.swift new file mode 100644 index 0000000000000..bf17ffa30bd0d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockNotifyPushServer.swift @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOWebSocket + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +public class MockNotifyPushServer: @unchecked Sendable { + /// The server's host. + private let host: String + /// The server's port. + private let port: Int + /// The server's event loop group. + private let eventLoopGroup: MultiThreadedEventLoopGroup + + private let username: String + private let password: String + private var usernameReceived = false + private var passwordReceived = false + private var connectedClients: [NIOAsyncChannel] = [] + public var delay: Int? + public var refuse = false + public var pingHandler: (() -> Void)? + + enum UpgradeResult { + case websocket(NIOAsyncChannel) + case notUpgraded(NIOAsyncChannel>) + } + + public init( + host: String, + port: Int, + username: String, + password: String, + eventLoopGroup: MultiThreadedEventLoopGroup + ) { + self.host = host + self.port = port + self.eventLoopGroup = eventLoopGroup + self.username = username + self.password = password + } + + public func resetCredentialsState() { + usernameReceived = false + passwordReceived = false + } + + public func reset() { + resetCredentialsState() + delay = nil + refuse = false + connectedClients = [] + pingHandler = nil + } + + /// This method starts the server and handles incoming connections. + public func run() async throws { + let channel: NIOAsyncChannel, Never> = try await ServerBootstrap(group: eventLoopGroup) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .bind(host: host, port: port) { channel in + channel.eventLoop.makeCompletedFuture { + let upgrader = NIOTypedWebSocketServerUpgrader( + shouldUpgrade: { channel, _ in + if self.refuse { + print("Refusing connection upgrade \(channel.localAddress?.description ?? "")") + return channel.eventLoop.makeSucceededFuture(nil) + } else { + return channel.eventLoop.makeSucceededFuture(HTTPHeaders()) + } + }, + upgradePipelineHandler: { channel, _ in + channel.eventLoop.makeCompletedFuture { + let asyncChannel = try NIOAsyncChannel(wrappingChannelSynchronously: channel) + return UpgradeResult.websocket(asyncChannel) + } + } + ) + + let serverUpgradeConfiguration = NIOTypedHTTPServerUpgradeConfiguration( + upgraders: [upgrader], + notUpgradingCompletionHandler: { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTPByteBufferResponsePartHandler()) + let asyncChannel = try NIOAsyncChannel>(wrappingChannelSynchronously: channel) + return UpgradeResult.notUpgraded(asyncChannel) + } + } + ) + + let negotiationResultFuture = try channel.pipeline.syncOperations.configureUpgradableHTTPServerPipeline( + configuration: .init(upgradeConfiguration: serverUpgradeConfiguration) + ) + + return negotiationResultFuture + } + } + + // We are handling each incoming connection in a separate child task. It is important + // to use a discarding task group here which automatically discards finished child tasks. + // A normal task group retains all child tasks and their outputs in memory until they are + // consumed by iterating the group or by exiting the group. Since, we are never consuming + // the results of the group we need the group to automatically discard them; otherwise, this + // would result in a memory leak over time. + try await withThrowingDiscardingTaskGroup { group in + try await channel.executeThenClose { inbound in + for try await upgradeResult in inbound { + group.addTask { + if let delay = self.delay { + try await Task.sleep(nanoseconds: .init(delay)) + } + await self.handleUpgradeResult(upgradeResult) + } + } + } + } + } + + /// This method handles a single connection by echoing back all inbound data. + private func handleUpgradeResult(_ upgradeResult: EventLoopFuture) async { + // Note that this method is non-throwing and we are catching any error. + // We do this since we don't want to tear down the whole server when a single connection + // encounters an error. + do { + switch try await upgradeResult.get() { + case let .websocket(websocketChannel): + print("Handling websocket connection") + connectedClients.append(websocketChannel) + try await handleWebsocketChannel(websocketChannel) + print("Done handling websocket connection") + case .notUpgraded: + print("Done handling HTTP connection") + } + } catch { + print("Hit error: \(error)") + } + } + + private func handleWebsocketChannel(_ channel: NIOAsyncChannel) async throws { + try await channel.executeThenClose { inbound, outbound in + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for try await frame in inbound { + switch frame.opcode { + case .ping: + print("Received ping") + self.pingHandler?() + + var frameData = frame.data + let maskingKey = frame.maskKey + + if let maskingKey { + frameData.webSocketUnmask(maskingKey) + } + + let responseFrame = WebSocketFrame(fin: true, opcode: .pong, data: frameData) + try await outbound.write(responseFrame) + case .connectionClose: + // This is an unsolicited close. We're going to send a response frame and + // then, when we've sent it, close up shop. We should send back the close code the remote + // peer sent us, unless they didn't send one at all. + print("Received close") + var data = frame.unmaskedData + let closeDataCode = data.readSlice(length: 2) ?? ByteBuffer() + let closeFrame = WebSocketFrame(fin: true, opcode: .connectionClose, data: closeDataCode) + try await outbound.write(closeFrame) + return + case .binary, .continuation, .pong: + // We ignore these frames. + break + case .text: + var frameData = frame.unmaskedData + let receivedText = frameData.readString(length: frameData.readableBytes) + print("Received text: \(receivedText ?? "nil")") + print("Username received: \(self.usernameReceived)") + print("Password received: \(self.passwordReceived)") + print("Instance: \(ObjectIdentifier(self))") + + if !self.usernameReceived { + self.usernameReceived = true + } else if !self.passwordReceived { + let matchingPassword = receivedText == self.password + if matchingPassword { + print("Correct auth") + self.passwordReceived = true + var buffer = channel.channel.allocator.buffer(capacity: 16) + buffer.writeString("authenticated") + let frame = WebSocketFrame(fin: true, opcode: .text, data: buffer) + try await outbound.write(frame) + } else { + print("Incorrect auth") + self.usernameReceived = false + var buffer = channel.channel.allocator.buffer(capacity: 32) + buffer.writeString("err: Invalid credentials") + let frame = WebSocketFrame(fin: true, opcode: .text, data: buffer) + try await outbound.write(frame) + } + } + default: + // Unknown frames are errors. + return + } + } + } + + try await group.next() + group.cancelAll() + } + } + } + + public func send(message: String) { + let buffer = ByteBuffer(string: message) + let messageFrame = WebSocketFrame(fin: true, opcode: .text, data: buffer) + + // Send a message to all connected WebSocket clients + for client in connectedClients { + _ = client.channel.write(messageFrame) + } + } + + public func closeConnections() { + for client in connectedClients { + print("Closing connection \(client.channel.localAddress?.description ?? "")") + var buffer = client.channel.allocator.buffer(capacity: 2) + buffer.write(webSocketErrorCode: .normalClosure) + + let closeFrame = WebSocketFrame(fin: true, opcode: .connectionClose, data: buffer) + client.channel.writeAndFlush(closeFrame).whenComplete { _ in + client.channel.close(mode: .all).whenComplete { _ in + print("Channel closed.") + } + } + } + connectedClients = [] + } +} + +final class HTTPByteBufferResponsePartHandler: ChannelOutboundHandler { + typealias OutboundIn = HTTPPart + typealias OutboundOut = HTTPServerResponsePart + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let part = unwrapOutboundIn(data) + switch part { + case let .head(head): + context.write(wrapOutboundOut(.head(head)), promise: promise) + case let .body(buffer): + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: promise) + case let .end(trailers): + context.write(wrapOutboundOut(.end(trailers)), promise: promise) + } + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteInterface.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteInterface.swift new file mode 100644 index 0000000000000..d93f0e19927ce --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteInterface.swift @@ -0,0 +1,1296 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation +import NextcloudCapabilitiesKit +import NextcloudFileProviderKit +import NextcloudKit + +let mockCapabilities = ##""" +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK", + "totalitems": "", + "itemsperpage": "" + }, + "data": { + "version": { + "major": 28, + "minor": 0, + "micro": 4, + "string": "28.0.4", + "edition": "", + "extendedSupport": false + }, + "capabilities": { + "core": { + "pollinterval": 60, + "webdav-root": "remote.php/webdav", + "reference-api": true, + "reference-regex": "(\\s|\\n|^)(https?:\\/\\/)((?:[-A-Z0-9+_]+\\.)+[-A-Z]+(?:\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)" + }, + "bruteforce": { + "delay": 0, + "allow-listed": false + }, + "files": { + "bigfilechunking": true, + "blacklisted_files": [ + ".htaccess" + ], + "chunked_upload": { + "max_size": 367001600, + "max_parallel_count": 5 + }, + "directEditing": { + "url": "https://mock.nc.com/ocs/v2.php/apps/files/api/v1/directEditing", + "etag": "c748e8fc588b54fc5af38c4481a19d20", + "supportsFileId": true + }, + "locking": "1.0", + "comments": true, + "undelete": true, + "versioning": true, + "version_labeling": true, + "version_deletion": true + }, + "activity": { + "apiv2": [ + "filters", + "filters-api", + "previews", + "rich-strings" + ] + }, + "circles": { + "version": "28.0.0", + "status": { + "globalScale": false + }, + "settings": { + "frontendEnabled": true, + "allowedCircles": 262143, + "allowedUserTypes": 31, + "membersLimit": -1 + }, + "circle": { + "constants": { + "flags": { + "1": "Single", + "2": "Personal", + "4": "System", + "8": "Visible", + "16": "Open", + "32": "Invite", + "64": "Join Request", + "128": "Friends", + "256": "Password Protected", + "512": "No Owner", + "1024": "Hidden", + "2048": "Backend", + "4096": "Local", + "8192": "Root", + "16384": "Circle Invite", + "32768": "Federated", + "65536": "Mount point" + }, + "source": { + "core": { + "1": "Nextcloud Account", + "2": "Nextcloud Group", + "4": "Email Address", + "8": "Contact", + "16": "Circle", + "10000": "Nextcloud App" + }, + "extra": { + "10001": "Circles App", + "10002": "Admin Command Line" + } + } + }, + "config": { + "coreFlags": [ + 1, + 2, + 4 + ], + "systemFlags": [ + 512, + 1024, + 2048 + ] + } + }, + "member": { + "constants": { + "level": { + "1": "Member", + "4": "Moderator", + "8": "Admin", + "9": "Owner" + } + }, + "type": { + "0": "single", + "1": "user", + "2": "group", + "4": "mail", + "8": "contact", + "16": "circle", + "10000": "app" + } + } + }, + "ocm": { + "enabled": true, + "apiVersion": "1.0-proposal1", + "endPoint": "https://mock.nc.com/ocm", + "resourceTypes": [ + { + "name": "file", + "shareTypes": [ + "user", + "group" + ], + "protocols": { + "webdav": "/public.php/webdav/" + } + } + ] + }, + "dav": { + "chunking": "1.0", + "bulkupload": "1.0" + }, + "deck": { + "version": "1.12.2", + "canCreateBoards": true, + "apiVersions": [ + "1.0", + "1.1" + ] + }, + "files_sharing": { + "api_enabled": true, + "public": { + "enabled": true, + "password": { + "enforced": false, + "askForOptionalPassword": false + }, + "expire_date": { + "enabled": true, + "days": 7, + "enforced": true + }, + "multiple_links": true, + "expire_date_internal": { + "enabled": false + }, + "expire_date_remote": { + "enabled": false + }, + "send_mail": false, + "upload": true, + "upload_files_drop": true + }, + "resharing": true, + "user": { + "send_mail": false, + "expire_date": { + "enabled": true + } + }, + "group_sharing": true, + "group": { + "enabled": true, + "expire_date": { + "enabled": true + } + }, + "default_permissions": 31, + "federation": { + "outgoing": true, + "incoming": true, + "expire_date": { + "enabled": true + }, + "expire_date_supported": { + "enabled": true + } + }, + "sharee": { + "query_lookup_default": false, + "always_show_unique": true + }, + "sharebymail": { + "enabled": true, + "send_password_by_mail": true, + "upload_files_drop": { + "enabled": true + }, + "password": { + "enabled": true, + "enforced": false + }, + "expire_date": { + "enabled": true, + "enforced": true + } + } + }, + "fulltextsearch": { + "remote": true, + "providers": [ + { + "id": "deck", + "name": "Deck" + }, + { + "id": "files", + "name": "Files" + } + ] + }, + "notes": { + "api_version": [ + "0.2", + "1.3" + ], + "version": "4.9.4" + }, + "notifications": { + "ocs-endpoints": [ + "list", + "get", + "delete", + "delete-all", + "icons", + "rich-strings", + "action-web", + "user-status", + "exists" + ], + "push": [ + "devices", + "object-data", + "delete" + ], + "admin-notifications": [ + "ocs", + "cli" + ] + }, + "notify_push": { + "type": [ + "files", + "activities", + "notifications" + ], + "endpoints": { + "websocket": "wss://mock.nc.com/push/ws", + "pre_auth": "https://mock.nc.com/apps/notify_push/pre_auth" + } + }, + "password_policy": { + "minLength": 10, + "enforceNonCommonPassword": true, + "enforceNumericCharacters": false, + "enforceSpecialCharacters": false, + "enforceUpperLowerCase": false, + "api": { + "generate": "https://mock.nc.com/ocs/v2.php/apps/password_policy/api/v1/generate", + "validate": "https://mock.nc.com/ocs/v2.php/apps/password_policy/api/v1/validate" + } + }, + "provisioning_api": { + "version": "1.18.0", + "AccountPropertyScopesVersion": 2, + "AccountPropertyScopesFederatedEnabled": true, + "AccountPropertyScopesPublishedEnabled": true + }, + "richdocuments": { + "version": "8.3.4", + "mimetypes": [ + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.graphics", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.text-flat-xml", + "application/vnd.oasis.opendocument.spreadsheet-flat-xml", + "application/vnd.oasis.opendocument.graphics-flat-xml", + "application/vnd.oasis.opendocument.presentation-flat-xml", + "application/vnd.lotus-wordpro", + "application/vnd.visio", + "application/vnd.ms-visio.drawing", + "application/vnd.wordperfect", + "application/rtf", + "text/rtf", + "application/msonenote", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.ms-word.document.macroEnabled.12", + "application/vnd.ms-word.template.macroEnabled.12", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.ms-excel.sheet.macroEnabled.12", + "application/vnd.ms-excel.template.macroEnabled.12", + "application/vnd.ms-excel.addin.macroEnabled.12", + "application/vnd.ms-excel.sheet.binary.macroEnabled.12", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.ms-powerpoint.addin.macroEnabled.12", + "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + "application/vnd.ms-powerpoint.template.macroEnabled.12", + "application/vnd.ms-powerpoint.slideshow.macroEnabled.12", + "text/csv" + ], + "mimetypesNoDefaultOpen": [ + "image/svg+xml", + "application/pdf", + "text/plain", + "text/spreadsheet" + ], + "mimetypesSecureView": [], + "collabora": { + "convert-to": { + "available": true, + "endpoint": "/cool/convert-to" + }, + "hasMobileSupport": true, + "hasProxyPrefix": false, + "hasTemplateSaveAs": false, + "hasTemplateSource": true, + "hasWASMSupport": false, + "hasZoteroSupport": true, + "productName": "Collabora Online Development Edition", + "productVersion": "23.05.10.1", + "productVersionHash": "baa6eef", + "serverId": "8bee4df3" + }, + "direct_editing": true, + "templates": true, + "productName": "Nextcloud Office", + "editonline_endpoint": "https://mock.nc.com/apps/richdocuments/editonline", + "config": { + "wopi_url": "https://mock.nc.com/", + "public_wopi_url": "https://mock.nc.com", + "wopi_callback_url": "", + "disable_certificate_verification": null, + "edit_groups": null, + "use_groups": null, + "doc_format": null, + "timeout": 15 + } + }, + "spreed": { + "features": [ + "audio", + "video", + "chat-v2", + "conversation-v4", + "guest-signaling", + "empty-group-room", + "guest-display-names", + "multi-room-users", + "favorites", + "last-room-activity", + "no-ping", + "system-messages", + "delete-messages", + "mention-flag", + "in-call-flags", + "conversation-call-flags", + "notification-levels", + "invite-groups-and-mails", + "locked-one-to-one-rooms", + "read-only-rooms", + "listable-rooms", + "chat-read-marker", + "chat-unread", + "webinary-lobby", + "start-call-flag", + "chat-replies", + "circles-support", + "force-mute", + "sip-support", + "sip-support-nopin", + "chat-read-status", + "phonebook-search", + "raise-hand", + "room-description", + "rich-object-sharing", + "temp-user-avatar-api", + "geo-location-sharing", + "voice-message-sharing", + "signaling-v3", + "publishing-permissions", + "clear-history", + "direct-mention-flag", + "notification-calls", + "conversation-permissions", + "rich-object-list-media", + "rich-object-delete", + "unified-search", + "chat-permission", + "silent-send", + "silent-call", + "send-call-notification", + "talk-polls", + "breakout-rooms-v1", + "recording-v1", + "avatar", + "chat-get-context", + "single-conversation-status", + "chat-keep-notifications", + "typing-privacy", + "remind-me-later", + "bots-v1", + "markdown-messages", + "media-caption", + "session-state", + "note-to-self", + "recording-consent", + "sip-support-dialout", + "message-expiration", + "reactions", + "chat-reference-id" + ], + "config": { + "attachments": { + "allowed": true, + "folder": "/Talk" + }, + "call": { + "enabled": true, + "breakout-rooms": true, + "recording": false, + "recording-consent": 0, + "supported-reactions": [ + "❤️", + "🎉", + "👏", + "👍", + "👎", + "😂", + "🤩", + "🤔", + "😲", + "😥" + ], + "sip-enabled": false, + "sip-dialout-enabled": false, + "predefined-backgrounds": [ + "1_office.jpg", + "2_home.jpg", + "3_abstract.jpg", + "4_beach.jpg", + "5_park.jpg", + "6_theater.jpg", + "7_library.jpg", + "8_space_station.jpg" + ], + "can-upload-background": true, + "can-enable-sip": true + }, + "chat": { + "max-length": 32000, + "read-privacy": 0, + "has-translation-providers": false, + "typing-privacy": 0 + }, + "conversations": { + "can-create": true + }, + "previews": { + "max-gif-size": 3145728 + }, + "signaling": { + "session-ping-limit": 200, + "hello-v2-token-key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECOu2NBMo4juGx6hHNIGa550gGaxN\nzqe/TPxsX3QRjCrkyvdQaltjuRt/9PddhpbMxcJSzwVLqZRVHylfllD8pg==\n-----END PUBLIC KEY-----\n" + } + }, + "version": "18.0.7" + }, + "systemtags": { + "enabled": true + }, + "theming": { + "name": "Nextcloud", + "url": "https://nextcloud.com", + "slogan": "a safe home for all your data", + "color": "#6ea68f", + "color-text": "#000000", + "color-element": "#6ea68f", + "color-element-bright": "#6ea68f", + "color-element-dark": "#6ea68f", + "logo": "https://mock.nc.com/core/img/logo/logo.svg?v=1", + "background": "#6ea68f", + "background-plain": true, + "background-default": true, + "logoheader": "https://mock.nc.com/core/img/logo/logo.svg?v=1", + "favicon": "https://mock.nc.com/core/img/logo/logo.svg?v=1" + }, + "user_status": { + "enabled": true, + "restore": true, + "supports_emoji": true + }, + "weather_status": { + "enabled": true + } + } + } + } +} +"""## + +public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { + /// + /// `RemoteInterface` makes it necessary to bypass its API to fully register a mocked account object. + /// + /// Use ``injectMock(_:)`` to add a new one. + /// + public var mockedAccounts = [String: Account]() + + public var capabilities = mockCapabilities + public var rootItem: MockRemoteItem? + public var delegate: (any NextcloudKitDelegate)? + public var rootTrashItem: MockRemoteItem? + public var currentChunks: [String: [RemoteFileChunk]] = [:] + public var completedChunkTransferSize: [String: Int64] = [:] + public var pagination: Bool + public var expectedEnumerationPaginationTokens: [String: String] = [:] + public var forceNextPageOnLastContentPage: Bool = false + + // Handler to track enumerate calls + public var enumerateCallHandler: ((String, EnumerateDepth, Bool, [String], Data?, Account, NKRequestOptions, @escaping (URLSessionTask) -> Void) -> Void)? + + public init( + account: Account, + rootItem: MockRemoteItem? = nil, + rootTrashItem: MockRemoteItem? = nil, + pagination: Bool = false + ) { + mockedAccounts[account.ncKitAccount] = account + self.rootItem = rootItem + self.rootTrashItem = rootTrashItem + self.pagination = pagination + } + + /// + /// Use this to register a mocked account object in ``mockedAccounts`` which otherwise cannot be passed through fully with `RemoteInterface`. + /// + public func injectMock(_ account: Account) { + mockedAccounts[account.ncKitAccount] = account + } + + func sanitisedPath(_ path: String, account: Account) -> String? { + var sanitisedPath = path + var filesPath: String + if sanitisedPath.hasPrefix(account.davFilesUrl) { + filesPath = account.davFilesUrl + } else if sanitisedPath.hasPrefix(account.trashUrl) { + filesPath = account.trashUrl + } else { + print("Invalid files path! Cannot create sanitised path for \(path)") + return nil + } + + if sanitisedPath.hasPrefix(filesPath) { + // Keep the leading slash for root path + let trimCount = filesPath.last == "/" ? filesPath.count - 1 : filesPath.count + sanitisedPath = String(sanitisedPath.dropFirst(trimCount)) + } else { + print("WARNING: Unexpected files path! \(filesPath)") + } + + if sanitisedPath != "/", sanitisedPath.last == "/" { + sanitisedPath = String(sanitisedPath.dropLast()) + } + if sanitisedPath.isEmpty { + sanitisedPath = "/" + } + return sanitisedPath + } + + func item(remotePath: String, account: String) -> MockRemoteItem? { + guard let rootItem, !remotePath.isEmpty else { + print("Invalid root item or remote path, cannot get item in item tree.") + return nil + } + + guard let account = mockedAccounts[account] else { + return nil + } + + let sanitisedPath = sanitisedPath(remotePath, account: account) + + guard sanitisedPath != "/" else { + return remotePath.hasPrefix(account.trashUrl) ? rootTrashItem : rootItem + } + + var pathComponents = sanitisedPath?.components(separatedBy: "/") + if pathComponents?.first?.isEmpty == true { pathComponents?.removeFirst() } + var currentNode = remotePath.hasPrefix(account.trashUrl) ? rootTrashItem : rootItem + + while pathComponents?.isEmpty == false { + let component = pathComponents?.removeFirst() + + guard component?.isEmpty == false, let nextNode = currentNode?.children.first(where: { $0.name == component }) else { + return nil + } + + guard pathComponents?.isEmpty == false else { + return nextNode // This is the target + } + + currentNode = nextNode + } + + return nil + } + + func parentPath(path: String, account: Account) -> String { + let sanitisedPath = sanitisedPath(path, account: account) + var pathComponents = sanitisedPath?.components(separatedBy: "/") + if pathComponents?.first?.isEmpty == true { pathComponents?.removeFirst() } + guard pathComponents?.isEmpty == false else { return "/" } + pathComponents?.removeLast() + let rootPath = path.hasPrefix(account.trashUrl) ? account.trashUrl : account.davFilesUrl + return rootPath + "/" + (pathComponents?.joined(separator: "/") ?? "") + } + + func parentItem(path: String, account: Account) -> MockRemoteItem? { + let parentRemotePath = parentPath(path: path, account: account) + return item(remotePath: parentRemotePath, account: account.ncKitAccount) + } + + func randomIdentifier() -> String { + UUID().uuidString + } + + func name(from path: String) throws -> String { + guard !path.isEmpty else { throw URLError(.badURL) } + + let sanitisedPath = path.last == "/" ? String(path.dropLast()) : path + let splitPath = sanitisedPath.split(separator: "/") + let name = String(splitPath.last!) + guard !name.isEmpty else { throw URLError(.badURL) } + + return name + } + + public func setDelegate(_ delegate: any NextcloudKitDelegate) { + self.delegate = delegate + } + + public func createFolder( + remotePath: String, + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) { + var itemName: String + do { + itemName = try name(from: remotePath) + } catch { + return (account.ncKitAccount, nil, nil, .urlError) + } + + let item = MockRemoteItem( + identifier: randomIdentifier(), + name: itemName, + remotePath: remotePath, + directory: true, + account: account.ncKitAccount, + username: account.username, + userId: account.id, + serverUrl: account.serverUrl + ) + guard let parent = parentItem(path: remotePath, account: account) else { + return (account.ncKitAccount, nil, nil, .urlError) + } + + parent.children.append(item) + item.parent = parent + return (account.ncKitAccount, item.identifier, item.creationDate as NSDate, .success) + } + + public func upload( + remotePath: String, + localPath: String, + creationDate: Date? = .init(), + modificationDate: Date? = .init(), + account: Account, + options _: NKRequestOptions = .init(), + requestHandler _: @escaping (Alamofire.UploadRequest) -> Void = { _ in }, + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in }, + progressHandler _: @escaping (Progress) -> Void = { _ in } + ) async -> ( + account: String, + ocId: String?, + etag: String?, + date: NSDate?, + size: Int64, + response: HTTPURLResponse?, + remoteError: NKError + ) { + var itemName: String + do { + itemName = try name(from: remotePath) + debugPrint("Handling item upload:", itemName) + } catch { + return (account.ncKitAccount, nil, nil, nil, 0, nil, .urlError) + } + + let itemLocalUrl = URL(fileURLWithPath: localPath) + var itemData: Data + do { + itemData = try Data(contentsOf: itemLocalUrl) + debugPrint("Acquired data:", itemData) + } catch { + return (account.ncKitAccount, nil, nil, nil, 0, nil, .urlError) + } + + guard let parent = parentItem(path: remotePath, account: account) else { + return (account.ncKitAccount, nil, nil, nil, 0, nil, .urlError) + } + debugPrint("Parent is:", parent.remotePath) + + var item: MockRemoteItem + if let existingItem = parent.children.first(where: { $0.remotePath == remotePath }) { + item = existingItem + item.data = itemData + item.modificationDate = modificationDate ?? .init() + print("Updated item \(item.name)") + } else { + item = MockRemoteItem( + identifier: randomIdentifier(), + name: itemName, + remotePath: remotePath, + creationDate: creationDate ?? .init(), + modificationDate: modificationDate ?? .init(), + data: itemData, + account: account.ncKitAccount, + username: account.username, + userId: account.id, + serverUrl: account.serverUrl + ) + + parent.children.append(item) + item.parent = parent + print("Created item \(item.name)") + } + + return ( + account.ncKitAccount, + item.identifier, + item.versionIdentifier, + item.modificationDate as NSDate, + item.size, + nil, + .success + ) + } + + public func chunkedUpload( + localPath: String, + remotePath: String, + remoteChunkStoreFolderName: String, + chunkSize: Int, + remainingChunks: [RemoteFileChunk], + creationDate: Date?, + modificationDate: Date?, + account: Account, + options: NKRequestOptions, + currentNumChunksUpdateHandler _: @escaping (Int) -> Void = { _ in }, + chunkCounter _: @escaping (Int) -> Void = { _ in }, + log _: any FileProviderLogging, + chunkUploadStartHandler: @escaping ([RemoteFileChunk]) -> Void = { _ in }, + requestHandler: @escaping (UploadRequest) -> Void = { _ in }, + taskHandler: @escaping (URLSessionTask) -> Void = { _ in }, + progressHandler: @escaping (Progress) -> Void = { _ in }, + chunkUploadCompleteHandler: @escaping (RemoteFileChunk) -> Void = { _ in } + ) async -> ( + account: String, + fileChunks: [RemoteFileChunk]?, + file: NKFile?, + nkError: NKError + ) { + guard let remoteUrl = URL(string: remotePath) else { + print("Invalid remote path!") + return ("", nil, nil, .urlError) + } + + // Create temp directory for file and create chunks within it + let fm = FileManager.default + let tempDirectoryUrl = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try! fm.createDirectory(atPath: tempDirectoryUrl.path, withIntermediateDirectories: true) + + // Access local file and gather metadata + let fileSize = try! fm.attributesOfItem(atPath: localPath)[.size] as! Int + + var remainingFileSize = fileSize + let numChunks = Int(ceil(Double(fileSize) / Double(chunkSize))) + let newChunks = !remainingChunks.isEmpty + ? remainingChunks + : (0 ..< numChunks).map { chunkIndex in + defer { remainingFileSize -= chunkSize } + return RemoteFileChunk( + fileName: String(chunkIndex + 1), + size: Int64(min(chunkSize, remainingFileSize)), + remoteChunkStoreFolderName: remoteChunkStoreFolderName + ) + } + let preexistingChunks = currentChunks[remoteChunkStoreFolderName] ?? [] + let totalChunks = preexistingChunks + newChunks + currentChunks[remoteChunkStoreFolderName] = totalChunks + chunkUploadStartHandler(newChunks) + + let (_, ocId, etag, date, size, _, remoteError) = await upload( + remotePath: remotePath, + localPath: localPath, + creationDate: creationDate, + modificationDate: modificationDate, + account: account, + options: options, + requestHandler: requestHandler, + taskHandler: taskHandler, + progressHandler: progressHandler + ) + newChunks.forEach { chunkUploadCompleteHandler($0) } + print(remainingChunks) + completedChunkTransferSize[remoteChunkStoreFolderName] = + remainingChunks.reduce(0) { $0 + $1.size } + + var file = NKFile() + file.fileName = remoteUrl.lastPathComponent + file.etag = etag ?? "" + file.size = size + file.path = remotePath + file.ocId = ocId ?? "" + file.serverUrl = remoteUrl.deletingLastPathComponent().absoluteString + file.urlBase = account.serverUrl + file.user = account.username + file.userId = account.id + file.account = account.ncKitAccount + file.creationDate = creationDate ?? Date() + file.date = date as? Date ?? Date() + + return (account.ncKitAccount, totalChunks, file, remoteError) + } + + public func move( + remotePathSource: String, + remotePathDestination: String, + overwrite: Bool = false, + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, data: Data?, error: NKError) { + print("Moving \(remotePathSource) to \(remotePathDestination)") + + let isTrashed = remotePathSource.hasPrefix(account.trashUrl) + let isTrashing = remotePathDestination.hasPrefix(account.trashUrl) + let isRestoreFromTrash = remotePathDestination.hasPrefix(account.trashRestoreUrl) + + guard !isTrashed || isRestoreFromTrash || isTrashing else { + print("Illegal moving of trash item into non-restore path \(remotePathDestination)") + return (account.ncKitAccount, nil, .urlError) + } + + guard !isRestoreFromTrash || overwrite else { + print("Cannot restore from trash without overwriting!") + return (account.ncKitAccount, nil, .urlError) + } + + guard let sourceItem = item(remotePath: remotePathSource, account: account.ncKitAccount) else { + print("Could not get item for remote path source\(remotePathSource)") + return (account.ncKitAccount, nil, .urlError) + } + + guard !isRestoreFromTrash || sourceItem.trashbinOriginalLocation != nil else { + print("Cannot restore item from trash, sourceItem has no trashbin original location") + return (account.ncKitAccount, nil, .urlError) + } + + if isTrashing { + sourceItem.identifier = sourceItem.identifier + trashedItemIdSuffix + } + + sourceItem.name = try! name(from: remotePathDestination) + sourceItem.parent?.children.removeAll(where: { $0.identifier == sourceItem.identifier }) + + // Shadow remotePathDestination and set it to the item's original destination if it is a + // restore from trash operation + let remotePathDestination = isRestoreFromTrash && sourceItem.trashbinOriginalLocation != nil + ? account.davFilesUrl + "/" + sourceItem.trashbinOriginalLocation! + : remotePathDestination + + guard let destinationParent = parentItem(path: remotePathDestination, account: account) else { + print("Failed to find destination parent item!") + return (account.ncKitAccount, nil, .urlError) + } + + if isRestoreFromTrash { + sourceItem.identifier = + sourceItem.identifier.replacingOccurrences(of: trashedItemIdSuffix, with: "") + sourceItem.name = try! name(from: sourceItem.trashbinOriginalLocation!) + sourceItem.trashbinOriginalLocation = nil + } + + let matchingNameChildCount = + destinationParent.children.count(where: { $0.name == sourceItem.name }) + + if !overwrite, matchingNameChildCount > 0 { + sourceItem.name += " (\(matchingNameChildCount))" + print("Found conflicting children, renaming file to \(sourceItem.name)") + } else if overwrite, matchingNameChildCount > 0 { + print("Found conflicting children, removing all due to overwrite.") + destinationParent.children.removeAll(where: { $0.name == sourceItem.name }) + } + + sourceItem.parent = destinationParent + destinationParent.children.append(sourceItem) + + let oldPath = sourceItem.remotePath + sourceItem.remotePath = destinationParent.remotePath + "/" + sourceItem.name + + print("Moved \(sourceItem.name) to \(remotePathDestination) (isTrashed: \(isTrashing))") + + var children = sourceItem.children + + while !children.isEmpty { + var nextChildren = [MockRemoteItem]() + for child in children { + if isTrashing { + if child.remotePath.hasPrefix(account.davFilesUrl) { + child.trashbinOriginalLocation = child.remotePath.replacingOccurrences( + of: account.davFilesUrl + "/", with: "" + ) + child.identifier = child.identifier + trashedItemIdSuffix + } + } else { + child.identifier = + child.identifier.replacingOccurrences(of: trashedItemIdSuffix, with: "") + child.trashbinOriginalLocation = nil + } + + let childNewPath = + child.remotePath.replacingOccurrences(of: oldPath, with: remotePathDestination) + print("Updating child path \(child.remotePath) to \(childNewPath)") + child.remotePath = childNewPath + nextChildren.append(contentsOf: child.children) + } + children = nextChildren + } + + return (account.ncKitAccount, nil, .success) + } + + public func downloadAsync( + serverUrlFileName: Any, + fileNameLocalPath: String, + account: String, + options _: NKRequestOptions, + requestHandler _: @escaping (_ request: DownloadRequest) -> Void = { _ in }, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in }, + progressHandler _: @escaping (_ progress: Progress) -> Void = { _ in } + ) async -> ( + account: String, + etag: String?, + date: Date?, + length: Int64, + headers: [AnyHashable: any Sendable]?, + afError: AFError?, + nkError: NKError + ) { + guard let serverUrlFileName = serverUrlFileName as? String ?? (serverUrlFileName as? URL)?.absoluteString else { + return (account, nil, nil, 0, nil, nil, .urlError) + } + + guard let account = mockedAccounts[account] else { + return (account, nil, nil, 0, nil, nil, .urlError) + } + + guard let item = item(remotePath: serverUrlFileName, account: account.ncKitAccount) else { + return (account.ncKitAccount, nil, nil, 0, nil, nil, .urlError) + } + + let localUrl = URL(fileURLWithPath: fileNameLocalPath) + + do { + if item.directory { + print("Creating directory at \(localUrl) for item \(item.name)") + let fm = FileManager.default + try fm.createDirectory(at: localUrl, withIntermediateDirectories: true) + } else { + print("Writing data to \(localUrl) for item \(item.name)") + try item.data?.write(to: localUrl) + } + } catch { + print("Could not write item data: \(error)") + return (account.ncKitAccount, nil, nil, 0, nil, nil, .urlError) + } + + return ( + account.ncKitAccount, + item.versionIdentifier, + item.creationDate as Date, + item.size, + nil, + nil, + .success + ) + } + + public func enumerate( + remotePath: String, + depth: EnumerateDepth, + showHiddenFiles: Bool = true, + includeHiddenFiles: [String] = [], + requestBody: Data? = nil, + account: Account, + options: NKRequestOptions = .init(), + taskHandler: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, files: [NKFile], data: AFDataResponse?, error: NKError) { + var remotePath = remotePath + + if remotePath.last == "." { + remotePath.removeLast() + } + + if remotePath.last == "/" { + remotePath.removeLast() + } + + print("Enumerating \(remotePath)") + + // Call the enumerate call handler if it exists + enumerateCallHandler?(remotePath, depth, showHiddenFiles, includeHiddenFiles, requestBody, account, options, taskHandler) + + guard let item = item(remotePath: remotePath, account: account.ncKitAccount) else { + print("Item at \(remotePath) not found.") + return ( + account.ncKitAccount, + [], + nil, + NKError(statusCode: 404, fallbackDescription: "File not found") + ) + } + + func generateResponse(itemCount: Int, finalPage: Bool) -> AFDataResponse? { + var responseHeaders: [String: String] = [:] + if pagination, options.paginate { + responseHeaders["X-NC-PAGINATE"] = "true" + if options.paginateToken == nil { + responseHeaders["X-NC-PAGINATE-TOTAL"] = String(itemCount) + } + if finalPage { + expectedEnumerationPaginationTokens.removeValue(forKey: account.ncKitAccount) + } else { + let token = UUID().uuidString + responseHeaders["X-NC-PAGINATE-TOKEN"] = token + expectedEnumerationPaginationTokens[account.ncKitAccount] = token + } + } + + return AFDataResponse( + request: nil, + response: HTTPURLResponse( + url: URL(string: account.davFilesUrl + remotePath)!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: responseHeaders + ), + data: Data(), + metrics: nil, + serializationDuration: 0, + result: .success(Data()) + ) + } + + let itemCount = options.paginateCount ?? .max + let firstItem = options.paginateOffset ?? 0 + + func generateReturn(files: [NKFile]) -> ( + account: String, files: [NKFile], data: AFDataResponse?, error: NKError + ) { + if pagination && + options.paginate && + options.paginateToken != expectedEnumerationPaginationTokens[account.ncKitAccount] + { + return (account.ncKitAccount, [], nil, .invalidData) + } + guard !forceNextPageOnLastContentPage || firstItem < files.count else { + let responseData = generateResponse(itemCount: files.count, finalPage: true) + return (account.ncKitAccount, [], responseData, .success) + } + let reachedEnd = firstItem + itemCount >= files.count + let lastItem = min(firstItem + itemCount, files.count) - 1 + assert(firstItem <= lastItem) + let itemsPage = Array(files[firstItem ... lastItem]) + let responseData = generateResponse(itemCount: files.count, finalPage: reachedEnd) + return (account.ncKitAccount, itemsPage, responseData, .success) + } + + switch depth { + case .target: + let responseData = generateResponse(itemCount: 1, finalPage: true) + return (account.ncKitAccount, [item.toNKFile()], responseData, .success) + case .targetAndDirectChildren: + let files = [item.toNKFile()] + item.children.map { $0.toNKFile() } + return generateReturn(files: files) + case .targetAndAllChildren: + var files = [NKFile]() + var queue = [item] + while !queue.isEmpty { + var nextQueue = [MockRemoteItem]() + for item in queue { + files.append(item.toNKFile()) + nextQueue.append(contentsOf: item.children) + } + queue = nextQueue + } + return generateReturn(files: files) + } + } + + public func delete( + remotePath: String, + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { + guard let item = item(remotePath: remotePath, account: account.ncKitAccount) else { + return (account.ncKitAccount, nil, .urlError) + } + + let relativePath = + item.remotePath.replacingOccurrences(of: account.davFilesUrl + "/", with: "") + item.trashbinOriginalLocation = relativePath + + let (_, _, error) = await move( + remotePathSource: item.remotePath, + remotePathDestination: account.trashUrl + "/" + item.name + " (trashed)", + account: account + ) + guard error == .success else { return (account.ncKitAccount, nil, error) } + + return (account.ncKitAccount, nil, .success) + } + + public func lockUnlockFile(serverUrlFileName: String, type _: NKLockType?, shouldLock: Bool, account: Account, options _: NKRequestOptions, taskHandler _: @escaping (URLSessionTask) -> Void) async throws -> NKLock? { + guard let item = item(remotePath: serverUrlFileName, account: account.ncKitAccount) else { + throw NKError.urlError + } + + item.locked = shouldLock + + return nil + } + + public func listingTrashAsync( + filename _: String?, + showHiddenFiles _: Bool, + account: String, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + items: [NKTrash]?, + responseData: AFDataResponse?, + error: NKError + ) { + guard let rootTrashItem else { + return (account, [], nil, .invalidData) + } + + return (account, rootTrashItem.children.map { $0.toNKTrash() }, nil, .success) + } + + public func restoreFromTrash( + filename: String, + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> (account: String, data: Data?, error: NKError) { + let fileTrashUrl = account.trashUrl + "/" + filename + let fileTrashRestoreUrl = account.trashRestoreUrl + "/" + filename + let (_, data, error) = await move( + remotePathSource: fileTrashUrl, + remotePathDestination: fileTrashRestoreUrl, + overwrite: true, + account: account + ) + guard error == .success else { + return (account.ncKitAccount, data, error) + } + return (account.ncKitAccount, data, .success) + } + + public func downloadThumbnail( + url _: URL, + account: Account, + options _: NKRequestOptions, + taskHandler _: @escaping (URLSessionTask) -> Void + ) async -> (account: String, data: Data?, error: NKError) { + // TODO: Implement downloadThumbnail + (account.ncKitAccount, nil, .success) + } + + public func fetchCapabilities( + account: Account, + options _: NKRequestOptions, + taskHandler _: @escaping (URLSessionTask) -> Void + ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) { + let capsData = capabilities.data(using: .utf8) + return (account.ncKitAccount, directMockCapabilities(), capsData, .success) + } + + public func directMockCapabilities() -> Capabilities? { + let capsData = capabilities.data(using: .utf8) + return Capabilities(data: capsData ?? Data()) + } + + public func getUserProfileAsync( + account: String, + options _: NKRequestOptions, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + userProfile: NKUserProfile?, + responseData: AFDataResponse?, + error: NKError + ) { + guard let account = mockedAccounts[account] else { + return (account, nil, nil, .urlError) + } + + let profile = NKUserProfile() + profile.address = account.serverUrl + profile.backend = "mock" + profile.displayName = account.ncKitAccount + profile.userId = account.id + + return (account.ncKitAccount, profile, nil, .success) + } + + public func tryAuthenticationAttempt( + account: Account, + options _: NKRequestOptions = .init(), + taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } + ) async -> AuthenticationAttemptResultState { + account.password.isEmpty ? .authenticationError : .success + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift new file mode 100644 index 0000000000000..98ff24f11f89d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudFileProviderKit +import NextcloudKit +import UniformTypeIdentifiers + +public let trashedItemIdSuffix = "-trashed-mri" + +public class MockRemoteItem: Equatable { + public var parent: MockRemoteItem? + public var children: [MockRemoteItem] = [] + + public var identifier: String + public var versionIdentifier: String + public var name: String + public var remotePath: String + public let directory: Bool + public let creationDate: Date + public var modificationDate: Date + public var data: Data? + public var locked: Bool + public var lockOwner: String + public var lockTimeOut: Date? + public var size: Int64 { Int64(data?.count ?? 0) } + public var account: String + public var username: String + public var userId: String + public var serverUrl: String + public var trashbinOriginalLocation: String? + + public static func == (lhs: MockRemoteItem, rhs: MockRemoteItem) -> Bool { + lhs.parent == rhs.parent && + lhs.children == rhs.children && + lhs.identifier == rhs.identifier && + lhs.versionIdentifier == rhs.versionIdentifier && + lhs.name == rhs.name && + lhs.directory == rhs.directory && + lhs.locked == rhs.locked && + lhs.lockOwner == rhs.lockOwner && + lhs.lockTimeOut == rhs.lockTimeOut && + lhs.data == rhs.data && + lhs.size == rhs.size && + lhs.creationDate == rhs.creationDate && + lhs.modificationDate == rhs.modificationDate && + lhs.account == rhs.account && + lhs.username == rhs.username && + lhs.userId == rhs.userId && + lhs.serverUrl == rhs.serverUrl && + lhs.trashbinOriginalLocation == rhs.trashbinOriginalLocation + } + + public static func rootItem(account: Account) -> MockRemoteItem { + MockRemoteItem( + identifier: NSFileProviderItemIdentifier.rootContainer.rawValue, + versionIdentifier: "root", + name: NextcloudKit.shared.nkCommonInstance.rootFileName, + remotePath: account.davFilesUrl, + directory: true, + account: account.ncKitAccount, + username: account.username, + userId: account.id, + serverUrl: account.serverUrl + ) + } + + public static func rootTrashItem(account: Account) -> MockRemoteItem { + MockRemoteItem( + identifier: NSFileProviderItemIdentifier.trashContainer.rawValue, + versionIdentifier: "root", + name: "", + remotePath: account.trashUrl, + directory: true, + account: account.ncKitAccount, + username: account.username, + userId: account.id, + serverUrl: account.serverUrl + ) + } + + public init( + identifier: String, + versionIdentifier: String = "0", + name: String, + remotePath: String, + directory: Bool = false, + creationDate: Date = Date(), + modificationDate: Date = Date(), + data: Data? = nil, + locked: Bool = false, + lockOwner: String = "", + lockTimeOut: Date? = nil, + account: String, + username: String, + userId: String, + serverUrl: String, + trashbinOriginalLocation: String? = nil + ) { + self.identifier = identifier + self.versionIdentifier = versionIdentifier + self.name = name + self.remotePath = remotePath + self.directory = directory + self.creationDate = creationDate + self.modificationDate = modificationDate + self.data = data + self.locked = locked + self.lockOwner = lockOwner + self.lockTimeOut = lockTimeOut + self.account = account + self.username = username + self.userId = userId + self.serverUrl = serverUrl + self.trashbinOriginalLocation = trashbinOriginalLocation + } + + public func toNKFile() -> NKFile { + let isRoot = identifier == NSFileProviderItemIdentifier.rootContainer.rawValue + var file = NKFile() + file.fileName = isRoot + ? NextcloudKit.shared.nkCommonInstance.rootFileName + : trashbinOriginalLocation?.split(separator: "/").last?.toString() ?? name + file.size = size + file.date = creationDate + file.directory = isRoot ? false : directory + file.etag = versionIdentifier + file.ocId = identifier + file.fileId = identifier.replacingOccurrences(of: trashedItemIdSuffix, with: "") + file.serverUrl = isRoot ? "\(serverUrl)/remote.php/dav/files/\(userId)" : parent?.remotePath ?? serverUrl + file.account = account + file.user = username + file.userId = userId + file.urlBase = serverUrl + file.lock = locked + file.lockOwner = lockOwner + file.lockTimeOut = lockTimeOut + file.trashbinFileName = name + file.trashbinOriginalLocation = trashbinOriginalLocation ?? "" + return file + } + + public func toNKTrash() -> NKTrash { + var trashItem = NKTrash() + trashItem.ocId = identifier + trashItem.fileId = identifier.replacingOccurrences(of: trashedItemIdSuffix, with: "") + trashItem.fileName = name + trashItem.directory = directory + trashItem.trashbinOriginalLocation = trashbinOriginalLocation ?? "" + trashItem.trashbinFileName = trashbinOriginalLocation?.split(separator: "/").last?.toString() ?? name + trashItem.size = size + trashItem.filePath = (parent?.remotePath ?? "") + "/" + name + return trashItem + } + + public func toItemMetadata(account: Account) -> SendableItemMetadata { + let originalFileName = trashbinOriginalLocation?.split(separator: "/").last?.toString() + let fileName = originalFileName ?? name + let serverUrlTrimCount = name.count + + var trimmedServerUrl = remotePath + if identifier != NSFileProviderItemIdentifier.rootContainer.rawValue { + trimmedServerUrl.removeSubrange( + remotePath.index( + remotePath.endIndex, offsetBy: -(serverUrlTrimCount + 1) // Remove trailing slash + ) ..< remotePath.endIndex + ) + } + + return SendableItemMetadata( + ocId: identifier, + account: account.ncKitAccount, + classFile: directory ? NKTypeClassFile.directory.rawValue : "", + contentType: directory ? UTType.folder.identifier : "", + creationDate: creationDate, // Use provided or fallback to default + date: modificationDate, // Use provided or fallback to default + directory: directory, + e2eEncrypted: false, // Default as not set in original code + etag: versionIdentifier, + fileId: identifier.replacingOccurrences(of: trashedItemIdSuffix, with: ""), + fileName: name, + fileNameView: name, + hasPreview: false, // Default as not set in original code + iconName: "", // Placeholder as not set in original code + mountType: "", // Placeholder as not set in original code + name: name, + ownerId: "", // Placeholder as not set in original code + ownerDisplayName: "", // Placeholder as not set in original code + lock: locked, + lockOwner: lockOwner, + lockOwnerType: lockOwner.isEmpty ? 0 : 1, + lockOwnerDisplayName: lockOwner == account.username ? account.username : "other user", + lockTime: nil, // Default as not set in original code + lockTimeOut: lockTimeOut, + path: "", // Placeholder as not set in original code + serverUrl: trimmedServerUrl, + size: size, + uploaded: true, + trashbinFileName: trashbinOriginalLocation != nil ? fileName : "", + trashbinOriginalLocation: trashbinOriginalLocation ?? "", + urlBase: account.serverUrl, + user: account.username, + userId: account.id + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/InterfaceTests/MockRemoteInterfaceTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/InterfaceTests/MockRemoteInterfaceTests.swift new file mode 100644 index 0000000000000..2a74dc7f7b3f8 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/InterfaceTests/MockRemoteInterfaceTests.swift @@ -0,0 +1,875 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@testable import NextcloudFileProviderKit +@testable import NextcloudFileProviderKitMocks +import NextcloudKit +@testable import TestInterface +import XCTest + +final class MockRemoteInterfaceTests: XCTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + lazy var rootItem = MockRemoteItem.rootItem(account: Self.account) + lazy var rootTrashItem = MockRemoteItem.rootTrashItem(account: Self.account) + + override func tearDown() { + rootItem.children = [] + rootTrashItem.children = [] + } + + func testItemForRemotePath() { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + versionIdentifier: "a", + name: "a", + remotePath: Self.account.davFilesUrl + "/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemB = MockRemoteItem( + identifier: "b", + versionIdentifier: "b", + name: "b", + remotePath: Self.account.davFilesUrl + "/b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemA_B = MockRemoteItem( + identifier: "b", + versionIdentifier: "b", + name: "b", + remotePath: Self.account.davFilesUrl + "/a/b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let targetItem = MockRemoteItem( + identifier: "target", + versionIdentifier: "target", + name: "target", + remotePath: Self.account.davFilesUrl + "/a/b/target", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteInterface.rootItem?.children = [itemA, itemB] + itemA.parent = remoteInterface.rootItem + itemB.parent = remoteInterface.rootItem + itemA.children = [itemA_B] + itemA_B.parent = itemA + itemA_B.children = [targetItem] + targetItem.parent = itemA_B + + XCTAssertEqual( + remoteInterface.item( + remotePath: Self.account.davFilesUrl + "/a/b/target", account: Self.account.ncKitAccount + ), targetItem + ) + } + + func testItemForRootPath() { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + XCTAssertEqual( + remoteInterface.item(remotePath: Self.account.davFilesUrl, account: Self.account.ncKitAccount), rootItem + ) + } + + func testPathParentPath() { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let testPath = Self.account.davFilesUrl + "/a/B/c/d" + let expectedPath = Self.account.davFilesUrl + "/a/B/c" + + XCTAssertEqual( + remoteInterface.parentPath(path: testPath, account: Self.account), expectedPath + ) + } + + func testRootPathParentPath() { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let testPath = Self.account.davFilesUrl + "/" + let expectedPath = Self.account.davFilesUrl + "/" + + XCTAssertEqual( + remoteInterface.parentPath(path: testPath, account: Self.account), expectedPath + ) + } + + func testNameFromPath() throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let testPath = Self.account.davFilesUrl + "/a/b/c/d" + let expectedName = "d" + let name = try remoteInterface.name(from: testPath) + XCTAssertEqual(name, expectedName) + } + + func testCreateFolder() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let newFolderAPath = Self.account.davFilesUrl + "/A" + let newFolderA_BPath = Self.account.davFilesUrl + "/A/B/" + + let resultA = await remoteInterface.createFolder( + remotePath: newFolderAPath, account: Self.account + ) + XCTAssertEqual(resultA.error, .success) + + let resultA_B = await remoteInterface.createFolder( + remotePath: newFolderA_BPath, account: Self.account + ) + XCTAssertEqual(resultA_B.error, .success) + + let itemA = remoteInterface.item(remotePath: newFolderAPath, account: Self.account.ncKitAccount) + XCTAssertNotNil(itemA) + XCTAssertEqual(itemA?.name, "A") + XCTAssertTrue(itemA?.directory ?? false) + + let itemA_B = remoteInterface.item(remotePath: newFolderA_BPath, account: Self.account.ncKitAccount) + XCTAssertNotNil(itemA_B) + XCTAssertEqual(itemA_B?.name, "B") + XCTAssertTrue(itemA_B?.directory ?? false) + } + + func testUpload() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let fileUrl = URL.temporaryDirectory.appendingPathComponent("file.txt", conformingTo: .text) + let fileData = Data("Hello, World!".utf8) + let fileSize = Int64(fileData.count) + try fileData.write(to: fileUrl) + + let result = await remoteInterface.upload( + remotePath: Self.account.davFilesUrl + "/file.txt", localPath: fileUrl.path, account: Self.account + ) + XCTAssertEqual(result.remoteError, .success) + + let remoteItem = remoteInterface.item( + remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account.ncKitAccount + ) + XCTAssertNotNil(remoteItem) + + XCTAssertEqual(remoteItem?.name, "file.txt") + XCTAssertEqual(remoteItem?.size, fileSize) + XCTAssertEqual(remoteItem?.data, fileData) + XCTAssertEqual(result.size, fileSize) + + // TODO: Add test for overwriting existing file + } + + func testUploadTargetName() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let fileName = UUID().uuidString + let fileUrl = URL.temporaryDirectory.appendingPathComponent(fileName) + let fileData = Data("Hello, World!".utf8) + try fileData.write(to: fileUrl) + + let result = await remoteInterface.upload( + remotePath: Self.account.davFilesUrl + "/file.txt", localPath: fileUrl.path, account: Self.account + ) + XCTAssertEqual(result.remoteError, .success) + + let remoteItem = remoteInterface.item( + remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account.ncKitAccount + ) + XCTAssertNotNil(remoteItem) + let remoteItemIncorrectFileName = remoteInterface.item( + remotePath: Self.account.davFilesUrl + "/" + fileName, account: Self.account.ncKitAccount + ) + XCTAssertNil(remoteItemIncorrectFileName) + } + + func testChunkedUpload() async throws { + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: 8) + try data.write(to: fileUrl) + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + debugPrint(remoteInterface) + + let remotePath = Self.account.davFilesUrl + "/file.txt" + let chunkSize = 3 + var uploadedChunks = [RemoteFileChunk]() + let result = await remoteInterface.chunkedUpload( + localPath: fileUrl.path, + remotePath: remotePath, + remoteChunkStoreFolderName: UUID().uuidString, + chunkSize: chunkSize, + remainingChunks: [], + creationDate: .init(), + modificationDate: .init(), + account: Self.account, + options: .init(), + log: FileProviderLogMock(), + chunkUploadCompleteHandler: { uploadedChunks.append($0) } + ) + + let resultChunks = try XCTUnwrap(result.fileChunks) + let expectedChunkCount = Int(ceil(Double(data.count) / Double(chunkSize))) + + XCTAssertEqual(result.nkError, .success) + XCTAssertEqual(resultChunks.count, expectedChunkCount) + XCTAssertNotNil(result.file) + XCTAssertEqual(result.file?.size, Int64(data.count)) + + XCTAssertEqual(uploadedChunks.count, resultChunks.count) + + let firstUploadedChunk = try XCTUnwrap(uploadedChunks.first) + let firstUploadedChunkNameInt = try XCTUnwrap(Int(firstUploadedChunk.fileName)) + let lastUploadedChunk = try XCTUnwrap(uploadedChunks.last) + let lastUploadedChunkNameInt = try XCTUnwrap(Int(lastUploadedChunk.fileName)) + XCTAssertEqual(firstUploadedChunkNameInt, 1) + XCTAssertEqual(lastUploadedChunkNameInt, expectedChunkCount) + XCTAssertEqual(Int(firstUploadedChunk.size), chunkSize) + XCTAssertEqual( + Int(lastUploadedChunk.size), data.count - ((lastUploadedChunkNameInt - 1) * chunkSize) + ) + } + + func testResumedChunkedUpload() async throws { + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: 8) + try data.write(to: fileUrl) + + let chunkSize = 3 + let uploadUuid = UUID().uuidString + let previousUploadedChunkNum = 1 + let previousUploadedChunk = RemoteFileChunk( + fileName: String(previousUploadedChunkNum), + size: Int64(chunkSize), + remoteChunkStoreFolderName: uploadUuid + ) + let previousUploadedChunks = [previousUploadedChunk] + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + debugPrint(remoteInterface) + remoteInterface.currentChunks = [uploadUuid: previousUploadedChunks] + + let remotePath = Self.account.davFilesUrl + "/file.txt" + + var uploadedChunks = [RemoteFileChunk]() + let result = await remoteInterface.chunkedUpload( + localPath: fileUrl.path, + remotePath: remotePath, + remoteChunkStoreFolderName: uploadUuid, + chunkSize: chunkSize, + remainingChunks: [ + RemoteFileChunk( + fileName: String(2), + size: Int64(chunkSize), + remoteChunkStoreFolderName: uploadUuid + ), + RemoteFileChunk( + fileName: String(3), + size: Int64(data.count - (chunkSize * 2)), + remoteChunkStoreFolderName: uploadUuid + ) + ], + creationDate: .init(), + modificationDate: .init(), + account: Self.account, + options: .init(), + log: FileProviderLogMock(), + chunkUploadCompleteHandler: { uploadedChunks.append($0) } + ) + + let resultChunks = try XCTUnwrap(result.fileChunks) + let expectedChunkCount = Int(ceil(Double(data.count) / Double(chunkSize))) + + XCTAssertEqual(result.nkError, .success) + XCTAssertEqual(resultChunks.count, expectedChunkCount) + XCTAssertNotNil(result.file) + XCTAssertEqual(result.file?.size, Int64(data.count)) + + XCTAssertEqual(uploadedChunks.count, resultChunks.count - previousUploadedChunks.count) + + let firstUploadedChunk = try XCTUnwrap(uploadedChunks.first) + let firstUploadedChunkNameInt = try XCTUnwrap(Int(firstUploadedChunk.fileName)) + let lastUploadedChunk = try XCTUnwrap(uploadedChunks.last) + let lastUploadedChunkNameInt = try XCTUnwrap(Int(lastUploadedChunk.fileName)) + XCTAssertEqual(firstUploadedChunkNameInt, previousUploadedChunkNum + 1) + XCTAssertEqual(lastUploadedChunkNameInt, previousUploadedChunkNum + 2) + print(uploadedChunks) + XCTAssertEqual(Int(firstUploadedChunk.size), chunkSize) + XCTAssertEqual( + Int(lastUploadedChunk.size), data.count - ((lastUploadedChunkNameInt - 1) * chunkSize) + ) + } + + func testMove() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + name: "a", + remotePath: Self.account.davFilesUrl + "/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemB = MockRemoteItem( + identifier: "b", + name: "b", + remotePath: Self.account.davFilesUrl + "/b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemC = MockRemoteItem( + identifier: "c", + name: "c", + remotePath: Self.account.davFilesUrl + "/a/c", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let targetItem = MockRemoteItem( + identifier: "target", + name: "target", + remotePath: Self.account.davFilesUrl + "/a/c/target", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteInterface.rootItem?.children = [itemA, itemB] + itemA.parent = remoteInterface.rootItem + itemA.children = [itemC] + itemC.parent = itemA + itemB.parent = remoteInterface.rootItem + + itemC.children = [targetItem] + targetItem.parent = itemC + + let result = await remoteInterface.move( + remotePathSource: Self.account.davFilesUrl + "/a/c/target", + remotePathDestination: Self.account.davFilesUrl + "/b/targetRenamed", + account: Self.account + ) + XCTAssertEqual(result.error, .success) + XCTAssertEqual(itemB.children, [targetItem]) + XCTAssertEqual(itemC.children, []) + XCTAssertEqual(targetItem.parent, itemB) + XCTAssertEqual(targetItem.name, "targetRenamed") + XCTAssertEqual(targetItem.remotePath, Self.account.davFilesUrl + "/b/targetRenamed") + } + + func testDownload() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + remoteInterface.injectMock(Self.account) + + debugPrint(remoteInterface) + + let fileUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file.txt", conformingTo: .text) + + if !FileManager.default.isWritableFile(atPath: fileUrl.path) { + print("WARNING: TEMP NOT WRITEABLE. SKIPPING TEST") + return + } + + let fileData = Data("Hello, World!".utf8) + _ = await remoteInterface.upload( + remotePath: Self.account.davFilesUrl + "/file.txt", + localPath: fileUrl.path, + account: Self.account + ) + + let result = await remoteInterface.downloadAsync( + serverUrlFileName: Self.account.davFilesUrl + "/file.txt", + fileNameLocalPath: fileUrl.path, + account: Self.account.ncKitAccount, + options: .init() + ) + + XCTAssertEqual(result.nkError, .success) + + let downloadedData = try Data(contentsOf: fileUrl) + XCTAssertEqual(downloadedData, fileData) + } + + func testEnumerate() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + name: "a", + remotePath: Self.account.davFilesUrl + "/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemB = MockRemoteItem( + identifier: "b", + name: "b", + remotePath: Self.account.davFilesUrl + "/b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemC = MockRemoteItem( + identifier: "c", + name: "c", + remotePath: Self.account.davFilesUrl + "/c", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemA_A = MockRemoteItem( + identifier: "a_a", + name: "a", + remotePath: Self.account.davFilesUrl + "/a/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemA_B = MockRemoteItem( + identifier: "a_b", + name: "b", + remotePath: Self.account.davFilesUrl + "/a/b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemC_A = MockRemoteItem( + identifier: "c_a", + name: "a", + remotePath: Self.account.davFilesUrl + "/c/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemC_A_A = MockRemoteItem( + identifier: "c_a_a", + name: "a", + remotePath: Self.account.davFilesUrl + "/c/a/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteInterface.rootItem?.children = [itemA, itemB, itemC] + itemA.parent = remoteInterface.rootItem + itemB.parent = remoteInterface.rootItem + itemC.parent = remoteInterface.rootItem + + itemA.children = [itemA_A, itemA_B] + itemA_A.parent = itemA + itemA_B.parent = itemA + + itemC.children = [itemC_A] + itemC_A.parent = itemC + + itemC_A.children = [itemC_A_A] + itemC_A_A.parent = itemC_A + + let result = await remoteInterface.enumerate( + remotePath: Self.account.davFilesUrl, depth: .target, account: Self.account + ) + XCTAssertEqual(result.error, .success) + XCTAssertEqual(result.files.count, 1) + let targetRootFile = result.files.first + let expectedRoot = remoteInterface.rootItem + XCTAssertEqual(targetRootFile?.ocId, expectedRoot?.identifier) + XCTAssertEqual(targetRootFile?.fileName, NextcloudKit.shared.nkCommonInstance.rootFileName) // NextcloudKit gives the root dir this name + XCTAssertEqual(targetRootFile?.serverUrl, "https://mock.nc.com/remote.php/dav/files/testUserId") // NextcloudKit gives the root dir this url + XCTAssertEqual(targetRootFile?.date, expectedRoot?.creationDate) + XCTAssertEqual(targetRootFile?.etag, expectedRoot?.versionIdentifier) + + let resultChildren = await remoteInterface.enumerate( + remotePath: Self.account.davFilesUrl, + depth: .targetAndDirectChildren, + account: Self.account + ) + XCTAssertEqual(resultChildren.error, .success) + XCTAssertEqual(resultChildren.files.count, 4) + XCTAssertEqual( + resultChildren.files.map(\.ocId), + [ + remoteInterface.rootItem?.identifier, + itemA.identifier, + itemB.identifier, + itemC.identifier + ] + ) + + let resultAChildren = await remoteInterface.enumerate( + remotePath: Self.account.davFilesUrl + "/a", + depth: .targetAndDirectChildren, + account: Self.account + ) + XCTAssertEqual(resultAChildren.error, .success) + XCTAssertEqual(resultAChildren.files.count, 3) + XCTAssertEqual( + resultAChildren.files.map(\.ocId), + [itemA.identifier, itemA_A.identifier, itemA_B.identifier] + ) + + let resultCChildren = await remoteInterface.enumerate( + remotePath: Self.account.davFilesUrl + "/c", + depth: .targetAndDirectChildren, + account: Self.account + ) + XCTAssertEqual(resultCChildren.error, .success) + XCTAssertEqual(resultCChildren.files.count, 2) + XCTAssertEqual( + resultCChildren.files.map(\.ocId), + [itemC.identifier, itemC_A.identifier] + ) + + let resultCRecursiveChildren = await remoteInterface.enumerate( + remotePath: Self.account.davFilesUrl + "/c", + depth: .targetAndAllChildren, + account: Self.account + ) + XCTAssertEqual(resultCRecursiveChildren.error, .success) + XCTAssertEqual(resultCRecursiveChildren.files.count, 3) + XCTAssertEqual( + resultCRecursiveChildren.files.map(\.ocId), + [itemC.identifier, itemC_A.identifier, itemC_A_A.identifier] + ) + } + + func testDelete() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + name: "a", + remotePath: Self.account.davFilesUrl + "/a", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemB = MockRemoteItem( + identifier: "b", + name: "b", + remotePath: Self.account.davFilesUrl + "/b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemA_C = MockRemoteItem( + identifier: "c", + name: "c", + remotePath: Self.account.davFilesUrl + "/a/c", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemA_C_D = MockRemoteItem( + identifier: "d", + name: "d", + remotePath: Self.account.davFilesUrl + "/a/c/d", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteInterface.rootItem?.children = [itemA, itemB] + itemA.parent = remoteInterface.rootItem + itemA.children = [itemA_C] + itemA_C.parent = itemA + itemB.parent = remoteInterface.rootItem + + itemA_C.children = [itemA_C_D] + itemA_C_D.parent = itemA_C + + let itemA_C_predeleteName = itemA_C.name + + let result = await remoteInterface.delete( + remotePath: Self.account.davFilesUrl + "/a/c", account: Self.account + ) + XCTAssertEqual(result.error, .success) + XCTAssertEqual(itemA.children, []) + XCTAssertEqual(remoteInterface.rootTrashItem?.children.contains(itemA_C), true) + XCTAssertEqual(itemA_C.name, itemA_C_predeleteName + " (trashed)") + XCTAssertEqual(itemA_C.remotePath, Self.account.trashUrl + "/c (trashed)") + XCTAssertEqual(itemA_C_D.trashbinOriginalLocation, "a/c/d") + XCTAssertEqual(itemA_C_D.remotePath, Self.account.trashUrl + "/c (trashed)/d") + } + + func testTrashedItems() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + name: "a (trashed)", + remotePath: Self.account.trashUrl + "/a (trashed)", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "a" + ) + let itemB = MockRemoteItem( + identifier: "b", + name: "b (trashed)", + remotePath: Self.account.trashUrl + "/b (trashed)", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "b" + ) + rootTrashItem.children = [itemA, itemB] + itemA.parent = rootTrashItem + itemB.parent = rootTrashItem + + let (_, items, _, error) = await remoteInterface.listingTrashAsync(filename: nil, showHiddenFiles: true, account: Self.account.ncKitAccount, options: .init(), taskHandler: { _ in }) + + XCTAssertEqual(error, .success) + + let unwrappedItems = try XCTUnwrap(items) + XCTAssertEqual(unwrappedItems.count, 2) + XCTAssertEqual(unwrappedItems[0].fileName, "a (trashed)") + XCTAssertEqual(unwrappedItems[1].fileName, "b (trashed)") + XCTAssertEqual(unwrappedItems[0].trashbinFileName, "a") + XCTAssertEqual(unwrappedItems[1].trashbinFileName, "b") + XCTAssertEqual(unwrappedItems[0].ocId, itemA.identifier) + XCTAssertEqual(unwrappedItems[1].ocId, itemB.identifier) + } + + // The server will return ocIds as fileIds. To try to test the item modification steps' handling + // of this, we intentionally mangle the item's original identifiers while keeping the fileIds + // consistent (this is what we are able to use to match pre-trashing items with their + // post-trashing metadata) + func testTrashingManglesIdentifiers() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + debugPrint(remoteInterface) + let folderOriginalIdentifier = "folder" + let folder = MockRemoteItem( + identifier: folderOriginalIdentifier, + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemAOriginalIdentifier = "a" + let itemA = MockRemoteItem( + identifier: itemAOriginalIdentifier, + name: "a", + remotePath: Self.account.davFilesUrl + "/a", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [folder] + folder.parent = rootItem + folder.children = [itemA] + itemA.parent = folder + + let (_, _, error) = await remoteInterface.move( + remotePathSource: folder.remotePath, + remotePathDestination: Self.account.trashUrl + "/" + folder.name, + account: Self.account + ) + XCTAssertEqual(error, .success) + XCTAssertNotEqual(folder.identifier, folderOriginalIdentifier) // Should not be equal + XCTAssertEqual(folder.identifier, folderOriginalIdentifier + trashedItemIdSuffix) + XCTAssertNotEqual(itemA.identifier, itemAOriginalIdentifier) // Should not be equal + XCTAssertEqual(itemA.identifier, itemAOriginalIdentifier + trashedItemIdSuffix) + + let folderConvertedMetadata = folder.toItemMetadata(account: Self.account) + XCTAssertEqual(folderConvertedMetadata.fileId, folderOriginalIdentifier) + let itemAConvertedMetadata = itemA.toItemMetadata(account: Self.account) + XCTAssertEqual(itemAConvertedMetadata.fileId, itemAOriginalIdentifier) + } + + func testRestoreFromTrash() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + name: "a (trashed)", + remotePath: Self.account.trashUrl + "/a (trashed)", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "a" + ) + rootTrashItem.children = [itemA] + itemA.parent = rootTrashItem + + let (_, _, error) = + await remoteInterface.restoreFromTrash(filename: itemA.name, account: Self.account) + XCTAssertEqual(error, .success) + XCTAssertEqual(rootTrashItem.children.count, 0) + XCTAssertEqual(rootItem.children.count, 1) + XCTAssertEqual(rootItem.children[0].identifier, "a") + XCTAssertEqual(itemA.identifier, "a") + XCTAssertEqual(itemA.remotePath, Self.account.davFilesUrl + "/a") + XCTAssertEqual(itemA.name, "a") + XCTAssertNil(itemA.trashbinOriginalLocation) + } + + func testNoDirectMoveFromTrash() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + debugPrint(remoteInterface) + let folder = MockRemoteItem( + identifier: "folder", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let itemA = MockRemoteItem( + identifier: "a", + name: "a (trashed)", + remotePath: Self.account.trashUrl + "/a (trashed)", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "a" + ) + rootTrashItem.children = [itemA] + itemA.parent = rootTrashItem + rootItem.children = [folder] + folder.parent = rootItem + + let newPath = folder.remotePath + "/" + itemA.name + let (_, _, directMoveError) = await remoteInterface.move( + remotePathSource: itemA.remotePath, + remotePathDestination: newPath, + overwrite: true, + account: Self.account + ) + XCTAssertNotEqual(directMoveError, .success) // Should fail as we need to restore first + + let expectedRestoreRemotePath = + Self.account.davFilesUrl + "/" + itemA.trashbinOriginalLocation! + let (_, _, restoreError) = + await remoteInterface.restoreFromTrash(filename: itemA.name, account: Self.account) + XCTAssertEqual(restoreError, .success) + XCTAssertEqual(itemA.remotePath, expectedRestoreRemotePath) + + let (_, _, postRestoreMoveError) = await remoteInterface.move( + remotePathSource: itemA.remotePath, + remotePathDestination: newPath, + overwrite: true, + account: Self.account + ) + XCTAssertEqual(postRestoreMoveError, .success) + } + + func testEnforceOverwriteOnRestore() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + debugPrint(remoteInterface) + let itemA = MockRemoteItem( + identifier: "a", + name: "a (trashed)", + remotePath: Self.account.trashUrl + "/a (trashed)", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "a" + ) + rootTrashItem.children = [itemA] + itemA.parent = rootTrashItem + + let restorePath = Self.account.trashRestoreUrl + "/" + itemA.name + let (_, _, noOverwriteError) = await remoteInterface.move( + remotePathSource: itemA.remotePath, + remotePathDestination: restorePath, + overwrite: false, + account: Self.account + ) + XCTAssertNotEqual(noOverwriteError, .success) // Should fail as we enforce overwrite + + let (_, _, overwriteError) = await remoteInterface.move( + remotePathSource: itemA.remotePath, + remotePathDestination: restorePath, + overwrite: true, + account: Self.account + ) + XCTAssertEqual(overwriteError, .success) + } + + func testFetchUserProfile() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + + let (account, profile, _, error) = await remoteInterface.getUserProfileAsync( + account: Self.account.ncKitAccount, + options: .init(), + taskHandler: { _ in } + ) + + XCTAssertEqual(error, .success) + XCTAssertEqual(account, Self.account.ncKitAccount) + XCTAssertNotNil(profile) + } + + func testTryAuthenticationAttempt() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + debugPrint(remoteInterface) + let state = await remoteInterface.tryAuthenticationAttempt(account: Self.account) + XCTAssertEqual(state, .success) + let failState = await remoteInterface.tryAuthenticationAttempt( + account: Account(user: "", id: "", serverUrl: "", password: "") + ) + XCTAssertEqual(failState, .authenticationError) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/AccountTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/AccountTests.swift new file mode 100644 index 0000000000000..3a68496370b9b --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/AccountTests.swift @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +@testable import NextcloudFileProviderKit +import XCTest + +final class AccountTests: NextcloudFileProviderKitTestCase { + func testInitializationDirect() { + let user = "user" + let userId = "userId" + let password = "password" + let serverUrl = "https://example.com" + let account = Account(user: user, id: userId, serverUrl: serverUrl, password: password) + + XCTAssertEqual(account.username, user) + XCTAssertEqual(account.id, userId) + XCTAssertEqual(account.password, password) + XCTAssertEqual(account.ncKitAccount, "\(user) \(serverUrl)") + XCTAssertEqual(account.serverUrl, serverUrl) + XCTAssertEqual(account.davFilesUrl, serverUrl + Account.webDavFilesUrlSuffix + userId) + XCTAssertEqual(account.trashUrl, serverUrl + Account.webDavTrashUrlSuffix + "\(userId)/trash") + XCTAssertEqual( + account.trashRestoreUrl, serverUrl + Account.webDavTrashUrlSuffix + "\(userId)/restore" + ) + XCTAssertEqual(account.fileName, "\(userId)_example_com") + } + + func testInitializationFromDictionary() { + let dictionary: [String: String] = [ + AccountDictUsernameKey: "user", + AccountDictIdKey: "userId", + AccountDictPasswordKey: "password", + AccountDictNcKitAccountKey: "user https://example.com", + AccountDictServerUrlKey: "https://example.com", + AccountDictDavFilesUrlKey: "https://example.com/remote.php/dav/files/user", + AccountDictTrashUrlKey: "https://example.com/remote.php/dav/trashbin/user/trash", + AccountDictTrashRestoreUrlKey: "https://example.com/remote.php/dav/trashbin/user/restore", + AccountDictFileNameKey: "userId_example_com" + ] + + let account = Account(dictionary: dictionary) + + XCTAssertNotNil(account) + XCTAssertEqual(account?.username, "user") + XCTAssertEqual(account?.id, "userId") + XCTAssertEqual(account?.password, "password") + XCTAssertEqual(account?.ncKitAccount, "user https://example.com") + XCTAssertEqual(account?.serverUrl, "https://example.com") + XCTAssertEqual(account?.davFilesUrl, "https://example.com/remote.php/dav/files/user") + XCTAssertEqual(account?.trashUrl, "https://example.com/remote.php/dav/trashbin/user/trash") + XCTAssertEqual( + account?.trashRestoreUrl, "https://example.com/remote.php/dav/trashbin/user/restore" + ) + XCTAssertEqual(account?.fileName, "userId_example_com") + } + + func testInitializationFromIncompleteDictionary() { + let incompleteDictionary: [String: String] = [ + AccountDictUsernameKey: "user" + // missing other keys + ] + + let account = Account(dictionary: incompleteDictionary) + XCTAssertNil(account) + } + + func testDictionaryRepresentation() { + let account = Account( + user: "user", id: "userId", serverUrl: "https://example.com", password: "password" + ) + let dictionary = account.dictionary() + + XCTAssertEqual(dictionary[AccountDictUsernameKey], "user") + XCTAssertEqual(dictionary[AccountDictPasswordKey], "password") + XCTAssertEqual(dictionary[AccountDictIdKey], "userId") + XCTAssertEqual(dictionary[AccountDictNcKitAccountKey], "user https://example.com") + XCTAssertEqual(dictionary[AccountDictServerUrlKey], "https://example.com") + XCTAssertEqual(dictionary[AccountDictDavFilesUrlKey], "https://example.com/remote.php/dav/files/userId") + XCTAssertEqual(dictionary[AccountDictTrashUrlKey], "https://example.com/remote.php/dav/trashbin/userId/trash") + XCTAssertEqual(dictionary[AccountDictTrashRestoreUrlKey], "https://example.com/remote.php/dav/trashbin/userId/restore") + XCTAssertEqual(dictionary[AccountDictFileNameKey], "userId_example_com") + } + + func testEquatability() { + let account1 = Account( + user: "user", id: "userId", serverUrl: "https://example.com", password: "password" + ) + let account2 = Account( + user: "user", id: "userId", serverUrl: "https://example.com", password: "password" + ) + + XCTAssertEqual(account1, account2) + + let account3 = Account( + user: "user", id: "userId", serverUrl: "https://example.net", password: "password" + ) + XCTAssertNotEqual(account1, account3) + } + + func testFilenameValid() { + let account = Account( + user: "user", id: "/u/s/e.r/", serverUrl: "https://example.com", password: "password" + ) + XCTAssertEqual(account.fileName, "u_s_e_r_example_com") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift new file mode 100644 index 0000000000000..5479fae2246e8 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@testable import NextcloudFileProviderKit +import XCTest + +final class ChunkedArrayTests: NextcloudFileProviderKitTestCase { + // MARK: - chunked(into:) + + func testChunkedEmptyArray() { + let emptyArray: [Int] = [] + XCTAssertEqual(emptyArray.chunked(into: 3), []) + } + + func testChunkedSingleElement() { + let array = [1] + XCTAssertEqual(array.chunked(into: 3), [[1]]) + } + + func testChunkedExactDivision() { + let array = [1, 2, 3, 4, 5, 6] + XCTAssertEqual(array.chunked(into: 2), [[1, 2], [3, 4], [5, 6]]) + } + + func testChunkedPartialDivision() { + let array = [1, 2, 3, 4, 5] + XCTAssertEqual(array.chunked(into: 2), [[1, 2], [3, 4], [5]]) + } + + func testChunkedInvalidSize() { + let array = [1, 2, 3] + XCTAssertEqual(array.chunked(into: 0), []) + XCTAssertEqual(array.chunked(into: -1), []) + } + + // MARK: - chunkedMap(into:transform:) + + func testChunkedMap() { + let array = [1, 2, 3, 4] + let transformed = array.chunkedMap(into: 2) { $0 * 2 } + XCTAssertEqual(transformed, [[2, 4], [6, 8]]) + } + + func testChunkedMapEmptyArray() { + let emptyArray: [Int] = [] + XCTAssertEqual(emptyArray.chunkedMap(into: 2) { $0 * 2 }, []) + } + + func testChunkedMapInvalidSize() { + let array = [1, 2, 3] + XCTAssertEqual(array.chunkedMap(into: 0) { $0 * 2 }, []) + } + + // MARK: - concurrentChunkedForEach(into:operation:) + + func testConcurrentChunkedForEach() async { + actor ResultsCollector { + var results = [Int]() + + func append(_ value: Int) { + results.append(value) + } + + func getResults() -> [Int] { + results + } + } + + let array = [1, 2, 3, 4] + let collector = ResultsCollector() + + await array.concurrentChunkedForEach(into: 2) { element in + try? await Task.sleep(nanoseconds: 100_000_000) // Simulate work (100ms) + await collector.append(element * 2) + } + let sortedResults = await collector.getResults().sorted() + let expectedResults = [2, 4, 6, 8] + XCTAssertEqual(sortedResults, expectedResults) + } + + func testConcurrentChunkedForEachEmptyArray() async { + actor ResultsCollector { + var results = [Int]() + + func append(_ value: Int) { + results.append(value) + } + + func isEmpty() -> Bool { + results.isEmpty + } + } + + let emptyArray: [Int] = [] + let collector = ResultsCollector() + + await emptyArray.concurrentChunkedForEach(into: 2) { element in + await collector.append(element) + } + + let isEmpty = await collector.isEmpty() + XCTAssertTrue(isEmpty) + } + + // MARK: - concurrentChunkedCompactMap(into:transform:) + + func testConcurrentChunkedCompactMap() async throws { + let array = [1, 2, 3, 4, 5, 6] + let results = try await array.concurrentChunkedCompactMap(into: 2) { $0 % 2 == 0 ? $0 : nil } + XCTAssertEqual(results.sorted(), [2, 4, 6]) + } + + func testConcurrentChunkedCompactMapEmptyArray() async throws { + let emptyArray: [Int] = [] + let results = try await emptyArray.concurrentChunkedCompactMap(into: 2) { $0 } + XCTAssertTrue(results.isEmpty) + } + + func testConcurrentChunkedCompactMapInvalidSize() async throws { + let array = [1, 2, 3] + let results = try await array.concurrentChunkedCompactMap(into: 0) { $0 } + XCTAssertTrue(results.isEmpty) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift new file mode 100644 index 0000000000000..b165aa0665a32 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import Testing + +@Suite struct EnumeratorPageResponseTests { + private func createMockAFDataResponse( + headers: [String: String]?, + statusCode: Int = 200, + data: Data? = Data() + ) -> AFDataResponse? { + guard let url = URL(string: "https://example.com") else { + print("Error: Failed to create URL in test helper.") + return nil + } + let httpResponse = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers + ) + let result: Result = .success(data ?? Data()) + return AFDataResponse( + request: nil, + response: httpResponse, + data: data, + metrics: nil, + serializationDuration: 0, + result: result + ) + } + + // MARK: - Success Cases + + @Test("Init with valid headers and total succeeds") + func initWithValidHeadersAndTotal() { + let headers = [ + "X-NC-PAGINATE": "true", + "X-NC-PAGINATE-TOKEN": "nextToken123", + "X-NC-PAGINATE-TOTAL": "100" + ] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + + #expect(enumeratorResponse != nil, "Initialization should succeed with valid values.") + #expect(enumeratorResponse?.token == "nextToken123") + #expect(enumeratorResponse?.index == 0) + #expect(enumeratorResponse?.total == 100) + } + + @Test("Init with valid headers and missing total succeeds") + func initWithValidHeadersAndMissingTotal() { + let headers = ["X-NC-PAGINATE": "true", "X-NC-PAGINATE-TOKEN": "anotherToken456"] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 1 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + + #expect(enumeratorResponse != nil, "Initialization should succeed with valid values.") + #expect(enumeratorResponse?.token == "anotherToken456") + #expect(enumeratorResponse?.index == 1) + #expect(enumeratorResponse?.total == nil, "Total should be nil when the header is missing.") + } + + @Test("Init with case-insensitive header keys and 'TRUE' succeeds") + func initWithCaseInsensitiveHeaders() { + let headers = [ + "x-nc-paginate": "TRUE", // Lowercase key, uppercase value for boolean + "x-nc-paginate-token": "mixedCaseToken789", + "X-NC-PAGINATE-TOTAL": "50" + ] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 2 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + + #expect(enumeratorResponse != nil, "Init should succeed with case-insensitive headers.") + #expect(enumeratorResponse?.token == "mixedCaseToken789") + #expect(enumeratorResponse?.index == 2) + #expect(enumeratorResponse?.total == 50) + } + + @Test("Init with non-integer total value results in nil total") + func initWithNonIntegerTotal() { + let headers = [ + "X-NC-PAGINATE": "true", + "X-NC-PAGINATE-TOKEN": "tokenWithInvalidTotal", + "X-NC-PAGINATE-TOTAL": "not-an-integer" + ] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 3 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + + #expect(enumeratorResponse != nil, "Init should succeed even if total is not valid integer") + #expect(enumeratorResponse?.token == "tokenWithInvalidTotal") + #expect(enumeratorResponse?.index == 3) + #expect(enumeratorResponse?.total == nil, "Total should be nil if cannot be parsed as Int") + } + + // MARK: - Failure Cases + + @Test("Init with nil nkResponseData returns nil") + func initWithNilNkResponseData() { + let index = 0 + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: nil, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if nkResponseData is nil.") + } + + @Test("Init with nil HTTPURLResponse returns nil") + func initWithNilHttpResponse() { + let afResponseWithNilHttp = AFDataResponse( + request: nil, + response: nil, // HTTPURLResponse is nil + data: Data(), + metrics: nil, + serializationDuration: 0, + result: .success(Data()) + ) + let index = 0 + let enumeratorResponse = + EnumeratorPageResponse(nkResponseData: afResponseWithNilHttp, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if HTTPURLResponse is nil.") + } + + @Test("Init with empty headers returns nil") + func initWithEmptyHeaders() { + let mockResponse = createMockAFDataResponse(headers: [:]) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if required headers empty") + } + + @Test("Init without X-NC-PAGINATE header returns nil") + func initWithoutPaginateHeader() { + let headers = ["X-NC-PAGINATE-TOKEN": "someToken"] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if PAGINATE header missing.") + } + + @Test("Init with X-NC-PAGINATE header set to 'false' returns nil") + func initWithPaginateHeaderFalse() { + let headers = [ + "X-NC-PAGINATE": "false", + "X-NC-PAGINATE-TOKEN": "someToken" + ] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if PAGINATE header is false") + } + + @Test("Init with X-NC-PAGINATE header not a valid 'true' string returns nil") + func initWithPaginateHeaderNotTrueString() { + let headers = ["X-NC-PAGINATE": "false", "X-NC-PAGINATE-TOKEN": "someToken"] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if PAGINATE header not true") + } + + @Test("Init without X-NC-PAGINATE-TOKEN header returns nil") + func initWithoutPaginateTokenHeader() { + let headers = ["X-NC-PAGINATE": "true"] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse == nil, "Initialization should fail if TOKEN header is missing.") + } + + @Test("Init with empty X-NC-PAGINATE-TOKEN header succeeds with empty token") + func initWithEmptyPaginateToken() { + // The current implementation allows an empty token if the header key exists. + let headers = ["X-NC-PAGINATE": "true", "X-NC-PAGINATE-TOKEN": ""] + let mockResponse = createMockAFDataResponse(headers: headers) + let index = 0 + + let enumeratorResponse = EnumeratorPageResponse(nkResponseData: mockResponse, index: index, log: FileProviderLogMock()) + #expect(enumeratorResponse != nil, "Initialization should succeed with empty token string.") + #expect(enumeratorResponse?.token == "", "Token should be an empty string.") + #expect(enumeratorResponse?.index == 0) + #expect(enumeratorResponse?.total == nil) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift new file mode 100644 index 0000000000000..48b79da006267 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -0,0 +1,1660 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +@testable import TestInterface +import XCTest + +final class EnumeratorTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + + var rootItem: MockRemoteItem! + var remoteFolder: MockRemoteItem! + var remoteItemA: MockRemoteItem! + var remoteItemB: MockRemoteItem! + var remoteItemC: MockRemoteItem! + + var rootTrashItem: MockRemoteItem! + var remoteTrashItemA: MockRemoteItem! + var remoteTrashItemB: MockRemoteItem! + var remoteTrashItemC: MockRemoteItem! + + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + + rootItem = MockRemoteItem.rootItem(account: Self.account) + + remoteFolder = MockRemoteItem( + identifier: "folder", + versionIdentifier: "NEW", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteItemA = MockRemoteItem( + identifier: "itemA", + versionIdentifier: "NEW", + name: "itemA", + remotePath: Self.account.davFilesUrl + "/folder/itemA", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteItemB = MockRemoteItem( + identifier: "itemB", + name: "itemB", + remotePath: Self.account.davFilesUrl + "/folder/itemB", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteItemC = MockRemoteItem( + identifier: "itemC", + name: "itemC", + remotePath: Self.account.davFilesUrl + "/folder/itemC", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + remoteFolder.children = [remoteItemA, remoteItemB] + remoteItemA.parent = remoteFolder + remoteItemB.parent = remoteFolder + remoteItemC.parent = nil + + rootTrashItem = MockRemoteItem.rootTrashItem(account: Self.account) + + remoteTrashItemA = MockRemoteItem( + identifier: "trashItemA", + name: "a.txt", + remotePath: Self.account.trashUrl + "/a.txt", + data: Data(repeating: 1, count: 32), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteTrashItemB = MockRemoteItem( + identifier: "trashItemB", + name: "b.txt", + remotePath: Self.account.trashUrl + "/b.txt", + data: Data(repeating: 1, count: 69), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + remoteTrashItemC = MockRemoteItem( + identifier: "trashItemC", + name: "c.txt", + remotePath: Self.account.trashUrl + "/c.txt", + data: Data(repeating: 1, count: 100), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + rootTrashItem.children = [remoteTrashItemA, remoteTrashItemB, remoteTrashItemC] + remoteTrashItemA.parent = rootTrashItem + remoteTrashItemB.parent = rootTrashItem + remoteTrashItemC.parent = rootTrashItem + } + + func testRootEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + XCTAssertEqual(observer.items.count, 2) + + let retrievedRootItem = try XCTUnwrap(observer.items.first) + XCTAssertEqual(retrievedRootItem.itemIdentifier.rawValue, rootItem.identifier) + + let retrievedFolderItem = try XCTUnwrap(observer.items.last) + XCTAssertEqual(retrievedFolderItem.itemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(retrievedFolderItem.filename, remoteFolder.name) + XCTAssertEqual(retrievedFolderItem.parentItemIdentifier.rawValue, rootItem.identifier) + XCTAssertEqual(retrievedFolderItem.creationDate, remoteFolder.creationDate) + XCTAssertEqual( + Int(retrievedFolderItem.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteFolder.modificationDate.timeIntervalSince1970) + ) + + let dbFolder = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertEqual(dbFolder.etag, remoteFolder.versionIdentifier) + XCTAssertEqual(dbFolder.fileName, remoteFolder.name) + XCTAssertEqual(dbFolder.fileNameView, remoteFolder.name) + XCTAssertEqual(dbFolder.serverUrl + "/" + dbFolder.fileName, remoteFolder.remotePath) + XCTAssertEqual(dbFolder.account, Self.account.ncKitAccount) + XCTAssertEqual(dbFolder.user, Self.account.username) + XCTAssertEqual(dbFolder.userId, Self.account.id) + XCTAssertEqual(dbFolder.urlBase, Self.account.serverUrl) + + let storedFolderItemMaybe = await Item.storedItem( + identifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedFolderItem = try XCTUnwrap(storedFolderItemMaybe) + XCTAssertEqual(storedFolderItem.itemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(storedFolderItem.filename, remoteFolder.name) + XCTAssertEqual(storedFolderItem.parentItemIdentifier.rawValue, rootItem.identifier) + XCTAssertEqual(storedFolderItem.creationDate, remoteFolder.creationDate) + XCTAssertEqual( + Int(storedFolderItem.contentModificationDate?.timeIntervalSince1970 ?? 0), + Int(remoteFolder.modificationDate.timeIntervalSince1970) + ) + XCTAssertEqual(storedFolderItem.childItemCount?.intValue, 0) // Not visited yet, so no kids + } + + func testWorkingSetEnumeration() async throws { + // This test verifies that the working set enumerator correctly returns ONLY the + // items that are "materialised" (downloaded files or visited directories) from the database + let db = Self.dbManager.ncDatabase() // Init DB + debugPrint(db) + + // Item 1: A downloaded file (should be in working set) + var downloadedFile = remoteItemA.toItemMetadata(account: Self.account) + downloadedFile.downloaded = true + Self.dbManager.addItemMetadata(downloadedFile) + + // Item 2: A visited directory (should be in working set) + var visitedDir = remoteFolder.toItemMetadata(account: Self.account) + visitedDir.visitedDirectory = true + Self.dbManager.addItemMetadata(visitedDir) + + // Item 3: A file that is in the DB but not downloaded (should NOT be in working set) + var notDownloadedFile = remoteItemB.toItemMetadata(account: Self.account) + notDownloadedFile.downloaded = false + Self.dbManager.addItemMetadata(notDownloadedFile) + + // Item 4: A directory that is in the DB but not visited (should NOT be in working set) + var notVisitedDir = remoteItemC.toItemMetadata(account: Self.account) + notVisitedDir.directory = true + notVisitedDir.visitedDirectory = false + Self.dbManager.addItemMetadata(notVisitedDir) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 2. Act + try await observer.enumerateItems() + + // 3. Assert + XCTAssertNil(observer.error, "Enumeration should complete without error.") + XCTAssertEqual(observer.items.count, 2, "Should only enumerate the 2 materialized items.") + + let enumeratedIds = Set(observer.items.map(\.itemIdentifier.rawValue)) + XCTAssertTrue( + enumeratedIds.contains(downloadedFile.ocId), + "The downloaded file should be in the working set." + ) + XCTAssertTrue( + enumeratedIds.contains(visitedDir.ocId), + "The visited directory should be in the working set." + ) + + XCTAssertFalse( + enumeratedIds.contains(notDownloadedFile.ocId), + "The non-downloaded file should NOT be in the working set." + ) + XCTAssertFalse( + enumeratedIds.contains(notVisitedDir.ocId), + "The non-visited directory should NOT be in the working set." + ) + } + + func testWorkingSetEnumerationWhenNoMaterialisedItems() async throws { + // This test verifies that the enumerator behaves correctly when there are + // no materialized items in the database. + + // 1. Arrange + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + // Add items to the DB, but none are materialised. + var notDownloadedFile = remoteItemA.toItemMetadata(account: Self.account) + notDownloadedFile.downloaded = false + Self.dbManager.addItemMetadata(notDownloadedFile) + + var notVisitedDir = remoteFolder.toItemMetadata(account: Self.account) + notVisitedDir.visitedDirectory = false + Self.dbManager.addItemMetadata(notVisitedDir) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 2. Act + try await observer.enumerateItems() + + // 3. Assert + XCTAssertNil(observer.error, "Enumeration should complete without error.") + XCTAssertTrue(observer.items.isEmpty, "Result should be empty when no items materialised.") + } + + func testReadServerUrlFollowUpPagination() async throws { + // 1. Arrange: Setup a folder with enough children to require multiple pages. + remoteFolder.children = [] + for i in 0 ..< 10 { + let childItem = MockRemoteItem( + identifier: "folderChild\(i)", + name: "folderChild\(i).txt", + remotePath: Self.account.davFilesUrl + "/folder/folderChild\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = remoteFolder + remoteFolder.children.append(childItem) + } + + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) + + // Pre-populate the folder's metadata with an old etag to verify it gets updated + // on the initial call. + let oldEtag = "OLD_ETAG" + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.etag = oldEtag + Self.dbManager.addItemMetadata(folderMetadata) + + // --- Scenario A: Initial Paginated Request (isFollowUpPaginatedRequest == false) --- + + // 2. Act: Call readServerUrl for the first page. + let (initialMetadatas, _, _, _, initialNextPage, initialError) = await Enumerator.readServerUrl( + remoteFolder.remotePath, + pageSettings: (page: nil, index: 0, size: 5), // index is 0 + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + // 3. Assert: Verify the initial request's behavior. + XCTAssertNil(initialError) + XCTAssertNotNil(initialNextPage, "Should receive a next page token for the initial request") + + // The first request for a folder returns the folder itself plus the first page of children. + XCTAssertEqual( + initialMetadatas?.count, + 4, + """ + Should get target + first page of children, + but the target should not be included in the first page, + so count is (4). + """ + ) + + XCTAssertFalse(initialMetadatas?.contains(where: { $0.ocId == remoteFolder.identifier }) ?? false, "The folder itself should not be in the initial results.") + + // The logic inside `if !isFollowUpPaginatedRequest` should have run, + // updating the folder's metadata. + let updatedFolderMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotEqual(updatedFolderMetadata.etag, oldEtag, "The folder's etag should have been updated.") + XCTAssertEqual(updatedFolderMetadata.etag, remoteFolder.versionIdentifier) + + // --- Scenario B: Follow-up Paginated Request (isFollowUpPaginatedRequest == true) --- + + // 4. Act: Call readServerUrl for the second page using the received page token. + let followUpPage = NSFileProviderPage(initialNextPage!.token!.data(using: .utf8)!) + + let (followUpMetadatas, _, _, _, finalNextPage, followUpError) = await Enumerator.readServerUrl( + remoteFolder.remotePath, + pageSettings: (page: followUpPage, index: 1, size: 5), // index > 0 and page is non-nil + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + // 5. Assert: Verify the follow-up request's behavior. + XCTAssertNil(followUpError) + XCTAssertNotNil(finalNextPage, "Should receive a next page token for the second request.") + // A follow-up request should *only* contain the children for that page. + XCTAssertEqual( + followUpMetadatas?.count, 5, "Should get only the second page of children (5)." + ) + XCTAssertFalse( + followUpMetadatas?.contains(where: { $0.ocId == remoteFolder.identifier }) ?? true, + "The folder itself should NOT be in the follow-up page results." + ) + + // This confirms the `if !isFollowUpPaginatedRequest` block was correctly skipped, as it'd + // have processed the first item of the result array (`folderChild5`) as the parent folder, + // which would lead to incorrect data or errors. + let child5Metadata = Self.dbManager.itemMetadata(ocId: "folderChild5") + XCTAssertNotNil( + child5Metadata, "Metadata for the items on the second page should be in the database." + ) + } + + func testHandlePagedReadResults() throws { + // 1. Arrange + let dbManager = Self.dbManager + let db = dbManager.ncDatabase() + debugPrint(db) + + let parentNKFile = remoteFolder.toNKFile() + let childrenNKFiles = (0 ..< 5).map { i in + MockRemoteItem( + identifier: "pagedChild\(i)", + name: "pagedChild\(i).txt", + remotePath: Self.account.davFilesUrl + "/folder/pagedChild\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ).toNKFile() + } + let followUpChildrenNKFiles = (5 ..< 10).map { i in + MockRemoteItem( + identifier: "pagedChild\(i)", + name: "pagedChild\(i).txt", + remotePath: Self.account.davFilesUrl + "/folder/pagedChild\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ).toNKFile() + } + + // --- Scenario A: First Page (pageIndex == 0) --- + // 2. Act + let firstPageFiles = [parentNKFile] + childrenNKFiles + let (firstPageResult, firstPageError) = Enumerator.handlePagedReadResults( + files: firstPageFiles, pageIndex: 0, dbManager: dbManager + ) + + // 3. Assert + XCTAssertNil(firstPageError) + // Result should only contain the children, not the parent. + XCTAssertEqual( + firstPageResult?.count, 5, "First page result should only contain children." + ) + // The parent's metadata should be processed and saved to the DB. + let parentMetadataFromDB = dbManager.itemMetadata(ocId: parentNKFile.ocId) + XCTAssertNotNil( + parentMetadataFromDB, "Parent folder metadata should be saved to DB from first page." + ) + XCTAssertEqual(parentMetadataFromDB?.etag, parentNKFile.etag, "Parent etag should match.") + // The children's metadata should also be saved. + let childMetadataFromDB = dbManager.itemMetadata(ocId: "pagedChild0") + XCTAssertNotNil( + childMetadataFromDB, "Child metadata should be saved to DB from first page." + ) + + // --- Scenario B: Follow-up Page (pageIndex > 0) --- + // 4. Act + let (followUpPageResult, followUpPageError) = Enumerator.handlePagedReadResults( + files: followUpChildrenNKFiles, pageIndex: 1, dbManager: dbManager + ) + + // 5. Assert + XCTAssertNil(followUpPageError) + // Result should contain all items passed in, as the parent is not included. + XCTAssertEqual( + followUpPageResult?.count, 5, "Follow-up page result should contain all its items." + ) + let followUpChildMetadata = dbManager.itemMetadata(ocId: "pagedChild5") + XCTAssertNotNil( + followUpChildMetadata, "Child metadata should be saved to DB from follow-up page." + ) + + // --- Scenario C: Root Folder (should not be ingested) --- + // 6. Act + var rootNKFile = MockRemoteItem.rootItem(account: Self.account).toNKFile() + // The check is based on URL string matching. + rootNKFile.path = Self.account.davFilesUrl + + let (rootResult, rootError) = Enumerator.handlePagedReadResults( + files: [rootNKFile], pageIndex: 0, dbManager: dbManager + ) + + // 7. Assert + XCTAssertNil(rootError) + // The result should be empty as there are no children. + XCTAssertEqual( + rootResult?.count, 0, "Root folder enumeration should yield no children." + ) + // Metadata should be saved for the root folder ID itself. + XCTAssertNotNil( + dbManager.itemMetadata(ocId: rootNKFile.ocId), + "Metadata for the root folder itself should be saved." + ) + } + + func testWorkingSetEnumerateChanges() async throws { + // This test verifies that `enumerateChanges` for the working set correctly + // queries the local database for changes since the provided sync anchor date. + + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let anchorDate = Date().addingTimeInterval(-300) // 5 minutes ago + let tenMinutesAgo = Date().addingTimeInterval(-600) + let now = Date() + + // Create a sync anchor from our date. + let formatter = ISO8601DateFormatter() + let anchor = NSFileProviderSyncAnchor(formatter.string(from: anchorDate).data(using: .utf8)!) + + // --- Database State --- + var rootMetadata = rootItem.toItemMetadata(account: Self.account) + rootMetadata.syncTime = now + Self.dbManager.addItemMetadata(rootMetadata) + + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.syncTime = now + Self.dbManager.addItemMetadata(folderMetadata) + + // Item synced BEFORE the anchor date (should not be reported). + var oldItem = remoteItemA.toItemMetadata(account: Self.account) + oldItem.downloaded = true // Materialised + oldItem.syncTime = tenMinutesAgo + Self.dbManager.addItemMetadata(oldItem) + + // Item synced AFTER the anchor date (should be reported as updated). + var updatedItem = remoteItemB.toItemMetadata(account: Self.account) + updatedItem.downloaded = true // Materialised + updatedItem.deleted = false + updatedItem.syncTime = now + Self.dbManager.addItemMetadata(updatedItem) + + // Item marked as deleted AFTER the anchor date (should be reported as deleted). + var deletedItem = remoteItemC.toItemMetadata(account: Self.account) + deletedItem.downloaded = true // Materialised + deletedItem.deleted = true + deletedItem.syncTime = now + Self.dbManager.addItemMetadata(deletedItem) + + // Non-materialised item synced after anchor date (should be ignored). + var nonMaterialisedItem = MockRemoteItem( + identifier: "non-mat", + name: "non-mat.txt", + remotePath: Self.account.davFilesUrl + "/non-mat.txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ).toItemMetadata(account: Self.account) + nonMaterialisedItem.downloaded = false + nonMaterialisedItem.syncTime = now + Self.dbManager.addItemMetadata(nonMaterialisedItem) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), // Not needed and no remote calls should be made + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + + // 2. Act + try await observer.enumerateChanges(from: anchor) + + // 3. Assert + XCTAssertNil(observer.error, "Enumeration should complete without error.") + + // Check for updated items + XCTAssertEqual(observer.changedItems.count, 1, "There should be one updated item.") + XCTAssertEqual(observer.changedItems.first?.itemIdentifier.rawValue, updatedItem.ocId, "The correct item should be reported as updated.") + + // Check for deleted items + XCTAssertEqual(observer.deletedItemIdentifiers.count, 1, "There should be one deleted item.") + XCTAssertEqual(observer.deletedItemIdentifiers.first?.rawValue, deletedItem.ocId, "The correct item should be reported as deleted.") + } + + func testFolderEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let oldEtag = "OLD" + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.etag = oldEtag + + Self.dbManager.addItemMetadata(folderMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + XCTAssertEqual(observer.items.count, 3) + + // A pass of enumerating a target should update the target too. Let's check. + let dbFolderMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteFolder.identifier) + ) + let storedFolderItemMaybe = await Item.storedItem( + identifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedFolderItem = try XCTUnwrap(storedFolderItemMaybe) + XCTAssertEqual(dbFolderMetadata.etag, remoteFolder.versionIdentifier) + XCTAssertNotEqual(dbFolderMetadata.etag, oldEtag) + XCTAssertEqual(storedFolderItem.childItemCount?.intValue, remoteFolder.children.count) + XCTAssertEqual(storedFolderItem.isUploaded, true) + + let retrievedItemA = try XCTUnwrap( + observer.items.first(where: { $0.itemIdentifier.rawValue == remoteItemA.identifier }) + ) + XCTAssertEqual(retrievedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(retrievedItemA.filename, remoteItemA.name) + XCTAssertEqual(retrievedItemA.parentItemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(retrievedItemA.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(retrievedItemA.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + XCTAssertEqual(retrievedItemA.isDownloaded, false) + XCTAssertEqual(retrievedItemA.isUploaded, true) + } + + func testEnumerateFile() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + var itemAMetadata = remoteItemA.toItemMetadata(account: Self.account) + itemAMetadata.downloaded = true + + Self.dbManager.addItemMetadata(folderMetadata) + Self.dbManager.addItemMetadata(itemAMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemA.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + XCTAssertEqual(observer.items.count, 1) + + let retrievedItemAItem = try XCTUnwrap(observer.items.first) + XCTAssertEqual(retrievedItemAItem.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(retrievedItemAItem.filename, remoteItemA.name) + XCTAssertEqual(retrievedItemAItem.parentItemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(retrievedItemAItem.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(retrievedItemAItem.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + XCTAssertEqual(retrievedItemAItem.isDownloaded, true) + XCTAssertEqual(retrievedItemAItem.isUploaded, true) + + let dbItemAMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemA.identifier) + ) + XCTAssertEqual(dbItemAMetadata.ocId, remoteItemA.identifier) + XCTAssertEqual(dbItemAMetadata.etag, remoteItemA.versionIdentifier) + XCTAssertTrue(dbItemAMetadata.downloaded) + + // Check download state is not just always true + Self.dbManager.addItemMetadata(remoteItemB.toItemMetadata(account: Self.account)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemB.identifier)) + let enumerator2 = Enumerator( + enumeratedItemIdentifier: .init(remoteItemB.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer2 = MockEnumerationObserver(enumerator: enumerator2) + try await observer2.enumerateItems() + XCTAssertEqual(observer2.items.count, 1) + + let dbItemBMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemB.identifier) + ) + XCTAssertEqual(dbItemBMetadata.ocId, remoteItemB.identifier) + XCTAssertEqual(dbItemBMetadata.etag, remoteItemB.versionIdentifier) + XCTAssertFalse(dbItemBMetadata.downloaded) + } + + func testFolderAndContentsChangeEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + remoteFolder.children.removeAll(where: { $0.identifier == remoteItemB.identifier }) + remoteFolder.children.append(remoteItemC) + remoteItemC.parent = remoteFolder + + let oldFolderEtag = "OLD" + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.etag = oldFolderEtag + folderMetadata.downloaded = true // Test downloaded state is properly retained + + let oldItemAEtag = "OLD" + var itemAMetadata = remoteItemA.toItemMetadata(account: Self.account) + itemAMetadata.etag = oldItemAEtag + itemAMetadata.downloaded = true // Test downloaded state is properly retained + + let itemBMetadata = remoteItemB.toItemMetadata(account: Self.account) + + Self.dbManager.addItemMetadata(folderMetadata) + Self.dbManager.addItemMetadata(itemAMetadata) + Self.dbManager.addItemMetadata(itemBMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemA.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemB.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + // There are four changes: changed folder, changed Item A, removed Item B, added Item C + XCTAssertEqual(observer.changedItems.count, 3) + XCTAssertTrue(observer.changedItems.contains( + where: { $0.itemIdentifier.rawValue == remoteItemA.identifier } + )) + XCTAssertTrue(observer.changedItems.contains( + where: { $0.itemIdentifier.rawValue == remoteItemC.identifier } + )) + XCTAssertEqual(observer.deletedItemIdentifiers.count, 1) + XCTAssertTrue(observer.deletedItemIdentifiers.contains( + where: { $0.rawValue == remoteItemB.identifier } + )) + + // A pass of enumerating a target should update the target too. Let's check. + let dbFolderMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteFolder.identifier) + ) + let dbItemAMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemA.identifier) + ) + let dbItemBMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemB.identifier) + ) + let dbItemCMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemC.identifier) + ) + XCTAssertTrue(dbItemBMetadata.deleted) + XCTAssertEqual(dbFolderMetadata.etag, remoteFolder.versionIdentifier) + XCTAssertTrue(dbFolderMetadata.downloaded) + XCTAssertEqual(dbItemAMetadata.etag, remoteItemA.versionIdentifier) + XCTAssertNotEqual(dbItemAMetadata.etag, oldItemAEtag) + XCTAssertTrue(dbItemAMetadata.downloaded) + XCTAssertEqual(dbItemCMetadata.ocId, remoteItemC.identifier) + XCTAssertEqual(dbItemCMetadata.etag, remoteItemC.versionIdentifier) + XCTAssertEqual(dbItemCMetadata.fileName, remoteItemC.name) + XCTAssertEqual(dbItemCMetadata.fileNameView, remoteItemC.name) + XCTAssertEqual(dbItemCMetadata.serverUrl, remoteFolder.remotePath) + XCTAssertEqual(dbItemCMetadata.account, Self.account.ncKitAccount) + XCTAssertEqual(dbItemCMetadata.user, Self.account.username) + XCTAssertEqual(dbItemCMetadata.userId, Self.account.id) + XCTAssertEqual(dbItemCMetadata.urlBase, Self.account.serverUrl) + XCTAssertFalse(dbItemCMetadata.downloaded) + + let storedFolderItemMaybe = await Item.storedItem( + identifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + XCTAssertNotNil(storedFolderItemMaybe) + + let retrievedItemA = try XCTUnwrap(observer.changedItems.first( + where: { $0.itemIdentifier.rawValue == remoteItemA.identifier } + )) + XCTAssertEqual(retrievedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(retrievedItemA.filename, remoteItemA.name) + XCTAssertEqual(retrievedItemA.parentItemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(retrievedItemA.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(retrievedItemA.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + + let retrievedItemC = try XCTUnwrap(observer.changedItems.first( + where: { $0.itemIdentifier.rawValue == remoteItemC.identifier } + )) + XCTAssertEqual(retrievedItemC.itemIdentifier.rawValue, remoteItemC.identifier) + XCTAssertEqual(retrievedItemC.filename, remoteItemC.name) + XCTAssertEqual(retrievedItemC.parentItemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(retrievedItemC.creationDate, remoteItemC.creationDate) + XCTAssertEqual( + Int(retrievedItemC.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteItemC.modificationDate.timeIntervalSince1970) + ) + } + + func testFileMoveChangeEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + remoteFolder.children.removeAll(where: { $0.identifier == remoteItemA.identifier }) + rootItem.children.append(remoteItemA) + remoteItemA.parent = rootItem + remoteItemA.remotePath = rootItem.remotePath + "/\(remoteItemA.name)" + + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.etag = "OLD" + + let oldEtag = "OLD" + let oldServerUrl = remoteFolder.remotePath + let oldName = "oldItemA" + var itemAMetadata = remoteItemA.toItemMetadata(account: Self.account) + itemAMetadata.etag = oldEtag + itemAMetadata.name = oldName + itemAMetadata.fileName = oldName + itemAMetadata.fileNameView = oldName + itemAMetadata.serverUrl = oldServerUrl + + let itemBMetadata = remoteItemB.toItemMetadata(account: Self.account) + + Self.dbManager.addItemMetadata(folderMetadata) + Self.dbManager.addItemMetadata(itemAMetadata) + Self.dbManager.addItemMetadata(itemBMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemA.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemB.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + // rootContainer has changed, folder has changed, itemA has changed + XCTAssertEqual(observer.changedItems.count, 3) + XCTAssertTrue(observer.deletedItemIdentifiers.isEmpty) + + let retrievedItemA = try XCTUnwrap(observer.changedItems.first( + where: { $0.itemIdentifier.rawValue == remoteItemA.identifier } + )) + XCTAssertEqual(retrievedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(retrievedItemA.filename, remoteItemA.name) + XCTAssertEqual(retrievedItemA.parentItemIdentifier.rawValue, rootItem.identifier) + XCTAssertEqual(retrievedItemA.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(retrievedItemA.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + + let storedItemAMaybe = await Item.storedItem( + identifier: .init(remoteItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemA = try XCTUnwrap(storedItemAMaybe) + XCTAssertEqual(storedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(storedItemA.filename, remoteItemA.name) + XCTAssertEqual(storedItemA.parentItemIdentifier.rawValue, rootItem.identifier) + XCTAssertEqual(storedItemA.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(storedItemA.contentModificationDate?.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + + let storedRootItem = await Item.rootContainer( + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + remoteSupportsTrash: remoteInterface.supportsTrash(account: Self.account), + log: FileProviderLogMock() + ) + print(storedRootItem.metadata.serverUrl) + XCTAssertEqual(storedRootItem.childItemCount?.intValue, 4) // All items + + let storedFolderMaybe = await Item.storedItem( + identifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedFolder = try XCTUnwrap(storedFolderMaybe) + XCTAssertEqual(storedFolder.childItemCount?.intValue, remoteFolder.children.count) + } + + func testFileLockStateEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + remoteFolder.children.append(remoteItemC) + remoteItemC.parent = remoteFolder + + remoteItemA.locked = true + remoteItemA.lockOwner = Self.account.username + remoteItemA.lockTimeOut = Date.now.advanced(by: 1_000_000_000_000) + + remoteItemB.locked = true + remoteItemB.lockOwner = "other different account" + remoteItemB.lockTimeOut = Date.now.advanced(by: 1_000_000_000_000) + + remoteItemC.locked = true + remoteItemC.lockOwner = "other different account" + remoteItemC.lockTimeOut = Date.now.advanced(by: -1_000_000_000_000) + + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.etag = "OLD" + + Self.dbManager.addItemMetadata(folderMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + XCTAssertEqual(observer.changedItems.count, 4) + + let dbItemAMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemA.identifier) + ) + let dbItemBMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemB.identifier) + ) + let dbItemCMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemC.identifier) + ) + + XCTAssertEqual(dbItemAMetadata.lock, remoteItemA.locked) + XCTAssertEqual(dbItemAMetadata.lockOwner, remoteItemA.lockOwner) + XCTAssertEqual(dbItemAMetadata.lockTimeOut, remoteItemA.lockTimeOut) + + XCTAssertEqual(dbItemBMetadata.lock, remoteItemB.locked) + XCTAssertEqual(dbItemBMetadata.lockOwner, remoteItemB.lockOwner) + XCTAssertEqual(dbItemBMetadata.lockTimeOut, remoteItemB.lockTimeOut) + + XCTAssertEqual(dbItemCMetadata.lock, remoteItemC.locked) + XCTAssertEqual(dbItemCMetadata.lockOwner, remoteItemC.lockOwner) + XCTAssertEqual(dbItemCMetadata.lockTimeOut, remoteItemC.lockTimeOut) + + let storedItemAMaybe = await Item.storedItem( + identifier: .init(remoteItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemA = try XCTUnwrap(storedItemAMaybe) + let storedItemBMaybe = await Item.storedItem( + identifier: .init(remoteItemB.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemB = try XCTUnwrap(storedItemBMaybe) + let storedItemCMaybe = await Item.storedItem( + identifier: .init(remoteItemC.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemC = try XCTUnwrap(storedItemCMaybe) + + // Should be able to write to files locked by self + XCTAssertTrue(storedItemA.fileSystemFlags.contains(.userWritable)) + // Should not be able to write to files locked by someone else + XCTAssertFalse(storedItemB.fileSystemFlags.contains(.userWritable)) + // Should be able to write to files with an expired lock + XCTAssertTrue(storedItemC.fileSystemFlags.contains(.userWritable)) + } + + // File Provider system will panic if we give it an NSFileProviderItem with an empty filename. + // Test that we have a fallback to avoid this, even if something catastrophic happens in the + // server and the file has no filename + func testEnsureNoEmptyItemNameEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + remoteItemA.name = "" + remoteItemA.parent = remoteInterface.rootItem + rootItem.children = [remoteItemA] + + let enumerator = Enumerator( + enumeratedItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + // rootContainer has changed, itemA has changed + XCTAssertEqual(observer.changedItems.count, 2) + + let dbItemAMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemA.identifier) + ) + XCTAssertEqual(dbItemAMetadata.ocId, remoteItemA.identifier) + XCTAssertEqual(dbItemAMetadata.fileName, remoteItemA.name) + + let storedItemAMaybe = await Item.storedItem( + identifier: .init(remoteItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemA = try XCTUnwrap(storedItemAMaybe) + XCTAssertEqual(storedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertNotEqual(storedItemA.filename, remoteItemA.name) + XCTAssertFalse(storedItemA.filename.isEmpty) + } + + func testTrashEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let enumerator = Enumerator( + enumeratedItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + XCTAssertEqual(observer.items.count, 3) + + let storedItemAMaybe = await Item.storedItem( + identifier: .init(remoteTrashItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemA = try XCTUnwrap(storedItemAMaybe) + XCTAssertEqual(storedItemA.itemIdentifier.rawValue, remoteTrashItemA.identifier) + XCTAssertEqual(storedItemA.filename, remoteTrashItemA.name) + XCTAssertEqual(storedItemA.documentSize?.int64Value, remoteTrashItemA.size) + XCTAssertEqual(storedItemA.isDownloaded, false) + XCTAssertEqual(storedItemA.isUploaded, true) + + let storedItemBMaybe = await Item.storedItem( + identifier: .init(remoteTrashItemB.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemB = try XCTUnwrap(storedItemBMaybe) + XCTAssertEqual(storedItemB.itemIdentifier.rawValue, remoteTrashItemB.identifier) + XCTAssertEqual(storedItemB.filename, remoteTrashItemB.name) + XCTAssertEqual(storedItemB.documentSize?.int64Value, remoteTrashItemB.size) + XCTAssertEqual(storedItemB.isDownloaded, false) + XCTAssertEqual(storedItemB.isUploaded, true) + + let storedItemCMaybe = await Item.storedItem( + identifier: .init(remoteTrashItemC.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemC = try XCTUnwrap(storedItemCMaybe) + XCTAssertEqual(storedItemC.itemIdentifier.rawValue, remoteTrashItemC.identifier) + XCTAssertEqual(storedItemC.filename, remoteTrashItemC.name) + XCTAssertEqual(storedItemC.documentSize?.int64Value, remoteTrashItemC.size) + XCTAssertEqual(storedItemC.isDownloaded, false) + XCTAssertEqual(storedItemC.isUploaded, true) + } + + func testTrashChangeEnumeration() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + rootTrashItem.children = [remoteTrashItemA] + remoteTrashItemA.parent = rootTrashItem + remoteTrashItemB.parent = nil + remoteTrashItemC.parent = nil + + Self.dbManager.addItemMetadata( + remoteTrashItemA.toNKTrash().toItemMetadata(account: Self.account) + ) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemA.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + XCTAssertEqual(observer.changedItems.count, 0) + observer.reset() + + rootTrashItem.children = [remoteTrashItemA, remoteTrashItemB] + remoteTrashItemB.parent = rootTrashItem + try await observer.enumerateChanges() + XCTAssertEqual(observer.changedItems.count, 1) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemB.identifier)) + observer.reset() + + rootTrashItem.children = [remoteTrashItemB, remoteTrashItemC] + remoteTrashItemA.parent = nil + remoteTrashItemC.parent = rootTrashItem + try await observer.enumerateChanges() + XCTAssertEqual(observer.changedItems.count, 1) + XCTAssertEqual(observer.deletedItemIdentifiers.count, 1) + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: remoteTrashItemA.identifier)?.deleted, true) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemB.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteTrashItemC.identifier)) + } + + func testTrashItemEnumerationFailWhenNoTrashInCapabilities() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let enumerator = Enumerator( + enumeratedItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + do { + try await observer.enumerateItems() + XCTFail("Item enumeration should have failed!") + } catch { + XCTAssertEqual((error as NSError?)?.code, NSFeatureUnsupportedError) + } + } + + func testKeepDownloadedRetainedDuringEnumeration() async throws { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let existingFolder = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(existingFolder) + + // Setup existing item with keepDownloaded = true + var existingItem = remoteItemA.toItemMetadata(account: Self.account) + existingItem.keepDownloaded = true + existingItem.downloaded = true + Self.dbManager.addItemMetadata(existingItem) + + // Simulate server response with updated etag but no keepDownloaded + remoteFolder.versionIdentifier = "NEW" + remoteItemA.versionIdentifier = "NEW_ETAG" + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + + // Verify the updated metadata + let updatedMetadata = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: remoteItemA.identifier) + ) + XCTAssertTrue(updatedMetadata.keepDownloaded, "keepDownloaded should remain true after enumeration") + XCTAssertEqual(updatedMetadata.etag, "NEW_ETAG", "Etag should be updated") + XCTAssertTrue(updatedMetadata.downloaded, "Downloaded state should be preserved") + } + + func testTrashChangeEnumerationFailWhenNoTrashInCapabilities() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let enumerator = Enumerator( + enumeratedItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + do { + try await observer.enumerateChanges() + XCTFail("Item enumeration should have failed!") + } catch { + XCTAssertEqual((error as NSError?)?.code, NSFeatureUnsupportedError) + } + } + + func testRemoteLockFilesNotEnumerated() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) // Avoid build-time warning about unused variable, ensure compiler won't free + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + + let remoteLockFileItem = MockRemoteItem( + identifier: "lock-file", + name: "~$lock-file.docx", + remotePath: Self.account.davFilesUrl + "/" + remoteFolder.name + "/~$lock-file.docx", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children.append(remoteLockFileItem) + remoteLockFileItem.parent = rootItem + + let enumerator = Enumerator( + enumeratedItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + XCTAssertEqual(observer.items.count, 2) + XCTAssertFalse( + observer.items.contains(where: { $0.itemIdentifier.rawValue == "lock-file" }) + ) + } + + // Tests situation where we are enumerating files and we can no longer find the parent item + // in the database. So we need to simulate a situation where this takes place. + func testCorrectEnumerateFileWithMissingParentInDb() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + var itemAMetadata = remoteItemA.toItemMetadata(account: Self.account) + itemAMetadata.etag = "OLD" + + Self.dbManager.addItemMetadata(itemAMetadata) + XCTAssertNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItemA.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockChangeObserver(enumerator: enumerator) + try await observer.enumerateChanges() + XCTAssertEqual(observer.changedItems.count, 2) // Must include the folder that was missing + XCTAssertTrue(observer.deletedItemIdentifiers.isEmpty) + + let retrievedItemA = try XCTUnwrap(observer.changedItems.first( + where: { $0.itemIdentifier.rawValue == remoteItemA.identifier } + )) + XCTAssertEqual(retrievedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(retrievedItemA.filename, remoteItemA.name) + XCTAssertEqual(retrievedItemA.parentItemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(retrievedItemA.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(retrievedItemA.contentModificationDate??.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + + let storedItemAMaybe = await Item.storedItem( + identifier: .init(remoteItemA.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let storedItemA = try XCTUnwrap(storedItemAMaybe) + XCTAssertEqual(storedItemA.itemIdentifier.rawValue, remoteItemA.identifier) + XCTAssertEqual(storedItemA.filename, remoteItemA.name) + XCTAssertEqual(storedItemA.parentItemIdentifier.rawValue, remoteFolder.identifier) + XCTAssertEqual(storedItemA.creationDate, remoteItemA.creationDate) + XCTAssertEqual( + Int(storedItemA.contentModificationDate?.timeIntervalSince1970 ?? 0), + Int(remoteItemA.modificationDate.timeIntervalSince1970) + ) + } + + func testFolderPaginatedEnumeration() async throws { + remoteFolder.children = [] + for i in 0 ... 20 { + let childItem = MockRemoteItem( + identifier: "folderChild\(i)", + name: "folderChild\(i).txt", + remotePath: Self.account.davFilesUrl + "folder/folderChild\(i).txt", + directory: i % 5 == 0, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = remoteFolder + remoteFolder.children.append(childItem) + } + + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) + + let oldEtag = "OLD" + var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + folderMetadata.etag = oldEtag + + Self.dbManager.addItemMetadata(folderMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + XCTAssertEqual(observer.items.count, 22) + + for item in observer.items { + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: item.itemIdentifier.rawValue)) + } + XCTAssertEqual( + observer.items.count(where: { $0.contentType?.conforms(to: .folder) ?? false }), + 6 + ) + XCTAssertTrue(observer.items.last?.contentType?.conforms(to: .folder) ?? false) + + XCTAssertEqual(observer.observedPages.first, NSFileProviderPage.initialPageSortedByName as NSFileProviderPage) + // XCTAssertEqual(observer.observedPages.count, 5) + } + + func testEmptyFolderPaginatedEnumeration() async throws { + // 1. Setup: remoteFolder exists in the DB but has no children. + // Ensure the folder itself is in the database, as the enumerator for a specific item + // will try to fetch its metadata. + remoteFolder.children = [] // Ensure it's empty for this test + Self.dbManager.addItemMetadata(remoteFolder.toItemMetadata(account: Self.account)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier), "Folder metadata should be in DB for enumeration.") + + let db = Self.dbManager.ncDatabase() // Strong ref for in-memory test db + debugPrint(db) + // Enable pagination in MockRemoteInterface to ensure the pagination path is taken + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) + + // 2. Create enumerator for the empty folder with a specific pageSize. + let enumerator = Enumerator( + enumeratedItemIdentifier: NSFileProviderItemIdentifier(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5, // Page size can be anything, as the folder is empty + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 3. Enumerate items. + try await observer.enumerateItems() + + // 4. Assertions. + // When enumerating a folder (even an empty one) with depth .targetAndDirectChildren, + // the folder item itself should be returned. + XCTAssertEqual(observer.items.count, 1, "Should enumerate nothing.") + + // For an empty folder, there's only one "page" of results (the folder itself). + XCTAssertEqual(observer.observedPages.count, 1, "Should be one page call for an empty folder.") + + // Verify the folder's metadata in the database is up-to-date. + // This ensures the enumeration process also updates the target item's metadata if necessary + let dbFolderMetadata = + try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertEqual( + dbFolderMetadata.etag, + remoteFolder.versionIdentifier, + "Folder ETag should be updated in DB if changed by enumeration." + ) + let storedFolderItem = Item( + metadata: dbFolderMetadata, + parentItemIdentifier: .init(rootItem.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let childItemCount = storedFolderItem.childItemCount as? Int + let expectedChildItemCount = remoteFolder.children.count + XCTAssertEqual(childItemCount, expectedChildItemCount) + } + + func testFolderWithFewItemsPaginatedEnumeration() async throws { + // 1. Setup: remoteFolder with 3 children (fewer than pageSize 5). + // Add folder metadata to DB. + remoteFolder.children = [] + for i in 0 ..< 3 { + let childItem = MockRemoteItem( + identifier: "fewItems-child\(i)", + name: "child\(i).txt", + remotePath: remoteFolder.remotePath + "/child\(i).txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + childItem.parent = remoteFolder + remoteFolder.children.append(childItem) + } + Self.dbManager.addItemMetadata(remoteFolder.toItemMetadata(account: Self.account)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) + + // 2. Create enumerator with pageSize > number of children. + let enumerator = Enumerator( + enumeratedItemIdentifier: NSFileProviderItemIdentifier(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + pageSize: 5, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + + // 3. Enumerate items. + try await observer.enumerateItems() + + // 4. Assertions. + // Expected items: 1 (folder itself) + 3 children = 3 items. + XCTAssertEqual(observer.items.count, 4, "Should enumerate the folder and 3 children.") + XCTAssertTrue( + observer.items.contains(where: { $0.itemIdentifier.rawValue == remoteFolder.identifier }), + "Folder itself should be enumerated." + ) + for i in 0 ..< 3 { + XCTAssertTrue( + observer.items.contains(where: { $0.itemIdentifier.rawValue == "fewItems-child\(i)" }), + "Child item fewItems-child\(i) should be enumerated." + ) + } + + // All items fit on one page. + XCTAssertEqual( + observer.observedPages.count, + 1, + "Should be one page call as all items fit in the first page." + ) + + // Verify folder metadata in DB. + let dbFolderMetadata = + try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertEqual(dbFolderMetadata.etag, remoteFolder.versionIdentifier) + // Ensure all children are also in the DB after enumeration + for i in 0 ..< 3 { + XCTAssertNotNil( + Self.dbManager.itemMetadata(ocId: "fewItems-child\(i)"), + "Child item fewItems-child\(i) metadata should be in DB." + ) + } + } + + func testVisitedDirectoryStatePreservedDuringUpdate() async throws { + // This test verifies that visitedDirectory state is preserved when updating + // existing folder metadata during enumeration, addressing the fix in FilesDatabaseManager + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Setup root container metadata in database (required for enumeration) + Self.dbManager.addItemMetadata(rootItem.toItemMetadata(account: Self.account)) + + // 1. Setup: Create a folder that has already been visited (visitedDirectory = true) + var existingFolderMetadata = remoteFolder.toItemMetadata(account: Self.account) + existingFolderMetadata.visitedDirectory = true + existingFolderMetadata.etag = "OLD_ETAG" + Self.dbManager.addItemMetadata(existingFolderMetadata) + + // Verify initial state + let initialMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertTrue(initialMetadata.visitedDirectory, "Folder should initially be marked as visited") + XCTAssertEqual(initialMetadata.etag, "OLD_ETAG") + + // 2. Act: Enumerate the folder, which will trigger an update + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + + // 3. Assert: Verify that visitedDirectory state is preserved after update + let updatedMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertTrue(updatedMetadata.visitedDirectory, + "visitedDirectory state should be preserved during update") + XCTAssertEqual(updatedMetadata.etag, remoteFolder.versionIdentifier, + "ETag should be updated to new value") + XCTAssertNotEqual(updatedMetadata.etag, "OLD_ETAG", + "ETag should have changed from old value") + } + + func testVisitedDirectorySetDuringDirectoryRead() async throws { + // This test verifies that visitedDirectory is correctly set to true + // when a directory is the target of a depth-1 read operation + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Setup root container metadata in database (required for enumeration) + Self.dbManager.addItemMetadata(rootItem.toItemMetadata(account: Self.account)) + + // 1. Setup: Create a new folder that hasn't been visited yet + var newFolderMetadata = remoteFolder.toItemMetadata(account: Self.account) + newFolderMetadata.visitedDirectory = false + newFolderMetadata.etag = "INITIAL_ETAG" + Self.dbManager.addItemMetadata(newFolderMetadata) + + // Verify initial state + let initialMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertFalse(initialMetadata.visitedDirectory, "Folder should initially not be marked as visited") + + // 2. Act: Enumerate the folder (depth-1 read) + let enumerator = Enumerator( + enumeratedItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let observer = MockEnumerationObserver(enumerator: enumerator) + try await observer.enumerateItems() + + // 3. Assert: Verify that visitedDirectory is now set to true + let updatedMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertTrue(updatedMetadata.visitedDirectory, + "visitedDirectory should be set to true after directory enumeration") + XCTAssertEqual(updatedMetadata.etag, remoteFolder.versionIdentifier, + "ETag should be updated") + } + + func testVisitedDirectoryStateInWorkingSet() async throws { + // This test verifies that folders marked as visitedDirectory appear in working set + // and that the state is preserved correctly across operations + let db = Self.dbManager.ncDatabase() + debugPrint(db) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Setup root container metadata in database (required for enumeration) + Self.dbManager.addItemMetadata(rootItem.toItemMetadata(account: Self.account)) + + // 1. Setup: Create folders with different visited states + var visitedFolder = remoteFolder.toItemMetadata(account: Self.account) + visitedFolder.visitedDirectory = true + visitedFolder.downloaded = false // Not downloaded, but visited + Self.dbManager.addItemMetadata(visitedFolder) + + var notVisitedFolder = MockRemoteItem( + identifier: "notVisitedFolder", + versionIdentifier: "V1", + name: "NotVisitedFolder", + remotePath: Self.account.davFilesUrl + "/NotVisitedFolder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ).toItemMetadata(account: Self.account) + notVisitedFolder.visitedDirectory = false + notVisitedFolder.downloaded = false + Self.dbManager.addItemMetadata(notVisitedFolder) + + // 2. Act: Enumerate working set + let workingSetEnumerator = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let workingSetObserver = MockEnumerationObserver(enumerator: workingSetEnumerator) + try await workingSetObserver.enumerateItems() + + // 3. Assert: Only visited folder should be in working set + let workingSetIds = Set(workingSetObserver.items.map(\.itemIdentifier.rawValue)) + XCTAssertTrue(workingSetIds.contains(visitedFolder.ocId), + "Visited folder should be in working set") + XCTAssertFalse(workingSetIds.contains(notVisitedFolder.ocId), + "Non-visited folder should not be in working set") + + // 4. Act: Now enumerate the not-visited folder to make it visited + // Add the not-visited folder to the remote structure for enumeration + let notVisitedRemoteFolder = MockRemoteItem( + identifier: notVisitedFolder.ocId, + versionIdentifier: "V2", + name: notVisitedFolder.fileName, + remotePath: notVisitedFolder.serverUrl + "/" + notVisitedFolder.fileName, + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children.append(notVisitedRemoteFolder) + notVisitedRemoteFolder.parent = rootItem + + let folderEnumerator = Enumerator( + enumeratedItemIdentifier: .init(notVisitedFolder.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let folderObserver = MockEnumerationObserver(enumerator: folderEnumerator) + try await folderObserver.enumerateItems() + + // 5. Assert: Verify the folder is now marked as visited + let updatedNotVisitedMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: notVisitedFolder.ocId)) + XCTAssertTrue(updatedNotVisitedMetadata.visitedDirectory, + "Folder should now be marked as visited after enumeration") + + // 6. Act: Enumerate working set again + let workingSetEnumerator2 = Enumerator( + enumeratedItemIdentifier: .workingSet, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let workingSetObserver2 = MockEnumerationObserver(enumerator: workingSetEnumerator2) + try await workingSetObserver2.enumerateItems() + + // 7. Assert: Both folders should now be in working set + let workingSetIds2 = Set(workingSetObserver2.items.map(\.itemIdentifier.rawValue)) + XCTAssertTrue(workingSetIds2.contains(visitedFolder.ocId), + "Original visited folder should still be in working set") + XCTAssertTrue(workingSetIds2.contains(notVisitedFolder.ocId), + "Newly visited folder should now be in working set") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift new file mode 100644 index 0000000000000..ad3b22e88da11 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift @@ -0,0 +1,1373 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import RealmSwift +import TestInterface +import XCTest + +final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + + func testFilesDatabaseManagerInitialization() { + XCTAssertNotNil(Self.dbManager, "FilesDatabaseManager should be initialized") + } + + func testAnyItemMetadatasForAccount() throws { + // Insert test data + let expected = true + let testAccount = "TestAccount" + let metadata = RealmItemMetadata() + metadata.account = testAccount + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(metadata) + } + + // Perform test + let result = Self.dbManager.anyItemMetadatasForAccount(testAccount) + XCTAssertEqual( + result, + expected, + "anyItemMetadatasForAccount should return \(expected) for existing account" + ) + } + + func testItemMetadataFromOcId() throws { + let ocId = "unique-id-123" + let metadata = RealmItemMetadata() + metadata.ocId = ocId + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(metadata) + } + + let fetchedMetadata = Self.dbManager.itemMetadata(ocId: ocId) + XCTAssertNotNil(fetchedMetadata, "Should fetch metadata with the specified ocId") + XCTAssertEqual( + fetchedMetadata?.ocId, ocId, "Fetched metadata ocId should match the requested ocId" + ) + } + + func testUpdateItemMetadatas() { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + var metadata = SendableItemMetadata(ocId: "test", fileName: "test", account: account) + metadata.downloaded = true + metadata.uploaded = true + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [metadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.newMetadatas?.isEmpty, false, "Should create new metadatas") + XCTAssertEqual(result.updatedMetadatas?.isEmpty, true, "No metadata should be updated") + + // Now test we are receiving the updated basic metadatas correctly. + metadata.etag = "new and shiny" + + let result2 = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [metadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result2.newMetadatas?.isEmpty, true, "Should create no new metadatas") + XCTAssertEqual(result2.updatedMetadatas?.isEmpty, false, "Metadata should be updated") + + // Also check the download state is correctly kept the same. + // We set it to false here to replicate the lack of a download state when converting from + // the NKFiles received during remote enumeration + metadata.downloaded = false + metadata.etag = "new and shiny, but keeping original download state" + + let result3 = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [metadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result3.newMetadatas?.isEmpty, true, "Should create no new metadatas") + XCTAssertEqual(result3.updatedMetadatas?.isEmpty, false, "Metadata should be updated") + XCTAssertEqual(result3.updatedMetadatas?.first?.downloaded, true) + } + + func testUpdateRenamesDirectoryAndPropagatesToChildren() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + var rootMetadata = SendableItemMetadata(ocId: "root", fileName: "", account: Self.account) + rootMetadata.directory = true + Self.dbManager.addItemMetadata(rootMetadata) + + // Insert original parent directory + var parent = SendableItemMetadata(ocId: "dir1", fileName: "oldDir", account: account) + parent.directory = true + parent.serverUrl = account.davFilesUrl + parent.downloaded = true + parent.uploaded = true + Self.dbManager.addItemMetadata(parent) + + // Insert a child item inside that directory + var child = SendableItemMetadata(ocId: "child1", fileName: "file.txt", account: account) + child.serverUrl = account.davFilesUrl + "/oldDir" + child.downloaded = true + Self.dbManager.addItemMetadata(child) + + var newContainerFolder = SendableItemMetadata( + ocId: "ncf", fileName: "newContainerFolder", account: account + ) + newContainerFolder.serverUrl = account.davFilesUrl + newContainerFolder.downloaded = true + Self.dbManager.addItemMetadata(newContainerFolder) + + // Rename the directory + var renamedParent = parent + renamedParent.fileName = "newDir" + renamedParent.serverUrl = account.davFilesUrl + "/" + newContainerFolder.fileName + renamedParent.etag = "etag-changed" + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [rootMetadata, renamedParent], + keepExistingDownloadState: true + ) + + // Ensure rename took place + XCTAssertEqual(result.newMetadatas?.isEmpty, true) + XCTAssertEqual(result.updatedMetadatas?.isEmpty, false) + XCTAssertNotNil(result.updatedMetadatas?.first(where: { $0.fileName == "newDir" })) + + // Ensure the child's serverUrl was updated accordingly + let updatedChild = Self.dbManager.itemMetadata(ocId: "child1") + XCTAssertNotNil(updatedChild) + XCTAssertEqual(updatedChild?.serverUrl, account.davFilesUrl + "/newContainerFolder/newDir") + } + + func testTransitItemIsNotUpdated() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Simulate existing item in transit + var transit = SendableItemMetadata(ocId: "transit1", fileName: "temp", account: account) + transit.uploaded = true + transit.downloaded = false + transit.status = Status.downloading.rawValue + transit.etag = "old-etag" + Self.dbManager.addItemMetadata(transit) + + // Send an updated version of the same item + var incoming = transit + incoming.uploaded = true + incoming.downloaded = false + incoming.status = Status.normal.rawValue + incoming.etag = "new-etag" + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [incoming], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.updatedMetadatas?.isEmpty, true, "Transit items should not be updated") + XCTAssertEqual(result.newMetadatas?.isEmpty, true) + XCTAssertEqual(result.deletedMetadatas?.isEmpty, true) + + let inDb = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: transit.ocId)) + XCTAssertEqual(inDb.etag, transit.etag) + } + + func testTransitItemIsDeleted() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Simulate existing item in transit + var transit = SendableItemMetadata(ocId: "transit1", fileName: "temp", account: account) + transit.uploaded = true + transit.downloaded = false + transit.status = Status.downloading.rawValue + Self.dbManager.addItemMetadata(transit) + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.updatedMetadatas?.isEmpty, true) + XCTAssertEqual(result.newMetadatas?.isEmpty, true) + XCTAssertEqual(result.deletedMetadatas?.isEmpty, false) + XCTAssertEqual(result.deletedMetadatas?.first?.ocId, transit.ocId) + } + + func testSetStatusForItemMetadata() throws { + // Create and add a test metadata to the database + let metadata = RealmItemMetadata() + metadata.ocId = "unique-id-123" + metadata.status = Status.normal.rawValue + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(metadata) + } + + let expectedStatus = Status.uploadError + let updatedMetadata = Self.dbManager.setStatusForItemMetadata( + SendableItemMetadata(value: metadata), status: expectedStatus + ) + XCTAssertEqual( + updatedMetadata?.status, + expectedStatus.rawValue, + "Status should be updated to \(expectedStatus)" + ) + } + + func testAddItemMetadata() { + let metadata = SendableItemMetadata( + ocId: "unique-id-123", + fileName: "b", + account: .init(user: "t", id: "t", serverUrl: "b", password: "") + ) + Self.dbManager.addItemMetadata(metadata) + + let fetchedMetadata = Self.dbManager.itemMetadata(ocId: "unique-id-123") + XCTAssertNotNil(fetchedMetadata, "Metadata should be added to the database") + } + + func testDeleteItemMetadata() throws { + let ocId = "unique-id-123" + let metadata = RealmItemMetadata() + metadata.ocId = ocId + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(metadata) + } + + let result = Self.dbManager.deleteItemMetadata(ocId: ocId) + XCTAssertTrue(result, "deleteItemMetadata should return true on successful deletion") + XCTAssertEqual( + Self.dbManager.itemMetadata(ocId: ocId)?.deleted, + true, + "Metadata should be deleted from the database" + ) + } + + func testRenameItemMetadata() throws { + let ocId = "unique-id-123" + let newFileName = "newFileName.pdf" + let newServerUrl = "https://new.example.com" + let metadata = RealmItemMetadata() + metadata.ocId = ocId + metadata.fileName = "oldFileName.pdf" + metadata.serverUrl = "https://old.example.com" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(metadata) + } + + Self.dbManager.renameItemMetadata( + ocId: ocId, newServerUrl: newServerUrl, newFileName: newFileName + ) + + let updatedMetadata = Self.dbManager.itemMetadata(ocId: ocId) + XCTAssertEqual(updatedMetadata?.fileName, newFileName, "File name should be updated") + XCTAssertEqual(updatedMetadata?.serverUrl, newServerUrl, "Server URL should be updated") + } + + func testDeleteItemMetadatasBasedOnUpdate() throws { + // Existing metadata in the database + let existingMetadata1 = RealmItemMetadata() + existingMetadata1.ocId = "id-1" + existingMetadata1.fileName = "Existing.pdf" + existingMetadata1.serverUrl = "https://example.com" + existingMetadata1.account = "TestAccount" + existingMetadata1.downloaded = true + existingMetadata1.uploaded = true + + let existingMetadata2 = RealmItemMetadata() + existingMetadata2.ocId = "id-2" + existingMetadata2.fileName = "Existing2.pdf" + existingMetadata2.serverUrl = "https://example.com" + existingMetadata2.account = "TestAccount" + existingMetadata2.downloaded = true + existingMetadata2.uploaded = true + + let existingMetadata3 = RealmItemMetadata() + existingMetadata3.ocId = "id-3" + existingMetadata3.fileName = "Existing3.pdf" + existingMetadata3.serverUrl = "https://example.com/folder" // Different child path + existingMetadata3.account = "TestAccount" + existingMetadata3.downloaded = true + existingMetadata3.uploaded = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(existingMetadata1) + realm.add(existingMetadata2) + realm.add(existingMetadata3) + } + + // Simulate updated metadata that leads to a deletion + let updatedMetadatas = [existingMetadata1, existingMetadata3] // Only include 2 of the 3 + + _ = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: "TestAccount", + serverUrl: "https://example.com", + updatedMetadatas: updatedMetadatas.map { SendableItemMetadata(value: $0) }, + keepExistingDownloadState: true + ) + + let remainingMetadatas = Self.dbManager.itemMetadatas( + account: "TestAccount", underServerUrl: "https://example.com" + ) + XCTAssertEqual(remainingMetadatas.filter(\.deleted).count, 1) + XCTAssertEqual(remainingMetadatas.count(where: { !$0.deleted }), 2) + + XCTAssertNotNil(remainingMetadatas.first { $0.ocId == "id-1" }) + XCTAssertNotNil(remainingMetadatas.first { $0.ocId == "id-3" }) + } + + func testProcessItemMetadatasToUpdate_NewAndUpdatedSeparation() throws { + let account = Account( + user: "TestAccount", id: "taid", serverUrl: "https://example.com", password: "pass" + ) + + let parent = RealmItemMetadata() + parent.ocId = "parent" + parent.fileName = "Parent" + parent.account = "TestAccount" + parent.serverUrl = "https://example.com" + parent.directory = true + parent.downloaded = true + parent.uploaded = true + + // Simulate existing metadata in the database + let existingMetadata = RealmItemMetadata() + existingMetadata.ocId = "id-1" + existingMetadata.fileName = "File.pdf" + existingMetadata.account = "TestAccount" + existingMetadata.serverUrl = "https://example.com/Parent" + existingMetadata.downloaded = true + existingMetadata.uploaded = true + + // Simulate updated metadata that includes changes and a new entry + var updatedParent = SendableItemMetadata(ocId: "parent", fileName: "Parent", account: account) + updatedParent.directory = true + + var updatedMetadata = + SendableItemMetadata(ocId: "id-1", fileName: "UpdatedFile.pdf", account: account) + updatedMetadata.serverUrl = "https://example.com/Parent" + + var newMetadata = + SendableItemMetadata(ocId: "id-2", fileName: "NewFile.pdf", account: account) + newMetadata.serverUrl = "https://example.com/Parent" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(parent) + realm.add(existingMetadata) + } + + let results = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: "TestAccount", + serverUrl: "https://example.com/Parent", + updatedMetadatas: [updatedParent, updatedMetadata, newMetadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(results.newMetadatas?.count, 1, "Should create one new metadata") + XCTAssertEqual(results.updatedMetadatas?.count, 2, "Should update two existing metadata") + XCTAssertEqual( + results.newMetadatas?.first?.ocId, "id-2", "New metadata ocId should be 'id-2'" + ) + XCTAssertEqual( + results.updatedMetadatas?.last?.fileName, + "UpdatedFile.pdf", + "Updated metadata should have the new file name" + ) + } + + func testUnuploadedItemsAreNotDeletedDuringUpdate() throws { + let testAccount = Self.account.ncKitAccount + let testServerUrl = Self.account.davFilesUrl + // 1. Item that exists locally and is marked as uploaded + let uploadedItem = RealmItemMetadata() + uploadedItem.ocId = "ocid-uploaded-123" + uploadedItem.fileName = "SyncedFile.txt" + uploadedItem.account = testAccount + uploadedItem.serverUrl = testServerUrl + uploadedItem.downloaded = true + uploadedItem.uploaded = true // IMPORTANT: Marked as uploaded + + // 2. Item that exists locally but is NOT marked as uploaded (e.g., new local file) + let unuploadedItem = RealmItemMetadata() + unuploadedItem.ocId = "ocid-local-456" // May or may not have ocId yet + unuploadedItem.fileName = "NewLocalFile.txt" + unuploadedItem.account = testAccount + unuploadedItem.serverUrl = testServerUrl + unuploadedItem.downloaded = true + unuploadedItem.uploaded = false // IMPORTANT: Not marked as uploaded + unuploadedItem.status = Status.normal.rawValue // Ensure it's not in a transient state if relevant + + var rootMetadata = SendableItemMetadata(ocId: "root", fileName: "", account: Self.account) + rootMetadata.directory = true + + Self.dbManager.addItemMetadata(rootMetadata) + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(uploadedItem) + realm.add(unuploadedItem) + } + + XCTAssertEqual(realm.objects(RealmItemMetadata.self).where { + $0.account == testAccount && $0.serverUrl == testServerUrl + }.count, 3) + + // Simulate an update from the server that contains NEITHER of these items. + // This means the server thinks 'SyncedFile.txt' was deleted, + // and it doesn't know about 'NewLocalFile.txt' yet. + let updatedMetadatasFromServer = [rootMetadata] + + let results = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: testAccount, + serverUrl: testServerUrl, + updatedMetadatas: updatedMetadatasFromServer, + keepExistingDownloadState: true // Value doesn't strictly matter for this test logic + ) + + // --- Assertion --- + let remainingMetadatas = realm.objects(RealmItemMetadata.self) + .where { $0.account == testAccount && $0.serverUrl == testServerUrl } + + // Check the returned delete list (based on the copy made before deletion) + XCTAssertEqual(results.deletedMetadatas?.count, 1, "Should identify the uploaded item as deleted.") + XCTAssertEqual(results.deletedMetadatas?.first?.ocId, "ocid-uploaded-123", "The correct uploaded item should be marked for deletion.") + XCTAssertTrue(results.deletedMetadatas?.first?.uploaded ?? false, "The item marked for deletion should have uploaded=true") + + // Check the actual database state after the write transaction + XCTAssertEqual(remainingMetadatas.filter(\.deleted).count, 1) + XCTAssertEqual(remainingMetadatas.count(where: { !$0.deleted }), 2) + + let survivingItem = remainingMetadatas.last + XCTAssertNotNil(survivingItem, "An item should survive.") + XCTAssertEqual(survivingItem?.ocId, "ocid-local-456", "The surviving item should be the unuploaded one.") + XCTAssertEqual(survivingItem?.fileName, "NewLocalFile.txt", "Filename should match the unuploaded item.") + XCTAssertFalse(survivingItem!.uploaded, "The surviving item must be the one marked uploaded = false.") + + // Check other return values are empty as expected + XCTAssertTrue(results.newMetadatas?.isEmpty ?? true, "No new items should have been created.") + XCTAssertTrue(results.updatedMetadatas?.isEmpty ?? true, "No items should have been updated.") + } + + func testDirectoryMetadataRetrieval() throws { + let account = "TestAccount" + let serverUrl = "https://cloud.example.com/files/documents" + let directoryFileName = "documents" + let metadata = RealmItemMetadata() + metadata.ocId = "dir-1" + metadata.account = account + metadata.serverUrl = "https://cloud.example.com/files" + metadata.fileName = directoryFileName + metadata.directory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(metadata) + } + + let retrievedMetadata = Self.dbManager.itemMetadata( + account: account, locatedAtRemoteUrl: serverUrl + ) + XCTAssertNotNil(retrievedMetadata, "Should retrieve directory metadata") + XCTAssertEqual( + retrievedMetadata?.fileName, directoryFileName, "Should match the directory file name" + ) + } + + func testChildItemsForDirectory() throws { + let directoryMetadata = RealmItemMetadata() + directoryMetadata.ocId = "dir-1" + directoryMetadata.account = "TestAccount" + directoryMetadata.serverUrl = "https://cloud.example.com/files" + directoryMetadata.fileName = "documents" + directoryMetadata.directory = true + + let childMetadata = RealmItemMetadata() + childMetadata.ocId = "item-1" + childMetadata.account = "TestAccount" + childMetadata.serverUrl = "https://cloud.example.com/files/documents" + childMetadata.fileName = "report.pdf" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(directoryMetadata) + realm.add(childMetadata) + } + + let children = Self.dbManager.childItems( + directoryMetadata: SendableItemMetadata(value: directoryMetadata) + ) + XCTAssertEqual(children.count, 1, "Should return one child item") + XCTAssertEqual( + children.first?.fileName, "report.pdf", "Should match the child item's file name" + ) + } + + func testDeleteDirectoryAndSubdirectoriesMetadata() throws { + let directoryMetadata = RealmItemMetadata() + directoryMetadata.ocId = "dir-1" + directoryMetadata.account = "TestAccount" + directoryMetadata.serverUrl = "https://cloud.example.com/files" + directoryMetadata.fileName = "documents" + directoryMetadata.directory = true + + let childMetadata = RealmItemMetadata() + childMetadata.ocId = "item-1" + childMetadata.account = "TestAccount" + childMetadata.serverUrl = "https://cloud.example.com/files/documents" + childMetadata.fileName = "report.pdf" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(directoryMetadata) + realm.add(childMetadata) + } + + let deletedMetadatas = Self.dbManager.deleteDirectoryAndSubdirectoriesMetadata( + ocId: "dir-1" + ) + XCTAssertNotNil(deletedMetadatas, "Should return a list of deleted metadatas") + XCTAssertEqual(deletedMetadatas?.count, 2, "Should delete the directory and its child") + } + + func testRenameDirectoryAndPropagateToChildren() throws { + let directoryMetadata = RealmItemMetadata() + directoryMetadata.ocId = "dir-1" + directoryMetadata.account = "TestAccount" + directoryMetadata.serverUrl = "https://cloud.example.com/files" + directoryMetadata.fileName = "documents" + directoryMetadata.directory = true + + let childMetadata = RealmItemMetadata() + childMetadata.ocId = "item-1" + childMetadata.account = "TestAccount" + childMetadata.serverUrl = "https://cloud.example.com/files/documents" + childMetadata.fileName = "report.pdf" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(directoryMetadata) + realm.add(childMetadata) + } + + let updatedChildren = Self.dbManager.renameDirectoryAndPropagateToChildren( + ocId: "dir-1", + newServerUrl: "https://cloud.example.com/office", + newFileName: "files" + ) + + XCTAssertNotNil(updatedChildren, "Should return updated children metadatas") + XCTAssertEqual(updatedChildren?.count, 1, "Should include one child") + XCTAssertEqual( + updatedChildren?.first?.serverUrl, + "https://cloud.example.com/office/files", + "Should update serverUrl of child items" + ) + } + + func testErrorOnDirectoryMetadataNotFound() throws { + let nonExistentServerUrl = "https://cloud.example.com/nonexistent" + let directoryMetadata = Self.dbManager.itemMetadata( + account: "TestAccount", locatedAtRemoteUrl: nonExistentServerUrl + ) + XCTAssertNil(directoryMetadata, "Should return nil when directory metadata is not found") + } + + func testChildItemsForRootDirectory() throws { + let rootMetadata = SendableItemMetadata( + ocId: NSFileProviderItemIdentifier.rootContainer.rawValue, + fileName: "", + account: Account( + user: "TestAccount", + id: "ta", + serverUrl: "https://cloud.example.com/files", + password: "" + ) + ) // Do not write, we do not track root container + + let childMetadata = RealmItemMetadata() + childMetadata.ocId = "item-1" + childMetadata.account = "TestAccount" + childMetadata.serverUrl = rootMetadata.serverUrl + childMetadata.fileName = "report.pdf" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(childMetadata) + } + + let children = Self.dbManager.childItems(directoryMetadata: rootMetadata) + XCTAssertEqual(children.count, 1, "Should return one child item for the root directory") + XCTAssertEqual( + children.first?.fileName, + "report.pdf", + "Should match the child item's file name for root directory" + ) + } + + func testDeleteNestedDirectoriesAndSubdirectoriesMetadata() throws { + // Create nested directories and their child items + let rootDirectoryMetadata = RealmItemMetadata() + rootDirectoryMetadata.ocId = "dir-1" + rootDirectoryMetadata.account = "TestAccount" + rootDirectoryMetadata.serverUrl = "https://cloud.example.com/files" + rootDirectoryMetadata.fileName = "documents" + rootDirectoryMetadata.directory = true + + let nestedDirectoryMetadata = RealmItemMetadata() + nestedDirectoryMetadata.ocId = "dir-2" + nestedDirectoryMetadata.account = "TestAccount" + nestedDirectoryMetadata.serverUrl = "https://cloud.example.com/files/documents" + nestedDirectoryMetadata.fileName = "projects" + nestedDirectoryMetadata.directory = true + + let childMetadata = RealmItemMetadata() + childMetadata.ocId = "item-1" + childMetadata.account = "TestAccount" + childMetadata.serverUrl = "https://cloud.example.com/files/documents/projects" + childMetadata.fileName = "report.pdf" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(rootDirectoryMetadata) + realm.add(nestedDirectoryMetadata) + realm.add(childMetadata) + } + + let deletedMetadatas = Self.dbManager.deleteDirectoryAndSubdirectoriesMetadata( + ocId: "dir-1" + ) + XCTAssertNotNil(deletedMetadatas, "Should return a list of deleted metadatas") + XCTAssertEqual( + deletedMetadatas?.count, + 3, + "Should delete the root directory, nested directory, and its child" + ) + } + + func testRecursiveRenameOfDirectoriesAndChildItems() throws { + // Setup a complex directory structure + let rootDirectoryMetadata = RealmItemMetadata() + rootDirectoryMetadata.ocId = "dir-1" + rootDirectoryMetadata.account = "TestAccount" + rootDirectoryMetadata.serverUrl = "https://cloud.example.com/files" + rootDirectoryMetadata.fileName = "documents" + rootDirectoryMetadata.directory = true + + let nestedDirectoryMetadata = RealmItemMetadata() + nestedDirectoryMetadata.ocId = "dir-2" + nestedDirectoryMetadata.account = "TestAccount" + nestedDirectoryMetadata.serverUrl = "https://cloud.example.com/files/documents" + nestedDirectoryMetadata.fileName = "projects" + nestedDirectoryMetadata.directory = true + + let childMetadata = RealmItemMetadata() + childMetadata.ocId = "item-1" + childMetadata.account = "TestAccount" + childMetadata.serverUrl = "https://cloud.example.com/files/documents/projects" + childMetadata.fileName = "report.pdf" + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(rootDirectoryMetadata) + realm.add(nestedDirectoryMetadata) + realm.add(childMetadata) + } + + let updatedChildren = Self.dbManager.renameDirectoryAndPropagateToChildren( + ocId: "dir-1", + newServerUrl: "https://cloud.example.com/storage", + newFileName: "files" + ) + + XCTAssertNotNil(updatedChildren, "Should return updated children metadatas") + XCTAssertEqual(updatedChildren?.count, 2, "Should include the nested directory and child item") + XCTAssertTrue( + updatedChildren?.contains { $0.serverUrl.contains("/storage/files/") } ?? false, + "Should update serverUrl of all child items to reflect new directory path" + ) + } + + func testDeletingDirectoryWithNoChildren() throws { + let directoryMetadata = RealmItemMetadata() + directoryMetadata.ocId = "dir-1" + directoryMetadata.account = "TestAccount" + directoryMetadata.serverUrl = "https://cloud.example.com/files" + directoryMetadata.fileName = "empty" + directoryMetadata.directory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(directoryMetadata) + } + + let deletedMetadatas = Self.dbManager.deleteDirectoryAndSubdirectoriesMetadata( + ocId: "dir-1" + ) + XCTAssertNotNil( + deletedMetadatas, + "Should return a list of deleted metadatas even if the directory has no children" + ) + XCTAssertEqual( + deletedMetadatas?.count, + 1, + "Should only delete the directory itself as there are no children" + ) + } + + func testRenamingDirectoryWithComplexNestedStructure() throws { + // Create a complex nested directory structure + let rootDirectoryMetadata = RealmItemMetadata() + rootDirectoryMetadata.ocId = "dir-1" + rootDirectoryMetadata.account = "TestAccount" + rootDirectoryMetadata.serverUrl = "https://cloud.example.com/files" + rootDirectoryMetadata.fileName = "dir-1" + rootDirectoryMetadata.directory = true + + let nestedDirectoryMetadata = RealmItemMetadata() + nestedDirectoryMetadata.ocId = "dir-2" + nestedDirectoryMetadata.account = "TestAccount" + nestedDirectoryMetadata.serverUrl = "https://cloud.example.com/files/dir-1" + nestedDirectoryMetadata.fileName = "dir-2" + nestedDirectoryMetadata.directory = true + + let deepNestedDirectoryMetadata = RealmItemMetadata() + deepNestedDirectoryMetadata.ocId = "dir-3" + deepNestedDirectoryMetadata.account = "TestAccount" + deepNestedDirectoryMetadata.serverUrl = "https://cloud.example.com/files/dir-1/dir-2" + deepNestedDirectoryMetadata.fileName = "dir-3" + deepNestedDirectoryMetadata.directory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(rootDirectoryMetadata) + realm.add(nestedDirectoryMetadata) + realm.add(deepNestedDirectoryMetadata) + } + + let updatedChildren = Self.dbManager.renameDirectoryAndPropagateToChildren( + ocId: "dir-1", + newServerUrl: "https://cloud.example.com/storage", + newFileName: "archives" + ) + + XCTAssertNotNil(updatedChildren, "Should return updated children metadatas") + XCTAssertEqual(updatedChildren?.count, 2, "Should include both nested directories") + XCTAssertTrue( + updatedChildren?.allSatisfy { $0.serverUrl.contains("/storage/archives") } ?? false, + "All children should have their serverUrl updated correctly" + ) + } + + func testFindingItemBasedOnRemotePath() throws { + let account = "TestAccount" + let filename = "super duper new file" + let parentUrl = "https://cloud.example.com/files/my great and incredible dir/dir-2" + let fullUrl = parentUrl + "/" + filename + + let deepNestedDirectoryMetadata = RealmItemMetadata() + deepNestedDirectoryMetadata.ocId = filename + deepNestedDirectoryMetadata.account = account + deepNestedDirectoryMetadata.serverUrl = parentUrl + deepNestedDirectoryMetadata.fileName = filename + deepNestedDirectoryMetadata.directory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { realm.add(deepNestedDirectoryMetadata) } + + XCTAssertNotNil(Self.dbManager.itemMetadata(account: account, locatedAtRemoteUrl: fullUrl)) + } + + func testKeepDownloadedSetting() throws { + let existingMetadata = RealmItemMetadata() + existingMetadata.ocId = "id-1" + existingMetadata.fileName = "File.pdf" + existingMetadata.account = "TestAccount" + existingMetadata.serverUrl = "https://example.com" + XCTAssertFalse(existingMetadata.keepDownloaded) + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(existingMetadata) + } + + let sendable = SendableItemMetadata(value: existingMetadata) + var updatedMetadata = + try XCTUnwrap(Self.dbManager.set(keepDownloaded: true, for: sendable)) + XCTAssertTrue(updatedMetadata.keepDownloaded) + + updatedMetadata.keepDownloaded = false + let finalMetadata = + try XCTUnwrap(Self.dbManager.set(keepDownloaded: false, for: updatedMetadata)) + XCTAssertFalse(finalMetadata.keepDownloaded) + } + + func testKeepDownloadedRetainedDuringDepth1ReadUpdate() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Create initial metadata with keepDownloaded = true + var initialMetadata = SendableItemMetadata(ocId: "test-keep-downloaded", fileName: "test.txt", account: account) + initialMetadata.downloaded = true + initialMetadata.uploaded = true + initialMetadata.keepDownloaded = true + initialMetadata.etag = "old-etag" + + Self.dbManager.addItemMetadata(initialMetadata) + + // Verify initial state + let storedMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "test-keep-downloaded")) + XCTAssertTrue(storedMetadata.keepDownloaded, "Initial keepDownloaded should be true") + XCTAssertTrue(storedMetadata.downloaded, "Initial downloaded should be true") + + // Update metadata with new etag (simulating server update) + var updatedMetadata = initialMetadata + updatedMetadata.etag = "new-etag" + updatedMetadata.keepDownloaded = false // This would be the case when converting from NKFile + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [updatedMetadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.newMetadatas?.isEmpty, true, "Should create no new metadatas") + XCTAssertEqual(result.updatedMetadatas?.isEmpty, false, "Should update existing metadata") + + // Verify keepDownloaded is retained + let finalMetadata = try XCTUnwrap(result.updatedMetadatas?.first) + XCTAssertTrue(finalMetadata.keepDownloaded, "keepDownloaded should be retained during update") + XCTAssertTrue(finalMetadata.downloaded, "downloaded should be retained during update") + XCTAssertEqual(finalMetadata.etag, "new-etag", "etag should be updated") + + // Verify in database + let dbMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "test-keep-downloaded")) + XCTAssertTrue(dbMetadata.keepDownloaded, "keepDownloaded should be retained in database") + } + + func testKeepDownloadedRetainedWithKeepExistingDownloadStateFalse() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Create initial metadata with keepDownloaded = true + var initialMetadata = SendableItemMetadata(ocId: "test-keep-downloaded-false", fileName: "test.txt", account: account) + initialMetadata.downloaded = true + initialMetadata.uploaded = true + initialMetadata.keepDownloaded = true + initialMetadata.etag = "old-etag" + + Self.dbManager.addItemMetadata(initialMetadata) + + // Update metadata with new etag + var updatedMetadata = initialMetadata + updatedMetadata.etag = "new-etag" + updatedMetadata.keepDownloaded = false // This would be the case when converting from NKFile + updatedMetadata.downloaded = false // Set to false to test keepDownloaded retention + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [updatedMetadata], + keepExistingDownloadState: false // Even when not keeping download state + ) + + XCTAssertEqual(result.updatedMetadatas?.isEmpty, false, "Should update existing metadata") + + // Verify keepDownloaded is still retained even when keepExistingDownloadState is false + let finalMetadata = try XCTUnwrap(result.updatedMetadatas?.first) + XCTAssertTrue(finalMetadata.keepDownloaded, "keepDownloaded should be retained regardless of keepExistingDownloadState") + XCTAssertEqual(finalMetadata.downloaded, false, "downloaded should not be retained when keepExistingDownloadState is false") + } + + func testKeepDownloadedRetainedInReadTargetMetadata() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Create existing metadata with keepDownloaded = true + var existingMetadata = SendableItemMetadata(ocId: "read-target-test", fileName: "target.txt", account: account) + existingMetadata.keepDownloaded = true + existingMetadata.downloaded = true + existingMetadata.status = Status.normal.rawValue + + Self.dbManager.addItemMetadata(existingMetadata) + + // Create new read target metadata (simulating reading from server) + var readTargetMetadata = SendableItemMetadata(ocId: "read-target-test", fileName: "target.txt", account: account) + readTargetMetadata.etag = "new-etag" + readTargetMetadata.keepDownloaded = false // Would be false when created from NKFile + readTargetMetadata.downloaded = false + + // This simulates the path in depth1ReadUpdateItemMetadatas where readTargetMetadata + // is processed and existing properties should be retained + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [readTargetMetadata], + keepExistingDownloadState: true + ) + + let updatedMetadata = try XCTUnwrap(result.updatedMetadatas?.first) + XCTAssertTrue(updatedMetadata.keepDownloaded, "keepDownloaded should be retained in read target metadata") + XCTAssertTrue(updatedMetadata.downloaded, "downloaded should be retained when keepExistingDownloadState is true") + } + + func testKeepDownloadedNotSetForNewMetadata() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Create completely new metadata (not existing in database) + var newMetadata = SendableItemMetadata(ocId: "new-item", fileName: "new.txt", account: account) + newMetadata.etag = "initial-etag" + newMetadata.keepDownloaded = false // Should remain false for new items + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [newMetadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.newMetadatas?.isEmpty, false, "Should create new metadata") + XCTAssertEqual(result.updatedMetadatas?.isEmpty, true, "Should not update any metadata") + + let createdMetadata = try XCTUnwrap(result.newMetadatas?.first) + XCTAssertFalse(createdMetadata.keepDownloaded, "keepDownloaded should remain false for new items") + + // Verify in database + let dbMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "new-item")) + XCTAssertFalse(dbMetadata.keepDownloaded, "keepDownloaded should be false in database for new items") + } + + func testKeepDownloadedRetainedWithMultipleItems() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + var parentFolder = SendableItemMetadata(ocId: "pf", fileName: "pf", account: account) + parentFolder.uploaded = true + parentFolder.etag = "old-pf" + + // Create multiple items with different keepDownloaded states + var item1 = SendableItemMetadata(ocId: "multi-1", fileName: "file1.txt", account: account) + item1.keepDownloaded = true + item1.downloaded = true + item1.uploaded = true + item1.etag = "old-1" + item1.serverUrl = account.davFilesUrl.appending("/pf") + + var item2 = SendableItemMetadata(ocId: "multi-2", fileName: "file2.txt", account: account) + item2.keepDownloaded = false + item2.downloaded = false + item2.uploaded = true + item2.etag = "old-2" + item2.serverUrl = account.davFilesUrl.appending("/pf") + + var item3 = SendableItemMetadata(ocId: "multi-3", fileName: "file3.txt", account: account) + item3.keepDownloaded = true + item3.downloaded = false + item3.uploaded = true + item3.etag = "old-3" + item3.serverUrl = account.davFilesUrl.appending("/pf") + + Self.dbManager.addItemMetadata(parentFolder) + Self.dbManager.addItemMetadata(item1) + Self.dbManager.addItemMetadata(item2) + Self.dbManager.addItemMetadata(item3) + + // Update all items with new etags + var updatedParentFolder = parentFolder + updatedParentFolder.etag = "new-pf" + + var updatedItem1 = item1 + updatedItem1.etag = "new-1" + updatedItem1.keepDownloaded = false // Would be reset when converting from NKFile + + var updatedItem2 = item2 + updatedItem2.etag = "new-2" + updatedItem2.keepDownloaded = false + + var updatedItem3 = item3 + updatedItem3.etag = "new-3" + updatedItem3.keepDownloaded = false + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl.appending("/pf"), + updatedMetadatas: [updatedParentFolder, updatedItem1, updatedItem2, updatedItem3], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.updatedMetadatas?.count, 4, "Should update all four items") + + // Verify each item's keepDownloaded state is correctly retained + let updatedMetadatas = try XCTUnwrap(result.updatedMetadatas) + + let finalItem1 = try XCTUnwrap(updatedMetadatas.first { $0.ocId == "multi-1" }) + XCTAssertTrue(finalItem1.keepDownloaded, "Item 1 should retain keepDownloaded = true") + + let finalItem2 = try XCTUnwrap(updatedMetadatas.first { $0.ocId == "multi-2" }) + XCTAssertFalse(finalItem2.keepDownloaded, "Item 2 should retain keepDownloaded = false") + + let finalItem3 = try XCTUnwrap(updatedMetadatas.first { $0.ocId == "multi-3" }) + XCTAssertTrue(finalItem3.keepDownloaded, "Item 3 should retain keepDownloaded = true") + } + + func testParentItemIdentifierWithRemoteFallback() async throws { + let rootItem = MockRemoteItem.rootItem(account: Self.account) + + let remoteFolder = MockRemoteItem( + identifier: "folder", + versionIdentifier: "NEW", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let remoteItem = MockRemoteItem( + identifier: "item", + versionIdentifier: "NEW", + name: "item", + remotePath: Self.account.davFilesUrl + "/folder/item", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + remoteFolder.children = [remoteItem] + remoteItem.parent = remoteFolder + + let remoteItemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(remoteItemMetadata) + XCTAssertNil(Self.dbManager.parentItemIdentifierFromMetadata(remoteItemMetadata)) + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + remoteInterface.injectMock(Self.account) + + let retrievedParentIdentifier = await Self.dbManager.parentItemIdentifierWithRemoteFallback( + fromMetadata: remoteItemMetadata, + remoteInterface: remoteInterface, + account: Self.account + ) + + let unwrappedParentIdentifier = try XCTUnwrap(retrievedParentIdentifier) + XCTAssertEqual(unwrappedParentIdentifier.rawValue, remoteFolder.identifier) + } + + func testMaterialisedFiles() async throws { + let itemA = RealmItemMetadata() + let itemB = RealmItemMetadata() + let itemC = RealmItemMetadata() + let folderA = RealmItemMetadata() + let folderB = RealmItemMetadata() + let folderC = RealmItemMetadata() + let notFolderA = RealmItemMetadata() + let notFolderB = RealmItemMetadata() + + folderA.directory = true + folderB.directory = true + folderC.directory = true + + itemA.ocId = "itemA" + itemB.ocId = "itemB" + itemC.ocId = "itemC" + folderA.ocId = "folderA" + folderB.ocId = "folderB" + folderC.ocId = "folderC" + notFolderA.ocId = "notFolderA" + notFolderB.ocId = "notFolderB" + + itemA.account = Self.account.ncKitAccount + itemB.account = Self.account.ncKitAccount + itemC.account = "another account" + folderA.account = Self.account.ncKitAccount + folderB.account = Self.account.ncKitAccount + folderC.account = "another account" + notFolderA.account = Self.account.ncKitAccount + notFolderB.account = "another account" + + itemA.downloaded = true + itemB.downloaded = false + itemC.downloaded = true + folderA.visitedDirectory = true + folderB.visitedDirectory = false + folderC.visitedDirectory = true + notFolderA.visitedDirectory = true + notFolderB.visitedDirectory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { + realm.add(itemA) + realm.add(itemB) + realm.add(itemC) + realm.add(folderA) + realm.add(folderB) + realm.add(folderC) + } + + // Test with addItemMetadata too + var sItemA = SendableItemMetadata(ocId: "sItemA", fileName: "sItemA", account: Self.account) + sItemA.downloaded = true + + var sItemB = SendableItemMetadata(ocId: "sItemB", fileName: "sItemB", account: Self.account) + sItemB.downloaded = false + + var sItemC = SendableItemMetadata(ocId: "sItemC", fileName: "sItemC", account: Self.account) + sItemC.downloaded = true + + var sDirD = SendableItemMetadata(ocId: "sDirD", fileName: "sDirD", account: Self.account) + sDirD.directory = true + sDirD.visitedDirectory = true + + Self.dbManager.addItemMetadata(sItemA) + Self.dbManager.addItemMetadata(sItemB) + Self.dbManager.addItemMetadata(sItemC) + Self.dbManager.addItemMetadata(sDirD) + + let materialized = + Self.dbManager.materialisedItemMetadatas(account: Self.account.ncKitAccount) + XCTAssertEqual(materialized.count, 5) + + let materialisedOcIds = materialized.map(\.ocId) + XCTAssertTrue(materialisedOcIds.contains(itemA.ocId)) + XCTAssertTrue(materialisedOcIds.contains(folderA.ocId)) + XCTAssertTrue(materialisedOcIds.contains(sItemA.ocId)) + XCTAssertTrue(materialisedOcIds.contains(sItemC.ocId)) + XCTAssertTrue(materialisedOcIds.contains(sDirD.ocId)) + } + + func testKeepDownloadedRetainedDuringUpdate() throws { + let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "") + + // Create initial metadata with keepDownloaded = true + var initialMetadata = SendableItemMetadata(ocId: "test-keep-downloaded", fileName: "test.txt", account: account) + initialMetadata.downloaded = true + initialMetadata.uploaded = true + initialMetadata.keepDownloaded = true + initialMetadata.etag = "old-etag" + + Self.dbManager.addItemMetadata(initialMetadata) + + // Verify initial state + let storedMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "test-keep-downloaded")) + XCTAssertTrue(storedMetadata.keepDownloaded, "Initial keepDownloaded should be true") + XCTAssertTrue(storedMetadata.downloaded, "Initial downloaded should be true") + + // Update metadata with new etag (simulating server update) + var updatedMetadata = initialMetadata + updatedMetadata.etag = "new-etag" + updatedMetadata.keepDownloaded = false // This would be the case when converting from NKFile + + let result = Self.dbManager.depth1ReadUpdateItemMetadatas( + account: account.ncKitAccount, + serverUrl: account.davFilesUrl, + updatedMetadatas: [updatedMetadata], + keepExistingDownloadState: true + ) + + XCTAssertEqual(result.newMetadatas?.isEmpty, true, "Should create no new metadatas") + XCTAssertEqual(result.updatedMetadatas?.isEmpty, false, "Should update existing metadata") + + // Verify keepDownloaded is retained + let finalMetadata = try XCTUnwrap(result.updatedMetadatas?.first) + XCTAssertTrue(finalMetadata.keepDownloaded, "keepDownloaded should be retained during update") + XCTAssertTrue(finalMetadata.downloaded, "downloaded should be retained during update") + XCTAssertEqual(finalMetadata.etag, "new-etag", "etag should be updated") + + // Verify in database + let dbMetadata = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "test-keep-downloaded")) + XCTAssertTrue(dbMetadata.keepDownloaded, "keepDownloaded should be retained in database") + } + + func testPendingWorkingSetChanges() { + // 1. Arrange + let anchorDate = Date().addingTimeInterval(-300) // 5 minutes ago + let oldSyncDate = Date().addingTimeInterval(-600) // 10 minutes ago (before anchor) + let recentSyncDate = Date() // Now (after anchor) + + // --- Multi-level file structure to test the full scope --- + + // LEVEL 1: Root level materialized folder updated recently + var updatedDir = SendableItemMetadata(ocId: "updatedDir", fileName: "UpdatedDir", account: Self.account) + updatedDir.directory = true + updatedDir.visitedDirectory = true // Materialised + updatedDir.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(updatedDir) + + // LEVEL 2: Child of updated folder with OLD sync time - should NOT be included + var childOfUpdatedDirOld = SendableItemMetadata(ocId: "childOfUpdatedOld", fileName: "childOld.txt", account: Self.account) + childOfUpdatedDirOld.serverUrl = Self.account.davFilesUrl + "/UpdatedDir" + childOfUpdatedDirOld.syncTime = oldSyncDate // Old sync time + childOfUpdatedDirOld.downloaded = true // Materialised + Self.dbManager.addItemMetadata(childOfUpdatedDirOld) + + // LEVEL 2: Child of updated folder with RECENT sync time - should be included (regardless of materialisation) + var childOfUpdatedDirRecent = SendableItemMetadata(ocId: "childOfUpdatedRecent", fileName: "childRecent.txt", account: Self.account) + childOfUpdatedDirRecent.serverUrl = Self.account.davFilesUrl + "/UpdatedDir" + childOfUpdatedDirRecent.syncTime = recentSyncDate // Recent sync time + childOfUpdatedDirRecent.downloaded = false // NOT materialized - but should still be included + Self.dbManager.addItemMetadata(childOfUpdatedDirRecent) + + // LEVEL 2: Child folder of updated folder with recent sync time + var childFolderOfUpdated = SendableItemMetadata(ocId: "childFolderOfUpdated", fileName: "ChildFolder", account: Self.account) + childFolderOfUpdated.directory = true + childFolderOfUpdated.visitedDirectory = false // Not materialised + childFolderOfUpdated.serverUrl = Self.account.davFilesUrl + "/UpdatedDir" + childFolderOfUpdated.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(childFolderOfUpdated) + + // LEVEL 3: Grandchild with recent sync time - should not be included + var grandchildOfUpdated = SendableItemMetadata(ocId: "grandchildOfUpdated", fileName: "grandchild.txt", account: Self.account) + grandchildOfUpdated.serverUrl = Self.account.davFilesUrl + "/UpdatedDir/ChildFolder" + grandchildOfUpdated.syncTime = recentSyncDate + grandchildOfUpdated.downloaded = false // Not materialised + Self.dbManager.addItemMetadata(grandchildOfUpdated) + + // DELETED STRUCTURE: Root level materialized folder deleted recently + var deletedDir = SendableItemMetadata(ocId: "deletedDir", fileName: "DeletedDir", account: Self.account) + deletedDir.directory = true + deletedDir.visitedDirectory = true // Materialised + deletedDir.deleted = true + deletedDir.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(deletedDir) + + // Child of deleted folder with OLD sync time - should NOT be included + var childOfDeletedDirOld = SendableItemMetadata(ocId: "childOfDeletedOld", fileName: "childDelOld.txt", account: Self.account) + childOfDeletedDirOld.serverUrl = Self.account.davFilesUrl + "/DeletedDir" + childOfDeletedDirOld.syncTime = oldSyncDate // Old sync time + childOfDeletedDirOld.downloaded = true + Self.dbManager.addItemMetadata(childOfDeletedDirOld) + + // Child of deleted folder with RECENT sync time - should be included in deleted + var childOfDeletedDirRecent = SendableItemMetadata(ocId: "childOfDeletedRecent", fileName: "childDelRecent.txt", account: Self.account) + childOfDeletedDirRecent.serverUrl = Self.account.davFilesUrl + "/DeletedDir" + childOfDeletedDirRecent.syncTime = recentSyncDate + childOfDeletedDirRecent.downloaded = false // Not materialised + Self.dbManager.addItemMetadata(childOfDeletedDirRecent) + + // Nested structure under deleted folder + var nestedFolderInDeleted = SendableItemMetadata(ocId: "nestedFolderInDeleted", fileName: "NestedFolder", account: Self.account) + nestedFolderInDeleted.directory = true + nestedFolderInDeleted.visitedDirectory = false + nestedFolderInDeleted.serverUrl = Self.account.davFilesUrl + "/DeletedDir" + nestedFolderInDeleted.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(nestedFolderInDeleted) + + // Deep nested item under deleted structure + var deepNestedInDeleted = SendableItemMetadata(ocId: "deepNestedInDeleted", fileName: "deepNested.txt", account: Self.account) + deepNestedInDeleted.serverUrl = Self.account.davFilesUrl + "/DeletedDir/NestedFolder" + deepNestedInDeleted.syncTime = recentSyncDate + deepNestedInDeleted.downloaded = false + Self.dbManager.addItemMetadata(deepNestedInDeleted) + + // STANDALONE ITEMS: materialized file synced recently - should be returned + var standaloneUpdatedFile = SendableItemMetadata(ocId: "standaloneUpdated", fileName: "standalone.txt", account: Self.account) + standaloneUpdatedFile.downloaded = true // Materialised + standaloneUpdatedFile.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(standaloneUpdatedFile) + + // materialized file synced too long ago - should NOT be returned + var standaloneOldFile = SendableItemMetadata(ocId: "standaloneOld", fileName: "old.txt", account: Self.account) + standaloneOldFile.downloaded = true // Materialised + standaloneOldFile.syncTime = oldSyncDate + Self.dbManager.addItemMetadata(standaloneOldFile) + + // Non-materialised item synced recently - should NOT be returned (not in initial materialized set) + var nonMaterialisedFile = SendableItemMetadata(ocId: "nonMaterialised", fileName: "non-mat.txt", account: Self.account) + nonMaterialisedFile.downloaded = false + nonMaterialisedFile.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(nonMaterialisedFile) + + // MIXED MATERIALISATION: Another materialized folder to test child inclusion + var anotherMaterialisedDir = SendableItemMetadata(ocId: "anotherMatDir", fileName: "AnotherMatDir", account: Self.account) + anotherMaterialisedDir.directory = true + anotherMaterialisedDir.visitedDirectory = true + anotherMaterialisedDir.syncTime = recentSyncDate + Self.dbManager.addItemMetadata(anotherMaterialisedDir) + + // Child with recent sync but NOT materialized - should still be included due to recent sync + var nonMatChildRecent = SendableItemMetadata(ocId: "nonMatChildRecent", fileName: "nonMatChild.txt", account: Self.account) + nonMatChildRecent.serverUrl = Self.account.davFilesUrl + "/AnotherMatDir" + nonMatChildRecent.syncTime = recentSyncDate + nonMatChildRecent.downloaded = false // Not materialised + Self.dbManager.addItemMetadata(nonMatChildRecent) + + // 2. Act + let result = Self.dbManager.pendingWorkingSetChanges( + account: Self.account, since: anchorDate + ) + + // 3. Assert - Updated items + let updatedIds = Set(result.updated.map(\.ocId)) + + // Should include materialized items with recent sync + XCTAssertTrue(updatedIds.contains("updatedDir"), "Updated materialized directory should be included") + XCTAssertTrue(updatedIds.contains("standaloneUpdated"), "Updated materialized file should be included") + XCTAssertTrue(updatedIds.contains("anotherMatDir"), "Another materialized directory should be included") + + // Should include children with recent sync regardless of materialisation + XCTAssertTrue(updatedIds.contains("childOfUpdatedRecent"), "Child with recent sync should be included regardless of materialisation") + XCTAssertTrue(updatedIds.contains("childFolderOfUpdated"), "Child folder with recent sync should be included") + XCTAssertTrue(updatedIds.contains("nonMatChildRecent"), "Non-materialised child with recent sync should be included") + + // Should NOT include items with old sync times + XCTAssertFalse(updatedIds.contains("childOfUpdatedOld"), "Child with old sync time should NOT be included") + XCTAssertFalse(updatedIds.contains("standaloneOld"), "Materialised file with old sync should NOT be included") + + // Should NOT include non-materialised items not under a recently updated path + XCTAssertFalse(updatedIds.contains("nonMaterialised"), "Standalone non-materialised item should NOT be included") + + // 4. Assert - Deleted items + let deletedIds = Set(result.deleted.map(\.ocId)) + + // Should include the deleted materialized directory + XCTAssertTrue(deletedIds.contains("deletedDir"), "Deleted materialized directory should be included") + + // Should include children/descendants with recent sync under deleted paths + XCTAssertTrue(deletedIds.contains("childOfDeletedRecent"), "Child of deleted dir with recent sync should be included") + XCTAssertTrue(deletedIds.contains("nestedFolderInDeleted"), "Nested folder under deleted dir should be included") + XCTAssertTrue(deletedIds.contains("deepNestedInDeleted"), "Deep nested item under deleted structure should be included") + + // Should NOT include children with old sync times + XCTAssertFalse(deletedIds.contains("childOfDeletedOld"), "Child of deleted dir with old sync should NOT be included") + + // 5. Verify expected counts + let expectedUpdatedCount = 6 // updatedDir, standaloneUpdated, anotherMatDir, childOfUpdatedRecent, childFolderOfUpdated, nonMatChildRecent + let expectedDeletedCount = 4 // deletedDir, childOfDeletedRecent, nestedFolderInDeleted, deepNestedInDeleted + + XCTAssertEqual(updatedIds.count, expectedUpdatedCount, "Should have \(expectedUpdatedCount) updated items, got \(updatedIds.count): \(updatedIds)") + XCTAssertEqual(deletedIds.count, expectedDeletedCount, "Should have \(expectedDeletedCount) deleted items, got \(deletedIds.count): \(deletedIds)") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift new file mode 100644 index 0000000000000..ea7b74121d44f --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@testable import NextcloudFileProviderKit +import Testing + +struct IgnoredFilesMatcherTests { + @Test func patternMatchingWorks() { + let patterns = [ + "*.tmp", + "build/", + "folder/*", + "secret.txt", + "deep/**" + ] + + let matcher = IgnoredFilesMatcher(ignoreList: patterns) + + #expect(matcher.isExcluded("foo.tmp")) + #expect(matcher.isExcluded("a/b/c/hello.tmp")) + #expect(matcher.isExcluded("/a/b/c/hello.tmp")) + #expect(matcher.isExcluded("build/")) + #expect(!matcher.isExcluded("build")) // We should not match files, just children of build + #expect(matcher.isExcluded("folder/file.txt")) + #expect(!matcher.isExcluded("folder/sub/file.txt")) + #expect(matcher.isExcluded("secret.txt")) + #expect(!matcher.isExcluded("secret.doc")) + #expect(matcher.isExcluded("deep/one.txt")) + #expect(matcher.isExcluded("deep/more/files/here.doc")) + #expect(!matcher.isExcluded("other/deep/file.txt")) + #expect(!matcher.isExcluded("random.file")) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift new file mode 100644 index 0000000000000..91c9494b01a4f --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift @@ -0,0 +1,728 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +import TestInterface +import UniformTypeIdentifiers +import XCTest + +final class ItemCreateTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + + var rootItem: MockRemoteItem! + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + rootItem = MockRemoteItem.rootItem(account: Self.account) + } + + override func tearDown() { + rootItem.children = [] + } + + func testCreateFolder() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + var folderItemMetadata = SendableItemMetadata( + ocId: "folder-id", fileName: "folder", account: Self.account + ) + folderItemMetadata.directory = true + folderItemMetadata.classFile = NKTypeClassFile.directory.rawValue + folderItemMetadata.serverUrl = Self.account.davFilesUrl + + let folderItemTemplate = Item( + metadata: folderItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let (createdItemMaybe, error) = await Item.create( + basedOn: folderItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + let createdItem = try XCTUnwrap(createdItemMaybe) + + XCTAssertNil(error) + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem.metadata.fileName, folderItemMetadata.fileName) + XCTAssertEqual(createdItem.metadata.directory, true) + + XCTAssertNotNil(rootItem.children.first { $0.name == folderItemMetadata.name }) + XCTAssertNotNil( + rootItem.children.first { $0.identifier == createdItem.itemIdentifier.rawValue } + ) + let remoteItem = rootItem.children.first { $0.name == folderItemMetadata.name } + XCTAssertTrue(remoteItem?.directory ?? false) + + let dbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(dbItem.fileName, folderItemMetadata.fileName) + XCTAssertEqual(dbItem.fileNameView, folderItemMetadata.fileNameView) + XCTAssertEqual(dbItem.directory, folderItemMetadata.directory) + XCTAssertEqual(dbItem.serverUrl, folderItemMetadata.serverUrl) + XCTAssertEqual(dbItem.ocId, createdItem.itemIdentifier.rawValue) + XCTAssertTrue(dbItem.downloaded) + XCTAssertTrue(dbItem.uploaded) + XCTAssertTrue(createdItem.isDownloaded) + XCTAssertTrue(createdItem.isUploaded) + } + + func testCreateFile() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + var fileItemMetadata = SendableItemMetadata( + ocId: "file-id", fileName: "file", account: Self.account + ) + fileItemMetadata.classFile = NKTypeClassFile.document.rawValue + + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file") + try Data("Hello world".utf8).write(to: tempUrl) + + let fileItemTemplate = Item( + metadata: fileItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let (createdItemMaybe, error) = await Item.create( + basedOn: fileItemTemplate, + contents: tempUrl, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + let createdItem = try XCTUnwrap(createdItemMaybe) + + XCTAssertNil(error) + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem.metadata.fileName, fileItemMetadata.fileName) + XCTAssertEqual(createdItem.metadata.directory, fileItemMetadata.directory) + + let remoteItem = try XCTUnwrap( + rootItem.children.first { $0.identifier == createdItem.itemIdentifier.rawValue } + ) + XCTAssertEqual(remoteItem.name, fileItemMetadata.fileName) + XCTAssertEqual(remoteItem.directory, fileItemMetadata.directory) + + let dbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(dbItem.fileName, fileItemMetadata.fileName) + XCTAssertEqual(dbItem.fileNameView, fileItemMetadata.fileNameView) + XCTAssertEqual(dbItem.directory, fileItemMetadata.directory) + XCTAssertEqual(dbItem.serverUrl, fileItemMetadata.serverUrl) + XCTAssertEqual(dbItem.ocId, createdItem.itemIdentifier.rawValue) + XCTAssertTrue(dbItem.downloaded) + XCTAssertTrue(dbItem.uploaded) + XCTAssertTrue(createdItem.isDownloaded) + XCTAssertTrue(createdItem.isUploaded) + } + + func testCreateFileIntoFolder() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + var folderItemMetadata = SendableItemMetadata( + ocId: "folder-id", fileName: "folder", account: Self.account + ) + folderItemMetadata.directory = true + folderItemMetadata.classFile = NKTypeClassFile.directory.rawValue + folderItemMetadata.serverUrl = Self.account.davFilesUrl + + let folderItemTemplate = Item( + metadata: folderItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (createdFolderItemMaybe, folderError) = await Item.create( + basedOn: folderItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertNil(folderError) + let createdFolderItem = try XCTUnwrap(createdFolderItemMaybe) + + let fileRelativeRemotePath = "/folder" + var fileItemMetadata = SendableItemMetadata( + ocId: "file-id", fileName: "file", account: Self.account + ) + fileItemMetadata.classFile = NKTypeClassFile.document.rawValue + fileItemMetadata.serverUrl = Self.account.davFilesUrl + fileRelativeRemotePath + + let fileItemTemplate = Item( + metadata: fileItemMetadata, + parentItemIdentifier: createdFolderItem.itemIdentifier, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file") + try Data("Hello world".utf8).write(to: tempUrl) + + let (createdFileItemMaybe, fileError) = await Item.create( + basedOn: fileItemTemplate, + contents: tempUrl, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let createdFileItem = try XCTUnwrap(createdFileItemMaybe) + + XCTAssertNil(fileError) + XCTAssertNotNil(createdFileItem) + + let remoteFolderItem = rootItem.children.first { $0.name == "folder" } + XCTAssertNotNil(remoteFolderItem) + XCTAssertFalse(remoteFolderItem?.children.isEmpty ?? true) + + let dbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdFileItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(dbItem.fileName, fileItemMetadata.fileName) + XCTAssertEqual(dbItem.fileNameView, fileItemMetadata.fileNameView) + XCTAssertEqual(dbItem.directory, fileItemMetadata.directory) + XCTAssertEqual(dbItem.serverUrl, fileItemMetadata.serverUrl) + XCTAssertEqual(dbItem.ocId, createdFileItem.itemIdentifier.rawValue) + XCTAssertTrue(dbItem.downloaded) + XCTAssertTrue(dbItem.uploaded) + + let parentDbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdFolderItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(parentDbItem.fileName, folderItemMetadata.fileName) + XCTAssertEqual(parentDbItem.fileNameView, folderItemMetadata.fileNameView) + XCTAssertEqual(parentDbItem.directory, folderItemMetadata.directory) + XCTAssertEqual(parentDbItem.serverUrl, folderItemMetadata.serverUrl) + XCTAssertTrue(parentDbItem.downloaded) + XCTAssertTrue(parentDbItem.uploaded) + } + + func testCreateBundle() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + + let keynoteBundleFilename = "test.key" + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + var bundleItemMetadata = SendableItemMetadata( + ocId: "keynotebundleid", fileName: keynoteBundleFilename, account: Self.account + ) + bundleItemMetadata.directory = true + bundleItemMetadata.serverUrl = Self.account.davFilesUrl + bundleItemMetadata.classFile = NKTypeClassFile.directory.rawValue + bundleItemMetadata.contentType = UTType.bundle.identifier + + let fm = FileManager.default + let tempUrl = fm.temporaryDirectory.appendingPathComponent(keynoteBundleFilename) + try fm.createDirectory(at: tempUrl, withIntermediateDirectories: true, attributes: nil) + let keynoteIndexZipPath = tempUrl.appendingPathComponent("Index.zip") + try Data("This is a fake zip!".utf8).write(to: keynoteIndexZipPath) + let keynoteDataDir = tempUrl.appendingPathComponent("Data") + try fm.createDirectory( + at: keynoteDataDir, withIntermediateDirectories: true, attributes: nil + ) + let keynoteMetadataDir = tempUrl.appendingPathComponent("Metadata") + try fm.createDirectory( + at: keynoteMetadataDir, withIntermediateDirectories: true, attributes: nil + ) + let keynoteDocIdentifierPath = + keynoteMetadataDir.appendingPathComponent("DocumentIdentifier") + try Data("8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01".utf8).write(to: keynoteDocIdentifierPath) + let keynoteBuildVersionPlistPath = + keynoteMetadataDir.appendingPathComponent("BuildVersionHistory.plist") + try Data( + """ + + + + + Template: 35_DynamicWavesDark (14.1) + M14.1-7040.0.73-4 + + + """ + .utf8).write(to: keynoteBuildVersionPlistPath) + let keynotePropertiesPlistPath = keynoteMetadataDir.appendingPathComponent("Properties.plist") + try Data( + """ + + + + + revision + 0::5B42B84E-6F62-4E53-9E71-7DD24FA7E2EA + documentUUID + 8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01 + versionUUID + 5B42B84E-6F62-4E53-9E71-7DD24FA7E2EA + privateUUID + 637C846B-6146-40C2-8EF8-26996E598E49 + isMultiPage + + stableDocumentUUID + 8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01 + fileFormatVersion + 14.1.1 + shareUUID + 8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01 + + + """ + .utf8).write(to: keynotePropertiesPlistPath) + + let bundleItemTemplate = Item( + metadata: bundleItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + // TODO: Add fail test with no contents + let (createdBundleItemMaybe, bundleError) = await Item.create( + basedOn: bundleItemTemplate, + contents: tempUrl, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + let createdBundleItem = try XCTUnwrap(createdBundleItemMaybe) + + XCTAssertNil(bundleError) + XCTAssertNotNil(createdBundleItem) + XCTAssertEqual(createdBundleItem.metadata.fileName, bundleItemMetadata.fileName) + XCTAssertEqual(createdBundleItem.metadata.directory, true) + XCTAssertTrue(createdBundleItem.isDownloaded) + XCTAssertTrue(createdBundleItem.isUploaded) + + // Below: this is an upstream issue (which we should fix) + // XCTAssertTrue(createdBundleItem.contentType.conforms(to: .bundle)) + + XCTAssertNotNil(rootItem.children.first { $0.name == bundleItemMetadata.name }) + XCTAssertNotNil( + rootItem.children.first { $0.identifier == createdBundleItem.itemIdentifier.rawValue } + ) + let remoteItem = rootItem.children.first { $0.name == bundleItemMetadata.name } + XCTAssertTrue(remoteItem?.directory ?? false) + + let dbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdBundleItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(dbItem.fileName, bundleItemMetadata.fileName) + XCTAssertEqual(dbItem.fileNameView, bundleItemMetadata.fileNameView) + XCTAssertEqual(dbItem.directory, bundleItemMetadata.directory) + XCTAssertEqual(dbItem.serverUrl, bundleItemMetadata.serverUrl) + XCTAssertEqual(dbItem.ocId, createdBundleItem.itemIdentifier.rawValue) + XCTAssertEqual( + dbItem.etag, String(data: createdBundleItem.itemVersion.contentVersion, encoding: .utf8) + ) + XCTAssertTrue(dbItem.downloaded) + XCTAssertTrue(dbItem.uploaded) + + let remoteBundleItem = rootItem.children.first { $0.name == keynoteBundleFilename } + XCTAssertNotNil(remoteBundleItem) + XCTAssertEqual(remoteBundleItem?.children.count, 3) + + XCTAssertNotNil(remoteBundleItem?.children.first { $0.name == "Data" }) + XCTAssertNotNil(remoteBundleItem?.children.first { $0.name == "Index.zip" }) + + let remoteMetadataItem = remoteBundleItem?.children.first { $0.name == "Metadata" } + XCTAssertNotNil(remoteMetadataItem) + XCTAssertEqual(remoteMetadataItem?.children.count, 3) + XCTAssertNotNil(remoteMetadataItem?.children.first { $0.name == "DocumentIdentifier" }) + XCTAssertNotNil(remoteMetadataItem?.children.first { $0.name == "Properties.plist" }) + XCTAssertNotNil(remoteMetadataItem?.children.first { + $0.name == "BuildVersionHistory.plist" + }) + + let childrenCount = Self.dbManager.childItemCount(directoryMetadata: dbItem) + XCTAssertEqual(childrenCount, 6) // Ensure all children recorded to database + } + + func testCreateFileChunked() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + var fileItemMetadata = SendableItemMetadata( + ocId: "file-id", fileName: "file", account: Self.account + ) + fileItemMetadata.classFile = NKTypeClassFile.document.rawValue + + let chunkSize = 2 + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file") + let tempData = Data(repeating: 1, count: chunkSize * 3) + try tempData.write(to: tempUrl) + + let fileItemTemplate = Item( + metadata: fileItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let (createdItemMaybe, error) = await Item.create( + basedOn: fileItemTemplate, + contents: tempUrl, + account: Self.account, + remoteInterface: remoteInterface, + forcedChunkSize: chunkSize, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + let createdItem = try XCTUnwrap(createdItemMaybe) + + XCTAssertNil(error) + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem.metadata.fileName, fileItemMetadata.fileName) + XCTAssertEqual(createdItem.metadata.directory, fileItemMetadata.directory) + + let remoteItem = try XCTUnwrap( + rootItem.children.first { $0.identifier == createdItem.itemIdentifier.rawValue } + ) + XCTAssertEqual(remoteItem.name, fileItemMetadata.fileName) + XCTAssertEqual(remoteItem.directory, fileItemMetadata.directory) + XCTAssertEqual(remoteItem.data, tempData) + + let dbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(dbItem.fileName, fileItemMetadata.fileName) + XCTAssertEqual(dbItem.fileNameView, fileItemMetadata.fileNameView) + XCTAssertEqual(dbItem.directory, fileItemMetadata.directory) + XCTAssertEqual(dbItem.serverUrl, fileItemMetadata.serverUrl) + XCTAssertEqual(dbItem.ocId, createdItem.itemIdentifier.rawValue) + XCTAssertTrue(dbItem.downloaded) + XCTAssertTrue(dbItem.uploaded) + } + + func testCreateFileChunkedResumed() async throws { + let chunkSize = 2 + let expectedChunkUploadId = UUID().uuidString // Check if illegal characters are stripped + let illegalChunkUploadId = expectedChunkUploadId + "/" // Check if illegal characters are stripped + let previousUploadedChunkNum = 1 + let preexistingChunk = RemoteFileChunk( + fileName: String(previousUploadedChunkNum), + size: Int64(chunkSize), + remoteChunkStoreFolderName: expectedChunkUploadId + ) + + let db = Self.dbManager.ncDatabase() + try db.write { + db.add([ + RemoteFileChunk( + fileName: String(previousUploadedChunkNum + 1), + size: Int64(chunkSize), + remoteChunkStoreFolderName: expectedChunkUploadId + ), + RemoteFileChunk( + fileName: String(previousUploadedChunkNum + 2), + size: Int64(chunkSize), + remoteChunkStoreFolderName: expectedChunkUploadId + ) + ]) + } + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + remoteInterface.currentChunks = [expectedChunkUploadId: [preexistingChunk]] + + // With real new item uploads we do not have an associated ItemMetadata as the template is + // passed onto us by the OS. We cannot rely on the chunkUploadId property we usually use + // during modified item uploads. + // + // We therefore can only use the system-provided item template's itemIdentifier as the + // chunked upload identifier during new item creation. + // + // To test this situation we set the ocId of the metadata used to construct the item + // template to the chunk upload id. + var fileItemMetadata = SendableItemMetadata( + ocId: illegalChunkUploadId, fileName: "file", account: Self.account + ) + fileItemMetadata.ocId = illegalChunkUploadId + fileItemMetadata.classFile = NKTypeClassFile.document.rawValue + + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent("file") + let tempData = Data(repeating: 1, count: chunkSize * 3) + try tempData.write(to: tempUrl) + + let fileItemTemplate = Item( + metadata: fileItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let (createdItemMaybe, error) = await Item.create( + basedOn: fileItemTemplate, + contents: tempUrl, + account: Self.account, + remoteInterface: remoteInterface, + forcedChunkSize: chunkSize, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + let createdItem = try XCTUnwrap(createdItemMaybe) + + XCTAssertNil(error) + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem.metadata.fileName, fileItemMetadata.fileName) + XCTAssertEqual(createdItem.metadata.directory, fileItemMetadata.directory) + + let remoteItem = try XCTUnwrap( + rootItem.children.first { $0.identifier == createdItem.itemIdentifier.rawValue } + ) + XCTAssertEqual(remoteItem.name, fileItemMetadata.fileName) + XCTAssertEqual(remoteItem.directory, fileItemMetadata.directory) + XCTAssertEqual(remoteItem.data, tempData) + XCTAssertEqual( + remoteInterface.completedChunkTransferSize[expectedChunkUploadId], + Int64(tempData.count) - preexistingChunk.size + ) + + let dbItem = try XCTUnwrap( + Self.dbManager.itemMetadata(ocId: createdItem.itemIdentifier.rawValue) + ) + XCTAssertEqual(dbItem.fileName, fileItemMetadata.fileName) + XCTAssertEqual(dbItem.fileNameView, fileItemMetadata.fileNameView) + XCTAssertEqual(dbItem.directory, fileItemMetadata.directory) + XCTAssertEqual(dbItem.serverUrl, fileItemMetadata.serverUrl) + XCTAssertEqual(dbItem.ocId, createdItem.itemIdentifier.rawValue) + XCTAssertNil(dbItem.chunkUploadId) + XCTAssertTrue(dbItem.downloaded) + XCTAssertTrue(dbItem.uploaded) + } + + func testCreateDoesNotPropagateIgnoredFile() async throws { + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.tmp", "/build/"]) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // We'll create a file that matches the ignored pattern + let parentIdentifier = NSFileProviderItemIdentifier.rootContainer + let metadata = SendableItemMetadata( + ocId: "ignored-file-id", fileName: "foo.tmp", account: Self.account + ) + let itemTemplate = Item( + metadata: metadata, + parentItemIdentifier: parentIdentifier, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let (createdItem, error) = await Item.create( + basedOn: itemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + ignoredFiles: ignoredMatcher, + progress: .init(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + // Assert + XCTAssertEqual(error as? NSFileProviderError, NSFileProviderError(.excludedFromSync)) + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem?.isUploaded, false) + XCTAssertEqual(createdItem?.isDownloaded, true) + XCTAssertTrue(rootItem.children.isEmpty) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: metadata.ocId)) + } + + func testCreateLockFileTriggersRemoteLockInsteadOfUpload() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Setup remote folder and file + let folderRemote = MockRemoteItem( + identifier: "folder", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.odt" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: false, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + // Insert folder and target file into DB + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + // Construct the lock file metadata + let lockFileName = ".~lock.\(targetFileName)#" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + + let lockItemTemplate = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (createdItem, error) = await Item.create( + basedOn: lockItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertNotNil(createdItem) + XCTAssertEqual(createdItem?.isUploaded, false) + XCTAssertEqual(createdItem?.isDownloaded, true) + XCTAssertNil(error) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)) + XCTAssertTrue(targetRemote.locked) + } + + func testCreateLockFileUnactionableWithoutCapabilities() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + XCTAssert(remoteInterface.capabilities.contains(##""locking": "1.0","##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""locking": "1.0","##, with: "") + + // Setup remote folder and file + let folderRemote = MockRemoteItem( + identifier: "folder", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.odt" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: false, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + // Insert folder and target file into DB + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + // Construct the lock file metadata + let lockFileName = ".~lock.\(targetFileName)#" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + + let lockItemTemplate = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (createdItem, error) = await Item.create( + basedOn: lockItemTemplate, + contents: nil, + account: Self.account, + remoteInterface: remoteInterface, + progress: Progress(), + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertNil(createdItem) + let unwrappedError = try XCTUnwrap(error) as? NSFileProviderError + let expectedError = if #available(macOS 13.0, *) { + NSFileProviderError(.excludedFromSync) + } else { + NSFileProviderError(.cannotSynchronize) + } + XCTAssertEqual(unwrappedError, expectedError) + XCTAssertNil(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)) + XCTAssertFalse(targetRemote.locked) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift new file mode 100644 index 0000000000000..236a1e4c4f988 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +import TestInterface +import XCTest + +final class ItemDeleteTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + lazy var rootItem = MockRemoteItem.rootItem(account: Self.account) + lazy var rootTrashItem = MockRemoteItem.rootTrashItem(account: Self.account) + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + + override func tearDown() { + rootItem.children = [] + rootTrashItem.children = [] + } + + func testDeleteFile() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let itemIdentifier = "file" + let remoteItem = MockRemoteItem( + identifier: itemIdentifier, + name: "file", + remotePath: Self.account.davFilesUrl + "/file", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + remoteItem.parent = rootItem + rootItem.children = [remoteItem] + + XCTAssertFalse(rootItem.children.isEmpty) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: itemIdentifier)) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let error = await item.delete(dbManager: Self.dbManager) + XCTAssertNil(error) + XCTAssertTrue(rootItem.children.isEmpty) + + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: itemIdentifier)?.deleted, true) + } + + func testDeleteFolderAndContents() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteFolder = MockRemoteItem( + identifier: "folder", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteItem = MockRemoteItem( + identifier: "file", + name: "file", + remotePath: Self.account.davFilesUrl + "/folder/file", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + remoteFolder.children = [remoteItem] + remoteItem.parent = remoteFolder + + XCTAssertFalse(rootItem.children.isEmpty) + XCTAssertFalse(remoteFolder.children.isEmpty) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + let remoteItemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + Self.dbManager.addItemMetadata(remoteItemMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItem.identifier)) + + let folder = Item( + metadata: folderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let error = await folder.delete(dbManager: Self.dbManager) + XCTAssertNil(error) + XCTAssertTrue(rootItem.children.isEmpty) + + XCTAssertNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNil(Self.dbManager.itemMetadata(ocId: remoteItem.identifier)) + } + + func testDeleteWithTrashing() async { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let itemIdentifier = "file" + let remoteItem = MockRemoteItem( + identifier: itemIdentifier, + name: "file", + remotePath: Self.account.davFilesUrl + "/file", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + remoteItem.parent = rootItem + rootItem.children = [remoteItem] + + XCTAssertFalse(rootItem.children.isEmpty) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + XCTAssertEqual(itemMetadata.isTrashed, false) + + Self.dbManager.addItemMetadata(itemMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: itemIdentifier)) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let error = await item.delete(trashing: true, dbManager: Self.dbManager) + XCTAssertNil(error) + XCTAssertTrue(rootItem.children.isEmpty) + + let postTrashingMetadata = Self.dbManager.itemMetadata(ocId: itemIdentifier) + XCTAssertNotNil(postTrashingMetadata) + XCTAssertEqual(postTrashingMetadata?.serverUrl, Self.account.trashUrl) + XCTAssertEqual( + Self.dbManager.parentItemIdentifierFromMetadata(postTrashingMetadata!), .trashContainer + ) + XCTAssertEqual(postTrashingMetadata?.isTrashed, true) + XCTAssertEqual(postTrashingMetadata?.trashbinFileName, "file") // Remember we need to sync + XCTAssertEqual(postTrashingMetadata?.trashbinOriginalLocation, "file") + } + + func testDeleteDoesNotPropagateIgnoredFile() async throws { + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.log", "/tmp/"]) + let metadata = SendableItemMetadata( + ocId: "ignored-file-id", + fileName: "debug.log", + account: Self.account + ) + Self.dbManager.addItemMetadata(metadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: metadata.ocId)) + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account, rootItem: rootItem), + dbManager: Self.dbManager + ) + let error = await item.delete( + trashing: false, + domain: nil, + ignoredFiles: ignoredMatcher, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: metadata.ocId)?.deleted, true) + } + + func testDeleteLockFileUnlocksTargetFile() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Setup remote folder and file + let folderRemote = MockRemoteItem( + identifier: "folder-id", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.odt" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + // Insert folder and target file into DB + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + // Construct the lock file metadata (used in deletion) + let lockFileName = ".~lock.\(targetFileName)#" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(lockFileMetadata) + + let lockItem = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + // Delete the lock file + let error = await lockItem.delete(dbManager: Self.dbManager) + XCTAssertEqual(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)?.deleted, true) + + // Assert: no error returned + XCTAssertNil(error) + + // Assert: remote file is now unlocked + XCTAssertFalse( + targetRemote.locked, "Expected the target file to be unlocked after lock file deletion" + ) + } + + func testDeleteLockFileWithoutCapabilitiesDoesNothing() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + XCTAssert(remoteInterface.capabilities.contains(##""locking": "1.0","##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""locking": "1.0","##, with: "") + + // Setup remote folder and file + let folderRemote = MockRemoteItem( + identifier: "folder-id", + versionIdentifier: "1", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + let targetFileName = "MyDoc.odt" + let targetRemote = MockRemoteItem( + identifier: "folder/\(targetFileName)", + versionIdentifier: "1", + name: targetFileName, + remotePath: folderRemote.remotePath + "/" + targetFileName, + data: Data("test data".utf8), + locked: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + folderRemote.children = [targetRemote] + folderRemote.parent = rootItem + rootItem.children = [folderRemote] + + // Insert folder and target file into DB + var folderMetadata = SendableItemMetadata( + ocId: folderRemote.identifier, fileName: "folder", account: Self.account + ) + folderMetadata.directory = true + Self.dbManager.addItemMetadata(folderMetadata) + + var targetMetadata = SendableItemMetadata( + ocId: targetRemote.identifier, fileName: targetFileName, account: Self.account + ) + targetMetadata.serverUrl += "/folder" + Self.dbManager.addItemMetadata(targetMetadata) + + // Construct the lock file metadata (used in deletion) + let lockFileName = ".~lock.\(targetFileName)#" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.serverUrl += "/folder" + + let lockItem = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .init(folderMetadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + // Delete the lock file + let error = await lockItem.delete(dbManager: Self.dbManager) + XCTAssertNil(Self.dbManager.itemMetadata(ocId: lockFileMetadata.ocId)) + XCTAssertNil(error) + XCTAssertTrue( + targetRemote.locked, "Expected the target file to still be locked" + ) + } + + func testFailOnNonRecursiveNonEmptyDirDelete() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteFolder = MockRemoteItem( + identifier: "folder", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteItem = MockRemoteItem( + identifier: "file", + name: "file", + remotePath: Self.account.davFilesUrl + "/folder/file", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + remoteFolder.children = [remoteItem] + remoteItem.parent = remoteFolder + + XCTAssertFalse(rootItem.children.isEmpty) + XCTAssertFalse(remoteFolder.children.isEmpty) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + let remoteItemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + Self.dbManager.addItemMetadata(remoteItemMetadata) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItem.identifier)) + + let folder = Item( + metadata: folderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let error = await folder.delete(options: [], dbManager: Self.dbManager) + XCTAssertNotNil(error) + XCTAssertEqual(error as? NSFileProviderError?, NSFileProviderError(.directoryNotEmpty)) + XCTAssertFalse(rootItem.children.isEmpty) + + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteFolder.identifier)) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: remoteItem.identifier)) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift new file mode 100644 index 0000000000000..f228df5859364 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +import TestInterface +import XCTest + +final class ItemFetchTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + + lazy var rootItem = MockRemoteItem.rootItem(account: Self.account) + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + + override func tearDown() { + rootItem.children = [] + } + + func testFetchFileContents() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + remoteInterface.injectMock(Self.account) + + let remoteItem = MockRemoteItem( + identifier: "item", + versionIdentifier: "0", + name: "item.txt", + remotePath: Self.account.davFilesUrl + "/item.txt", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [remoteItem] + remoteItem.parent = rootItem + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + XCTAssertNotNil(itemMetadata.ocId) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (localPathMaybe, fetchedItemMaybe, error) = await item.fetchContents(dbManager: Self.dbManager) + XCTAssertNil(error) + let localPath = try XCTUnwrap(localPathMaybe) + let fetchedItem = try XCTUnwrap(fetchedItemMaybe) + let contents = try Data(contentsOf: localPath) + + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: itemMetadata.ocId)) + + XCTAssertEqual(contents, remoteItem.data) + XCTAssertTrue(fetchedItem.isDownloaded) + XCTAssertTrue(fetchedItem.isUploaded) + XCTAssertEqual(fetchedItem.itemIdentifier, item.itemIdentifier) + XCTAssertEqual(fetchedItem.filename, item.filename) + XCTAssertEqual(fetchedItem.creationDate, item.creationDate) + } + + func testFetchDirectoryContents() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + remoteInterface.injectMock(Self.account) + + let remoteDirectory = MockRemoteItem( + identifier: "directory", + versionIdentifier: "0", + name: "directory", + remotePath: Self.account.davFilesUrl + "/directory", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteDirectoryChildFile = MockRemoteItem( + identifier: "childFile", + versionIdentifier: "0", + name: "file.txt", + remotePath: remoteDirectory.remotePath + "/file.txt", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteDirectoryChildDirA = MockRemoteItem( + identifier: "childDirectoryA", + versionIdentifier: "0", + name: "directoryA", + remotePath: remoteDirectory.remotePath + "/directoryA", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteDirectoryChildDirB = MockRemoteItem( + identifier: "childDirectoryB", + versionIdentifier: "0", + name: "directoryB", + remotePath: remoteDirectory.remotePath + "/directoryB", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteDirectoryChildDirBChildFile = MockRemoteItem( + identifier: "childDirectoryBChildFile", + versionIdentifier: "0", + name: "dirBfile.txt", + remotePath: remoteDirectoryChildDirB.remotePath + "/dirBfile.txt", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [remoteDirectory] + remoteDirectory.parent = rootItem + remoteDirectory.children = [ + remoteDirectoryChildFile, remoteDirectoryChildDirA, remoteDirectoryChildDirB + ] + remoteDirectoryChildFile.parent = remoteDirectory + remoteDirectoryChildDirA.parent = remoteDirectory + remoteDirectoryChildDirB.parent = remoteDirectory + remoteDirectoryChildDirB.children = [remoteDirectoryChildDirBChildFile] + remoteDirectoryChildDirBChildFile.parent = remoteDirectoryChildDirB + + let directoryMetadata = remoteDirectory.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(directoryMetadata) + + let directoryChildFileMetadata = + remoteDirectoryChildFile.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(directoryChildFileMetadata) + + let directoryChildDirAMetadata = + remoteDirectoryChildDirA.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(directoryChildDirAMetadata) + + let directoryChildDirBMetadata = + remoteDirectoryChildDirB.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(directoryChildDirBMetadata) + + let directoryChildDirBChildFileMetadata = + remoteDirectoryChildDirBChildFile.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(directoryChildDirBChildFileMetadata) + + let item = Item( + metadata: directoryMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (localPathMaybe, fetchedItemMaybe, error) = + await item.fetchContents(dbManager: Self.dbManager) + XCTAssertNil(error) + let localPath = try XCTUnwrap(localPathMaybe) + let fetchedItem = try XCTUnwrap(fetchedItemMaybe) + + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: directoryMetadata.ocId)) + + XCTAssertEqual(fetchedItem.itemIdentifier, item.itemIdentifier) + XCTAssertEqual(fetchedItem.filename, item.filename) + XCTAssertEqual(fetchedItem.creationDate, item.creationDate) + XCTAssertTrue(fetchedItem.isUploaded) + XCTAssertTrue(fetchedItem.isDownloaded) + + let fm = FileManager.default + var itemIsDir = ObjCBool(false) + XCTAssertTrue(fm.fileExists(atPath: localPath.path, isDirectory: &itemIsDir)) + XCTAssertTrue(itemIsDir.boolValue) + + let itemChildFileUrl = localPath.appendingPathComponent("file.txt") + let itemChildFilePath = itemChildFileUrl.path + var itemChildFileIsDir = ObjCBool(false) + XCTAssertTrue(fm.fileExists(atPath: itemChildFilePath, isDirectory: &itemChildFileIsDir)) + XCTAssertFalse(itemChildFileIsDir.boolValue) + XCTAssertEqual(try Data(contentsOf: itemChildFileUrl), remoteDirectoryChildFile.data) + + let itemChildDirAPath = localPath.appendingPathComponent("directoryA").path + var itemChildDirAIsDir = ObjCBool(false) + XCTAssertTrue(fm.fileExists(atPath: itemChildDirAPath, isDirectory: &itemChildDirAIsDir)) + XCTAssertTrue(itemChildDirAIsDir.boolValue) + + let itemChildDirBUrl = localPath.appendingPathComponent("directoryB") + let itemChildDirBPath = itemChildDirBUrl.path + var itemChildDirBIsDir = ObjCBool(false) + XCTAssertTrue(fm.fileExists(atPath: itemChildDirBPath, isDirectory: &itemChildDirBIsDir)) + XCTAssertTrue(itemChildDirBIsDir.boolValue) + + let itemChildDirBChildFileUrl = itemChildDirBUrl.appendingPathComponent("dirBfile.txt") + let itemChildDirBChildFilePath = itemChildDirBChildFileUrl.path + var itemChildDirBChildFileIsDir = ObjCBool(false) + XCTAssertTrue(fm.fileExists( + atPath: itemChildDirBChildFilePath, isDirectory: &itemChildDirBChildFileIsDir + )) + XCTAssertFalse(itemChildDirBChildFileIsDir.boolValue) + XCTAssertEqual( + try Data(contentsOf: itemChildDirBChildFileUrl), remoteDirectoryChildDirBChildFile.data + ) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift new file mode 100644 index 0000000000000..af894b83c8b93 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +@testable import NextcloudFileProviderKit +import Testing + +struct ItemMetadataTests { + @Test func thumbnailUrlCorrect() { + let account = + Account(user: "user", id: "id", serverUrl: "https://examplecloud.com", password: "bla") + var item = SendableItemMetadata(ocId: "ec-test", fileName: "test.txt", account: account) + item.fileId = "test" + item.hasPreview = true + let expectedUrl = URL(string: "https://examplecloud.com/index.php/core/preview?fileId=test&x=250.0&y=250.0&a=true") + #expect(expectedUrl != nil) + #expect(item.thumbnailUrl(size: .init(width: 250, height: 250)) == expectedUrl) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift new file mode 100644 index 0000000000000..de71ef5b17abf --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift @@ -0,0 +1,1568 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +import TestInterface +import UniformTypeIdentifiers +import XCTest + +final class ItemModifyTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + + lazy var rootItem = MockRemoteItem.rootItem(account: Self.account) + lazy var rootTrashItem = MockRemoteItem.rootTrashItem(account: Self.account) + + var remoteFolder: MockRemoteItem! + var remoteItem: MockRemoteItem! + var remoteTrashItem: MockRemoteItem! + var remoteTrashFolder: MockRemoteItem! + var remoteTrashFolderChildItem: MockRemoteItem! + + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + + remoteItem = MockRemoteItem( + identifier: "item", + versionIdentifier: "0", + name: "item.txt", + remotePath: Self.account.davFilesUrl + "/item.txt", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + remoteFolder = MockRemoteItem( + identifier: "folder", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + remoteTrashItem = MockRemoteItem( + identifier: "trashItem", + versionIdentifier: "0", + name: "trashItem.txt (trashed)", + remotePath: Self.account.trashUrl + "/trashItem.txt (trashed)", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "folder/trashItem.txt" + ) + remoteTrashFolder = MockRemoteItem( + identifier: "trashedFolder", + versionIdentifier: "0", + name: "trashedFolder (trashed)", + remotePath: Self.account.trashUrl + "/trashedFolder (trashed)", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "trashedFolder" + ) + remoteTrashFolderChildItem = MockRemoteItem( + identifier: "trashChildItem", + versionIdentifier: "0", + name: "trashChildItem.txt", + remotePath: remoteTrashFolder.remotePath + "/trashChildItem.txt", + data: "Hello world, I'm trash!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl, + trashbinOriginalLocation: "trashedFolder/trashChildItem.txt" + ) + + rootItem.children = [remoteItem, remoteFolder] + rootTrashItem.children = [remoteTrashItem, remoteTrashFolder] + remoteItem.parent = rootItem + remoteFolder.parent = rootItem + remoteTrashFolder.children = [remoteTrashFolderChildItem] + remoteTrashFolderChildItem.parent = remoteTrashFolder + } + + func testModifyFile() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let newContents = "Hello, New World!".data(using: .utf8) + let newContentsUrl = FileManager.default.temporaryDirectory.appendingPathComponent("test") + try newContents?.write(to: newContentsUrl) + + var targetItemMetadata = SendableItemMetadata(value: itemMetadata) + targetItemMetadata.name = "item-renamed.txt" // Renamed + targetItemMetadata.fileName = "item-renamed.txt" // Renamed + targetItemMetadata.fileNameView = "item-renamed.txt" // Renamed + targetItemMetadata.serverUrl = Self.account.davFilesUrl + "/folder" // Move + targetItemMetadata.date = .init() + targetItemMetadata.size = Int64(newContents!.count) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let targetItem = Item( + metadata: targetItemMetadata, + parentItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedItemMaybe, error) = await item.modify( + itemTarget: targetItem, + changedFields: [.filename, .contents, .parentItemIdentifier, .contentModificationDate], + contents: newContentsUrl, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + let modifiedItem = try XCTUnwrap(modifiedItemMaybe) + + XCTAssertEqual(modifiedItem.itemIdentifier, targetItem.itemIdentifier) + XCTAssertEqual(modifiedItem.filename, targetItem.filename) + XCTAssertEqual(modifiedItem.parentItemIdentifier, targetItem.parentItemIdentifier) + XCTAssertEqual(modifiedItem.contentModificationDate, targetItem.contentModificationDate) + XCTAssertEqual(modifiedItem.documentSize?.intValue, newContents!.count) + + XCTAssertFalse(remoteFolder.children.isEmpty) + XCTAssertEqual(remoteItem.data, newContents) + XCTAssertEqual(remoteItem.name, targetItemMetadata.fileName) + XCTAssertEqual( + remoteItem.remotePath, targetItemMetadata.serverUrl + "/" + targetItemMetadata.fileName + ) + } + + func testModifyFolder() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let remoteFolderB = MockRemoteItem( + identifier: "folder-b", + name: "folder-b", + remotePath: Self.account.davFilesUrl + "/folder-b", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + rootItem.children = [remoteFolder, remoteFolderB] + remoteFolder.parent = rootItem + remoteFolderB.parent = rootItem + + remoteFolder.children = [remoteItem] + remoteItem.parent = remoteFolder + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let folderBMetadata = remoteFolderB.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderBMetadata) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let testingUrl = FileManager.default.temporaryDirectory.appendingPathComponent("nctest-dir") + do { + try FileManager.default.createDirectory( + atPath: testingUrl.path, withIntermediateDirectories: true, attributes: nil + ) + } catch { + print(error.localizedDescription) + } + + var modifiedFolderMetadata = SendableItemMetadata(value: folderMetadata) + modifiedFolderMetadata.apply(fileName: "folder-renamed") + modifiedFolderMetadata.serverUrl = remoteFolderB.remotePath + + let folderItem = Item( + metadata: folderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let targetFolderItem = Item( + metadata: modifiedFolderMetadata, + parentItemIdentifier: .init(remoteFolderB.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedFolderMaybe, error) = await folderItem.modify( + itemTarget: targetFolderItem, + changedFields: [.filename, .contents, .parentItemIdentifier, .contentModificationDate], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + let modifiedFolder = try XCTUnwrap(modifiedFolderMaybe) + + XCTAssertEqual(modifiedFolder.itemIdentifier, targetFolderItem.itemIdentifier) + XCTAssertEqual(modifiedFolder.filename, targetFolderItem.filename) + XCTAssertEqual(modifiedFolder.parentItemIdentifier, targetFolderItem.parentItemIdentifier) + XCTAssertEqual(modifiedFolder.contentModificationDate, targetFolderItem.contentModificationDate) + + XCTAssertEqual(rootItem.children.count, 1) + XCTAssertEqual(remoteFolder.children.count, 1) + XCTAssertEqual(remoteFolderB.children.count, 1) + XCTAssertEqual(remoteFolder.name, targetFolderItem.filename) + XCTAssertEqual( + remoteFolder.remotePath, modifiedFolderMetadata.serverUrl + "/" + modifiedFolderMetadata.fileName + ) + // We do not yet support modification of folder contents + } + + func testModifyBundleContents() async throws { + let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db + debugPrint(db) + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + let keynoteBundleFilename = "test.key" + let keynoteIndexZipFilename = "Index.zip" + let keynoteRandomFileName = "random.txt" + let keynoteDataFolderName = "Data" + let keynoteDataRandomImageName = "random.jpg" + let keynoteMetadataFolderName = "Metadata" + let keynoteDocIdentifierFilename = "DocumentIdentifier" + let keynoteVersionPlistFilename = "BuildVersionHistory.plist" + let keynotePropertiesPlistFilename = "Properties.plist" + + let remoteFolder = MockRemoteItem( + identifier: "folder", + versionIdentifier: "old", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteBundle = MockRemoteItem( + identifier: keynoteBundleFilename, + versionIdentifier: "old", + name: keynoteBundleFilename, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename, + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteDataFolder = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteDataFolderName, + versionIdentifier: "old", + name: keynoteDataFolderName, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteDataFolderName, + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteDataRandomFile = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteDataFolderName + "/" + keynoteDataRandomImageName, + versionIdentifier: "old", + name: keynoteDataRandomImageName, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteDataFolderName + "/" + keynoteDataRandomImageName, + data: "000".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteMetadataFolder = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteMetadataFolderName, + versionIdentifier: "old", + name: keynoteMetadataFolderName, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteMetadataFolderName, + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteIndexZip = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteIndexZipFilename, + versionIdentifier: "old", + name: keynoteIndexZipFilename, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteIndexZipFilename, + data: "This is a fake zip, pre modification".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteRandomFile = MockRemoteItem( // We will want this to be gone later + identifier: keynoteBundleFilename + "/" + keynoteRandomFileName, + versionIdentifier: "old", + name: keynoteRandomFileName, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteRandomFileName, + data: "This is a random file, I should be gone post modify".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteDocIdentifier = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteMetadataFolderName + "/" + keynoteDocIdentifierFilename, + versionIdentifier: "old", + name: keynoteDocIdentifierFilename, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteMetadataFolderName + "/" + keynoteDocIdentifierFilename, + data: "8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynoteVersionPlist = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteMetadataFolderName + "/" + keynoteVersionPlistFilename, + versionIdentifier: "new", + name: keynoteVersionPlistFilename, + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteMetadataFolderName + "/" + keynoteVersionPlistFilename, + data: """ + + + + + Template: 35_DynamicWavesDark (14.1) + M14.1-7040.0.73-4 + + + """.data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteKeynotePropertiesPlist = MockRemoteItem( + identifier: keynoteBundleFilename + "/" + keynoteMetadataFolderName + "/" + keynotePropertiesPlistFilename, + versionIdentifier: "old", + name: "Properties.plist", + remotePath: Self.account.davFilesUrl + "/" + keynoteBundleFilename + "/" + keynoteMetadataFolderName + "/" + keynotePropertiesPlistFilename, + data: """ + + + + + revision + 0::5B42B84E-6F62-4E53-9E71-7DD24FA7E2EA + documentUUID + 8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01 + versionUUID + 5B42B84E-6F62-4E53-9E71-7DD24FA7E2EA + privateUUID + 637C846B-6146-40C2-8EF8-26996E598E49 + isMultiPage + + stableDocumentUUID + 8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01 + fileFormatVersion + 14.1.1 + shareUUID + 8B0C6C1F-4DA4-4DE8-8510-0C91FDCE7D01 + + + """.data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + rootItem.children.forEach { $0.parent = nil } + rootItem.children = [remoteKeynoteBundle, remoteFolder] + remoteFolder.parent = rootItem + remoteKeynoteBundle.parent = rootItem + remoteKeynoteBundle.children = [ + remoteKeynoteIndexZip, + remoteKeynoteRandomFile, + remoteKeynoteDataFolder, + remoteKeynoteMetadataFolder + ] + remoteKeynoteIndexZip.parent = remoteKeynoteBundle + remoteKeynoteRandomFile.parent = remoteKeynoteBundle + remoteKeynoteDataFolder.parent = remoteKeynoteBundle + remoteKeynoteDataRandomFile.parent = remoteKeynoteDataFolder + remoteKeynoteDataFolder.children = [remoteKeynoteDataRandomFile] + remoteKeynoteMetadataFolder.parent = remoteKeynoteBundle + remoteKeynoteMetadataFolder.children = [ + remoteKeynoteDocIdentifier, + remoteKeynoteVersionPlist, + remoteKeynotePropertiesPlist + ] + remoteKeynoteDocIdentifier.parent = remoteKeynoteMetadataFolder + remoteKeynoteVersionPlist.parent = remoteKeynoteMetadataFolder + remoteKeynotePropertiesPlist.parent = remoteKeynoteMetadataFolder + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + var bundleItemMetadata = remoteKeynoteBundle.toItemMetadata(account: Self.account) + bundleItemMetadata.contentType = UTType.bundle.identifier + Self.dbManager.addItemMetadata(bundleItemMetadata) + + var bundleIndexZipMetadata = remoteKeynoteIndexZip.toItemMetadata(account: Self.account) + bundleIndexZipMetadata.classFile = NKTypeClassFile.compress.rawValue + bundleIndexZipMetadata.contentType = UTType.zip.identifier + Self.dbManager.addItemMetadata(bundleIndexZipMetadata) + + var bundleRandomFileMetadata = remoteKeynoteRandomFile.toItemMetadata(account: Self.account) + bundleRandomFileMetadata.contentType = UTType.text.identifier + Self.dbManager.addItemMetadata(bundleRandomFileMetadata) + + let bundleDataFolderMetadata = remoteKeynoteDataFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(bundleDataFolderMetadata) + + var bundleDataRandomFileMetadata = + remoteKeynoteDataRandomFile.toItemMetadata(account: Self.account) + bundleDataRandomFileMetadata.classFile = NKTypeClassFile.image.rawValue + bundleDataRandomFileMetadata.contentType = UTType.image.identifier + Self.dbManager.addItemMetadata(bundleDataRandomFileMetadata) + + let bundleMetadataFolderMetadata = remoteKeynoteMetadataFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(bundleMetadataFolderMetadata) + + var bundleDocIdentifierMetadata = + remoteKeynoteDocIdentifier.toItemMetadata(account: Self.account) + bundleDocIdentifierMetadata.contentType = UTType.text.identifier + Self.dbManager.addItemMetadata(bundleDocIdentifierMetadata) + + var bundleVersionPlistMetadata = + remoteKeynoteVersionPlist.toItemMetadata(account: Self.account) + bundleVersionPlistMetadata.contentType = UTType.xml.identifier + Self.dbManager.addItemMetadata(bundleVersionPlistMetadata) + + var bundlePropertiesPlistMetadata = + remoteKeynotePropertiesPlist.toItemMetadata(account: Self.account) + bundlePropertiesPlistMetadata.size = Int64(remoteKeynotePropertiesPlist.data?.count ?? 0) + bundlePropertiesPlistMetadata.directory = false + bundlePropertiesPlistMetadata.contentType = UTType.xml.identifier + + Self.dbManager.addItemMetadata(bundlePropertiesPlistMetadata) + + let bundleItem = Item( + metadata: bundleItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let fm = FileManager.default + let tempUrl = fm.temporaryDirectory.appendingPathComponent(keynoteBundleFilename) + try fm.createDirectory(at: tempUrl, withIntermediateDirectories: true, attributes: nil) + let keynoteIndexZipPath = tempUrl.appendingPathComponent("Index.zip") + try Data("This is a fake zip (but new!)".utf8).write(to: keynoteIndexZipPath) + let keynoteDataDir = tempUrl.appendingPathComponent("Data") + try fm.createDirectory( + at: keynoteDataDir, withIntermediateDirectories: true, attributes: nil + ) + let keynoteMetadataDir = tempUrl.appendingPathComponent("Metadata") + try fm.createDirectory( + at: keynoteMetadataDir, withIntermediateDirectories: true, attributes: nil + ) + let keynoteDocIdentifierPath = + keynoteMetadataDir.appendingPathComponent("DocumentIdentifier") + try Data("82LQN84b-12JF-BV90-13F0-149UFRN241B".utf8).write(to: keynoteDocIdentifierPath) + let keynoteBuildVersionPlistPath = + keynoteMetadataDir.appendingPathComponent("BuildVersionHistory.plist") + try Data( + """ + + + + + Template: 34_DynamicWaves (15.0) + M15.0-7040.0.73-4 + + + """ + .utf8).write(to: keynoteBuildVersionPlistPath) + let keynotePropertiesPlistPath = + keynoteMetadataDir.appendingPathComponent("Properties.plist") + try Data( + """ + + + + + revision + SOME-RANDOM-REVISION-STRING + documentUUID + 82LQN84b-12JF-BV90-13F0-149UFRN241B + versionUUID + VERSION-BEEP-BOOP-HEHE + privateUUID + PRIVATE-UUID-BEEP-BOOP-HEHE + isMultiPage + + stableDocumentUUID + 82LQN84b-12JF-BV90-13F0-149UFRN241B + fileFormatVersion + 15.0 + shareUUID + 82LQN84b-12JF-BV90-13F0-149UFRN241B + + + """ + .utf8).write(to: keynotePropertiesPlistPath) + + var targetBundleMetadata = remoteKeynoteBundle.toItemMetadata(account: Self.account) + targetBundleMetadata.etag = "this-is-a-new-etag" + targetBundleMetadata.name = "renamed-" + keynoteBundleFilename + targetBundleMetadata.fileName = "renamed-" + keynoteBundleFilename + targetBundleMetadata.fileNameView = "renamed-" + keynoteBundleFilename + targetBundleMetadata.serverUrl = Self.account.davFilesUrl + "/folder" // Move + + let targetItem = Item( + metadata: targetBundleMetadata, + parentItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedItemMaybe, error) = await bundleItem.modify( + itemTarget: targetItem, + changedFields: [.filename, .contents, .parentItemIdentifier, .contentModificationDate], + contents: tempUrl, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + let modifiedItem = try XCTUnwrap(modifiedItemMaybe) + + XCTAssertEqual(modifiedItem.itemIdentifier, targetItem.itemIdentifier) + XCTAssertEqual(modifiedItem.filename, targetItem.filename) + XCTAssertEqual(modifiedItem.parentItemIdentifier, targetItem.parentItemIdentifier) + // TODO: This is a folder, unfortunately through NCKit we cannot set these details on a + // TODO: folder's creation; we should fix this + // XCTAssertEqual(modifiedItem.contentModificationDate, targetItem.contentModificationDate) + + XCTAssertEqual(remoteFolder.children.count, 1) + XCTAssertEqual(remoteFolder.children.first, remoteKeynoteBundle) + XCTAssertEqual(remoteKeynoteBundle.name, targetBundleMetadata.fileName) + XCTAssertEqual( + remoteKeynoteBundle.remotePath, + targetBundleMetadata.serverUrl + "/" + targetBundleMetadata.fileName + ) + + XCTAssertNil(remoteKeynoteBundle.children.first { $0.name == keynoteRandomFileName }) + XCTAssertNil( + remoteKeynoteDataFolder.children.first { $0.name == keynoteDataRandomImageName } + ) + + XCTAssertEqual(remoteKeynoteBundle.children.count, 3) + XCTAssertNotNil(remoteKeynoteBundle.children.first { $0.name == keynoteIndexZipFilename }) + XCTAssertNotNil(remoteKeynoteBundle.children.first { $0.name == keynoteMetadataFolderName }) + XCTAssertNotNil(remoteKeynoteBundle.children.first { $0.name == keynoteDataFolderName }) + XCTAssertEqual(remoteKeynoteDataFolder.children.count, 0) + XCTAssertEqual(remoteKeynoteMetadataFolder.children.count, 3) + XCTAssertNotNil( + remoteKeynoteMetadataFolder.children.first { $0.name == keynoteDocIdentifierFilename } + ) + XCTAssertNotNil( + remoteKeynoteMetadataFolder.children.first { $0.name == keynoteVersionPlistFilename } + ) + XCTAssertNotNil( + remoteKeynoteMetadataFolder.children.first { $0.name == keynotePropertiesPlistFilename } + ) + + XCTAssertEqual(remoteKeynoteIndexZip.data, try Data(contentsOf: keynoteIndexZipPath)) + XCTAssertEqual( + remoteKeynoteDocIdentifier.data, try Data(contentsOf: keynoteDocIdentifierPath) + ) + XCTAssertEqual( + remoteKeynoteVersionPlist.data, try Data(contentsOf: keynoteBuildVersionPlistPath) + ) + XCTAssertEqual( + remoteKeynotePropertiesPlist.data, try Data(contentsOf: keynotePropertiesPlistPath) + ) + } + + func testMoveFileToTrash() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let trashItem = Item( + metadata: itemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (trashedItemMaybe, error) = await item.modify( + itemTarget: trashItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + + XCTAssertEqual(rootTrashItem.children.count, 3) + let remoteTrashedItem = + rootTrashItem.children.first(where: { $0.identifier == itemMetadata.ocId + trashedItemIdSuffix }) + XCTAssertNotNil(remoteTrashedItem) + + let trashedItem = try XCTUnwrap(trashedItemMaybe) + XCTAssertEqual( + trashedItem.itemIdentifier.rawValue + trashedItemIdSuffix, remoteTrashedItem?.identifier + ) + // The mock remote interface renames items when trashing them, so, ensure this is synced + XCTAssertEqual(trashedItem.metadata.fileName, remoteTrashedItem?.name) + XCTAssertEqual(trashedItem.metadata.isTrashed, true) + XCTAssertEqual( + trashedItem.metadata.trashbinOriginalLocation, + (itemMetadata.serverUrl + "/" + itemMetadata.fileName) + .replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + ) + XCTAssertEqual(trashedItem.parentItemIdentifier, .trashContainer) + } + + func testRenameMoveFileToTrash() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let (_, _, initMoveError) = await remoteInterface.move( + remotePathSource: remoteItem.remotePath, + remotePathDestination: remoteFolder.remotePath + "/" + remoteItem.name, + account: Self.account + ) + XCTAssertEqual(initMoveError, .success) + + let folderMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + var renamedItemMetadata = SendableItemMetadata(value: itemMetadata) + renamedItemMetadata.name = "renamed" + renamedItemMetadata.fileName = "renamed" + renamedItemMetadata.fileNameView = "renamed" + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let trashItem = Item( + metadata: renamedItemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (trashedItemMaybe, error) = await item.modify( + itemTarget: trashItem, + changedFields: [.parentItemIdentifier, .filename], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + + XCTAssertEqual(rootTrashItem.children.count, 3) + let remoteTrashedItem = rootTrashItem.children.first( + where: { $0.identifier == itemMetadata.ocId + trashedItemIdSuffix } + ) + XCTAssertNotNil(remoteTrashedItem) + + let trashedItem = try XCTUnwrap(trashedItemMaybe) + XCTAssertEqual( + trashedItem.itemIdentifier.rawValue + trashedItemIdSuffix, remoteTrashedItem?.identifier + ) + XCTAssertTrue(remoteTrashedItem?.name.hasPrefix(renamedItemMetadata.fileName) ?? false) + // The mock remote interface renames items when trashing them, so, ensure this is synced + XCTAssertEqual(trashedItem.metadata.fileName, remoteTrashedItem?.name) + XCTAssertEqual(trashedItem.metadata.isTrashed, true) + XCTAssertEqual( + trashedItem.metadata.trashbinOriginalLocation, + (remoteFolder.remotePath + "/" + renamedItemMetadata.fileName) + .replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + ) + XCTAssertEqual(trashedItem.parentItemIdentifier, .trashContainer) + } + + func testMoveFolderToTrash() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteFolder = MockRemoteItem( + identifier: "folder", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteItem = MockRemoteItem( + identifier: "item", + versionIdentifier: "0", + name: "item.txt", + remotePath: remoteFolder.remotePath + "/item.txt", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + remoteFolder.children = [remoteItem] + remoteItem.parent = remoteFolder + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let folderItem = Item( + metadata: folderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let trashFolderItem = Item( + metadata: folderMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (trashedFolderItemMaybe, error) = await folderItem.modify( + itemTarget: trashFolderItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + + XCTAssertEqual(rootTrashItem.children.count, 3) + let remoteTrashedFolderItem = rootTrashItem.children.first( + where: { $0.identifier == folderMetadata.ocId + trashedItemIdSuffix } + ) + XCTAssertNotNil(remoteTrashedFolderItem) + + let trashedFolderItem = try XCTUnwrap(trashedFolderItemMaybe) + XCTAssertEqual( + trashedFolderItem.itemIdentifier.rawValue + trashedItemIdSuffix, + remoteTrashedFolderItem?.identifier + ) + // The mock remote interface renames items when trashing them, so, ensure this is synced + XCTAssertEqual(trashedFolderItem.metadata.fileName, remoteTrashedFolderItem?.name) + XCTAssertEqual(trashedFolderItem.metadata.isTrashed, true) + XCTAssertEqual( + trashedFolderItem.metadata.trashbinOriginalLocation, + (folderMetadata.serverUrl + "/" + folderMetadata.fileName) + .replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + ) + XCTAssertEqual(trashedFolderItem.parentItemIdentifier, .trashContainer) + + let trashChildItemMetadata = Self.dbManager.itemMetadata(ocId: itemMetadata.ocId) + XCTAssertNotNil(trashChildItemMetadata) + XCTAssertEqual(trashChildItemMetadata?.isTrashed, true) + XCTAssertEqual( + trashChildItemMetadata?.serverUrl, + trashedFolderItem.metadata.serverUrl + "/" + trashedFolderItem.metadata.fileName + ) + XCTAssertEqual(trashChildItemMetadata?.trashbinFileName, itemMetadata.fileName) + XCTAssertEqual( + trashChildItemMetadata?.trashbinOriginalLocation, + (itemMetadata.serverUrl + "/" + itemMetadata.fileName) + .replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + ) + } + + func testMoveFolderToTrashWithRename() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteFolder = MockRemoteItem( + identifier: "folder", + name: "folder", + remotePath: Self.account.davFilesUrl + "/folder", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let remoteItem = MockRemoteItem( + identifier: "item", + versionIdentifier: "0", + name: "item.txt", + remotePath: remoteFolder.remotePath + "/item.txt", + data: "Hello, World!".data(using: .utf8), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + rootItem.children = [remoteFolder] + remoteFolder.parent = rootItem + remoteFolder.children = [remoteItem] + remoteItem.parent = remoteFolder + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + var renamedFolderMetadata = SendableItemMetadata(value: folderMetadata) + renamedFolderMetadata.fileName = "folder (renamed)" + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let folderItem = Item( + metadata: folderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let trashFolderItem = Item( + metadata: renamedFolderMetadata, // Test rename first and then trash + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (trashedFolderItemMaybe, error) = await folderItem.modify( + itemTarget: trashFolderItem, + changedFields: [.parentItemIdentifier, .filename], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + + XCTAssertEqual(rootTrashItem.children.count, 3) + let remoteTrashedFolderItem = rootTrashItem.children.first( + where: { $0.identifier == folderMetadata.ocId + trashedItemIdSuffix } + ) + XCTAssertNotNil(remoteTrashedFolderItem) + + let trashedFolderItem = try XCTUnwrap(trashedFolderItemMaybe) + XCTAssertEqual( + trashedFolderItem.itemIdentifier.rawValue + trashedItemIdSuffix, + remoteTrashedFolderItem?.identifier + ) + // The mock remote interface renames items when trashing them, so, ensure this is synced + XCTAssertEqual(trashedFolderItem.metadata.fileName, remoteTrashedFolderItem?.name) + XCTAssertEqual(trashedFolderItem.metadata.isTrashed, true) + XCTAssertEqual( + trashedFolderItem.metadata.trashbinOriginalLocation, + (renamedFolderMetadata.serverUrl + "/" + renamedFolderMetadata.fileName) + .replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + ) + XCTAssertEqual(trashedFolderItem.parentItemIdentifier, .trashContainer) + + let trashChildItemMetadata = Self.dbManager.itemMetadata(ocId: itemMetadata.ocId) + XCTAssertNotNil(trashChildItemMetadata) + XCTAssertEqual(trashChildItemMetadata?.isTrashed, true) + XCTAssertEqual( + trashChildItemMetadata?.serverUrl, + trashedFolderItem.metadata.serverUrl + "/" + trashedFolderItem.metadata.fileName + ) + XCTAssertEqual(trashChildItemMetadata?.trashbinFileName, itemMetadata.fileName) + XCTAssertEqual( + trashChildItemMetadata?.trashbinOriginalLocation, + (renamedFolderMetadata.serverUrl + "/" + renamedFolderMetadata.fileName + "/" + itemMetadata.fileName) + .replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + ) + } + + func testTrashAndMoveFileOutOfTrash() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let trashItem = Item( + metadata: itemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (trashedItemMaybe, trashError) = await item.modify( + itemTarget: trashItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(trashError) + let trashedItem = try XCTUnwrap(trashedItemMaybe) + XCTAssertEqual(trashedItem.parentItemIdentifier, .trashContainer) + + let (untrashedItemMaybe, untrashError) = await trashedItem.modify( + itemTarget: item, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(untrashError) + let untrashedItem = try XCTUnwrap(untrashedItemMaybe) + XCTAssertEqual(untrashedItem.parentItemIdentifier, .rootContainer) + } + + func testMoveTrashedFileOutOfTrash() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + let trashItemMetadata = remoteTrashItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashItemMetadata) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let trashItem = Item( + metadata: trashItemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let untrashedTargetItem = Item( + metadata: trashItemMetadata, + parentItemIdentifier: .init(remoteFolder.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (untrashedItemMaybe, untrashError) = await trashItem.modify( + itemTarget: untrashedTargetItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(untrashError) + let untrashedItem = try XCTUnwrap(untrashedItemMaybe) + XCTAssertEqual(untrashedItem.parentItemIdentifier, .init(remoteFolder.identifier)) + } + + func testMoveTrashedFileOutOfTrashAndRenameAndModifyContents() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + let trashItemMetadata = remoteTrashItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashItemMetadata) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let newContents = "I've changed!".data(using: .utf8)! + let newContentsUrl = FileManager.default.temporaryDirectory.appendingPathComponent("test") + try newContents.write(to: newContentsUrl) + + var targetItemMetadata = SendableItemMetadata(value: trashItemMetadata) + targetItemMetadata.serverUrl = Self.account.davFilesUrl + targetItemMetadata.fileName = "new-file.txt" + targetItemMetadata.fileNameView = "new-file.txt" + targetItemMetadata.name = "new-file.txt" + targetItemMetadata.size = Int64(newContents.count) + targetItemMetadata.trashbinFileName = "" + targetItemMetadata.trashbinOriginalLocation = "" + targetItemMetadata.trashbinDeletionTime = Date() + + let trashItem = Item( + metadata: trashItemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let targetItem = Item( + metadata: targetItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedUntrashedItemMaybe, error) = await trashItem.modify( + itemTarget: targetItem, + changedFields: [.parentItemIdentifier, .filename, .contents], + contents: newContentsUrl, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + + let modifiedUntrashedItem = try XCTUnwrap(modifiedUntrashedItemMaybe) + + XCTAssertEqual(modifiedUntrashedItem.parentItemIdentifier, .rootContainer) + XCTAssertEqual(modifiedUntrashedItem.itemIdentifier, targetItem.itemIdentifier) + XCTAssertEqual(modifiedUntrashedItem.filename, targetItem.filename) + XCTAssertEqual(modifiedUntrashedItem.documentSize?.int64Value, targetItemMetadata.size) + + XCTAssertEqual(remoteTrashItem.name, targetItem.filename) + XCTAssertEqual(remoteTrashItem.data!, newContents) + } + + func testMoveFileOutOfTrashWithExistingIdenticallyNamedFile() async throws { + // Make sure that we properly get the post-untrash state of the target item and not the + // identically-named file in the location the file has been untrashed to + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + remoteTrashItem.trashbinOriginalLocation = + remoteItem.remotePath.replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") + + let trashItemMetadata = remoteTrashItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashItemMetadata) + + let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(folderMetadata) + + let trashItem = Item( + metadata: trashItemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let untrashedTargetItem = Item( + metadata: trashItemMetadata, + parentItemIdentifier: .init(rootItem.identifier), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (untrashedItemMaybe, untrashError) = await trashItem.modify( + itemTarget: untrashedTargetItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(untrashError) + let untrashedItem = try XCTUnwrap(untrashedItemMaybe) + XCTAssertEqual(untrashedItem.itemIdentifier, trashItem.itemIdentifier) + XCTAssertEqual(untrashedItem.parentItemIdentifier, .init(rootItem.identifier)) + } + + func testMoveFolderOutOfTrash() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + let trashFolderMetadata = remoteTrashFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashFolderMetadata) + + let trashFolderChildItemMetadata = + remoteTrashFolderChildItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashFolderChildItemMetadata) + + let trashedFolderItem = Item( + metadata: trashFolderMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let untrashedTargetItem = Item( + metadata: trashFolderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (untrashedFolderItemMaybe, untrashError) = await trashedFolderItem.modify( + itemTarget: untrashedTargetItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(untrashError) + let untrashedItem = try XCTUnwrap(untrashedFolderItemMaybe) + XCTAssertEqual(untrashedItem.parentItemIdentifier, .rootContainer) + XCTAssertEqual(remoteTrashFolder.children.count, 1) + XCTAssertTrue(remoteTrashFolder.remotePath.hasPrefix(Self.account.davFilesUrl)) + + let untrashedFolderChildItemMaybe = + Self.dbManager.itemMetadata(ocId: remoteTrashFolderChildItem.identifier) + let untrashedFolderChildItem = try XCTUnwrap(untrashedFolderChildItemMaybe) + XCTAssertEqual(remoteTrashFolder.children.first?.identifier, untrashedFolderChildItem.ocId) + XCTAssertEqual( + remoteTrashFolderChildItem.remotePath, + remoteTrashFolder.remotePath + "/" + remoteTrashFolderChildItem.name + ) + XCTAssertEqual(untrashedFolderChildItem.serverUrl, remoteTrashFolder.remotePath) + } + + func testMoveFolderOutOfTrashAndRename() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + + let trashFolderMetadata = remoteTrashFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashFolderMetadata) + + var renamedTrashFolderMetadata = SendableItemMetadata(value: trashFolderMetadata) + renamedTrashFolderMetadata.apply(fileName: "renamed-folder") + renamedTrashFolderMetadata.serverUrl = Self.account.davFilesUrl + renamedTrashFolderMetadata.trashbinFileName = "" + renamedTrashFolderMetadata.trashbinOriginalLocation = "" + renamedTrashFolderMetadata.trashbinDeletionTime = Date() + + let trashFolderChildItemMetadata = + remoteTrashFolderChildItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(trashFolderChildItemMetadata) + + let trashedFolderItem = Item( + metadata: trashFolderMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let untrashedTargetItem = Item( + metadata: renamedTrashFolderMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (untrashedFolderItemMaybe, untrashError) = await trashedFolderItem.modify( + itemTarget: untrashedTargetItem, + changedFields: [.parentItemIdentifier, .filename], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNil(untrashError) + let untrashedFolderItem = try XCTUnwrap(untrashedFolderItemMaybe) + XCTAssertEqual(untrashedFolderItem.parentItemIdentifier, .rootContainer) + XCTAssertEqual(untrashedFolderItem.filename, renamedTrashFolderMetadata.fileName) + XCTAssertEqual(remoteTrashFolder.children.count, 1) + XCTAssertEqual(remoteTrashFolder.name, renamedTrashFolderMetadata.fileName) + XCTAssertTrue(remoteTrashFolder.remotePath.hasPrefix(Self.account.davFilesUrl)) + + let untrashedFolderChildItemMaybe = + Self.dbManager.itemMetadata(ocId: remoteTrashFolderChildItem.identifier) + let untrashedFolderChildItem = try XCTUnwrap(untrashedFolderChildItemMaybe) + XCTAssertEqual(remoteTrashFolder.children.first?.identifier, untrashedFolderChildItem.ocId) + XCTAssertEqual( + remoteTrashFolderChildItem.remotePath, + remoteTrashFolder.remotePath + "/" + remoteTrashFolderChildItem.name + ) + XCTAssertEqual(untrashedFolderChildItem.serverUrl, remoteTrashFolder.remotePath) + } + + func testModifyFileContentsChunked() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let chunkSize = 2 + let newContents = Data(repeating: 1, count: chunkSize * 3) + let newContentsUrl = FileManager.default.temporaryDirectory.appendingPathComponent("test") + try newContents.write(to: newContentsUrl) + + var targetItemMetadata = SendableItemMetadata(value: itemMetadata) + targetItemMetadata.date = .init() + targetItemMetadata.size = Int64(newContents.count) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let targetItem = Item( + metadata: targetItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedItemMaybe, error) = await item.modify( + itemTarget: targetItem, + changedFields: [.contents, .contentModificationDate], + contents: newContentsUrl, + forcedChunkSize: chunkSize, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + let modifiedItem = try XCTUnwrap(modifiedItemMaybe) + + XCTAssertEqual(modifiedItem.itemIdentifier, targetItem.itemIdentifier) + XCTAssertEqual(modifiedItem.contentModificationDate, targetItem.contentModificationDate) + XCTAssertEqual(modifiedItem.documentSize?.intValue, newContents.count) + + XCTAssertEqual(remoteItem.data, newContents) + } + + func testModifyFileContentsChunkedResumed() async throws { + let chunkSize = 2 + let chunkUploadId = UUID().uuidString + let previousUploadedChunkNum = 1 + let preexistingChunk = RemoteFileChunk( + fileName: String(previousUploadedChunkNum), + size: Int64(chunkSize), + remoteChunkStoreFolderName: chunkUploadId + ) + + let db = Self.dbManager.ncDatabase() + try db.write { + db.add([ + RemoteFileChunk( + fileName: String(previousUploadedChunkNum + 1), + size: Int64(chunkSize), + remoteChunkStoreFolderName: chunkUploadId + ), + RemoteFileChunk( + fileName: String(previousUploadedChunkNum + 2), + size: Int64(chunkSize), + remoteChunkStoreFolderName: chunkUploadId + ) + ]) + } + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + remoteInterface.currentChunks = [chunkUploadId: [preexistingChunk]] + + var itemMetadata = remoteItem.toItemMetadata(account: Self.account) + itemMetadata.chunkUploadId = chunkUploadId + Self.dbManager.addItemMetadata(itemMetadata) + + let newContents = Data(repeating: 1, count: chunkSize * 3) + let newContentsUrl = FileManager.default.temporaryDirectory.appendingPathComponent("test") + try newContents.write(to: newContentsUrl) + + var targetItemMetadata = SendableItemMetadata(value: itemMetadata) + targetItemMetadata.date = .init() + targetItemMetadata.size = Int64(newContents.count) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let targetItem = Item( + metadata: targetItemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedItemMaybe, error) = await item.modify( + itemTarget: targetItem, + changedFields: [.contents, .contentModificationDate], + contents: newContentsUrl, + forcedChunkSize: chunkSize, + dbManager: Self.dbManager + ) + XCTAssertNil(error) + let modifiedItem = try XCTUnwrap(modifiedItemMaybe) + + XCTAssertEqual(modifiedItem.itemIdentifier, targetItem.itemIdentifier) + XCTAssertEqual(modifiedItem.contentModificationDate, targetItem.contentModificationDate) + XCTAssertEqual(modifiedItem.documentSize?.intValue, newContents.count) + + XCTAssertEqual(remoteItem.data, newContents) + XCTAssertEqual( + remoteInterface.completedChunkTransferSize[chunkUploadId], + Int64(newContents.count) - preexistingChunk.size + ) + + let dbItem = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: itemMetadata.ocId)) + XCTAssertNil(dbItem.chunkUploadId) + } + + func testModifyDoesNotPropagateIgnoredFile() async throws { + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.bak", "/logs/"]) + let metadata = SendableItemMetadata( + ocId: "ignored-modify-id", + fileName: "error.bak", + account: Self.account + ) + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account, rootItem: rootItem), + dbManager: Self.dbManager + ) + let (resultItem, error) = await item.modify( + itemTarget: item, + changedFields: [.contents], + contents: nil, + ignoredFiles: ignoredMatcher, + dbManager: Self.dbManager + ) + if #available(macOS 13.0, *) { + XCTAssertEqual(error as? NSFileProviderError, NSFileProviderError(.excludedFromSync)) + } else { + XCTAssertNil(error) + } + XCTAssertNotNil(resultItem) + XCTAssertEqual(resultItem?.metadata.fileName, "error.bak") + } + + func testModifyCreatesFileThatWasPreviouslyIgnoredWithContentsUrlProvided() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["/logs/"]) + + let tempFileName = UUID().uuidString + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(tempFileName) + let modifiedData = try XCTUnwrap("Hello world".data(using: .utf8)) + try modifiedData.write(to: tempUrl) + + var metadata = SendableItemMetadata( + ocId: UUID().uuidString, // We will still be holding the ID given by fileproviderd + fileName: "error.bak", + account: Self.account + ) + // Imitate expected uploaded/downloaded state + metadata.uploaded = false + metadata.downloaded = true + Self.dbManager.addItemMetadata(metadata) + + var modifiedMetadata = metadata + modifiedMetadata.size = Int64(modifiedData.count) + + let item = Item( + metadata: modifiedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (resultItem, error) = await item.modify( + itemTarget: item, + changedFields: [.contents], + contents: tempUrl, + ignoredFiles: ignoredMatcher, + dbManager: Self.dbManager + ) + + // Then it should not error and should not propagate changes + XCTAssertNil(error) + XCTAssertNotNil(resultItem) + + XCTAssertFalse(rootItem.children.isEmpty) + let remoteItem = try XCTUnwrap( + rootItem.children.first { $0.identifier == resultItem?.itemIdentifier.rawValue } + ) + XCTAssertEqual(remoteItem.name, metadata.fileName) + XCTAssertEqual(remoteItem.data, modifiedData) + } + + func testModifyLockFileCompletesWithoutSyncing() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Construct lock file metadata + let lockFileName = ".~lock.test.doc#" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.classFile = "lock" + + let lockItem = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + // Simulate new contents, even though this shouldn't matter + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(lockFileName) + let tempData = try XCTUnwrap(Data("updated lock file".utf8)) + try tempData.write(to: tempUrl) + + var newParent = SendableItemMetadata(ocId: "np", fileName: "np", account: Self.account) + newParent.serverUrl = Self.account.davFilesUrl + Self.dbManager.addItemMetadata(newParent) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: newParent.ocId)) + + var modifiedMetadata = lockFileMetadata + modifiedMetadata.fileName = ".~lock.newtest.doc#" + modifiedMetadata.size = Int64(tempData.count) + modifiedMetadata.date = Date() + modifiedMetadata.creationDate = Date(timeIntervalSinceNow: -100) + let modifyTemplateItem = Item( + metadata: modifiedMetadata, + parentItemIdentifier: .init(newParent.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedItem, error) = await lockItem.modify( + itemTarget: modifyTemplateItem, + changedFields: [ + .filename, .contents, .parentItemIdentifier, .creationDate, .contentModificationDate + ], + contents: tempUrl, + dbManager: Self.dbManager + ) + + XCTAssertNil(error) + XCTAssertEqual(modifiedItem?.itemIdentifier, lockItem.itemIdentifier) + XCTAssertEqual(modifiedItem?.filename, modifiedMetadata.fileName) + XCTAssertEqual(modifiedItem?.documentSize?.intValue, tempData.count) + XCTAssertEqual(modifiedItem?.parentItemIdentifier.rawValue, newParent.ocId) + XCTAssertEqual(modifiedItem?.contentModificationDate, modifiedMetadata.date) + XCTAssertEqual(modifiedItem?.creationDate, modifiedMetadata.creationDate) + } + + func testModifyLockFileToNonLockFileCompletesWithSync() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + // Construct lock file metadata + let lockFileName = ".~lock.test.doc#" + var lockFileMetadata = SendableItemMetadata( + ocId: "lock-id", fileName: lockFileName, account: Self.account + ) + lockFileMetadata.classFile = "lock" + + let lockItem = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + // Simulate new contents, even though this shouldn't matter + let tempUrl = FileManager.default.temporaryDirectory.appendingPathComponent(lockFileName) + let tempData = try XCTUnwrap(Data("updated, no longer a lock file".utf8)) + try tempData.write(to: tempUrl) + + let newParent = remoteFolder.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(newParent) + XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: newParent.ocId)) + + var modifiedMetadata = lockFileMetadata + modifiedMetadata.fileName = "nolongerlock.txt" + modifiedMetadata.size = Int64(tempData.count) + modifiedMetadata.date = Date() + modifiedMetadata.creationDate = Date(timeIntervalSinceNow: -100) + let modifyTemplateItem = Item( + metadata: modifiedMetadata, + parentItemIdentifier: .init(newParent.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (modifiedItem, error) = await lockItem.modify( + itemTarget: modifyTemplateItem, + changedFields: [ + .filename, .contents, .parentItemIdentifier, .creationDate, .contentModificationDate + ], + contents: tempUrl, + dbManager: Self.dbManager + ) + + XCTAssertNil(error) + + let remoteItem = try XCTUnwrap( + remoteFolder.children.first(where: { $0.name == modifiedMetadata.fileName }) + ) + + // remote will always give new ocId on create + XCTAssertNotEqual(modifiedItem?.itemIdentifier, lockItem.itemIdentifier) + XCTAssertNotEqual(modifiedItem?.itemVersion.contentVersion, lockItem.itemVersion.contentVersion) + + XCTAssertEqual(modifiedItem?.itemIdentifier.rawValue, remoteItem.identifier) + XCTAssertEqual(modifiedItem?.metadata.etag, remoteItem.versionIdentifier) + + XCTAssertEqual(modifiedItem?.filename, modifiedMetadata.fileName) + XCTAssertEqual(modifiedItem?.documentSize?.intValue, tempData.count) + XCTAssertEqual(modifiedItem?.parentItemIdentifier.rawValue, newParent.ocId) + XCTAssertEqual(modifiedItem?.contentModificationDate, modifiedMetadata.date) + + XCTAssertNotEqual(modifiedItem?.metadata.classFile, "lock") + } + + func testMoveToTrashFailsWhenNoTrashInCapabilities() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + + let itemMetadata = remoteItem.toItemMetadata(account: Self.account) + Self.dbManager.addItemMetadata(itemMetadata) + + let item = Item( + metadata: itemMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + let trashItem = Item( + metadata: itemMetadata, + parentItemIdentifier: .trashContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager + ) + + let (_, error) = await item.modify( + itemTarget: trashItem, + changedFields: [.parentItemIdentifier], + contents: nil, + dbManager: Self.dbManager + ) + XCTAssertNotNil(error) + XCTAssertEqual((error as NSError?)?.code, NSFeatureUnsupportedError) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift new file mode 100644 index 0000000000000..4c9df8318b997 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift @@ -0,0 +1,948 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +import TestInterface +import UniformTypeIdentifiers +import XCTest + +final class ItemPropertyTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + + func testMetadataContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadata.etag = "test-etag" + metadata.contentType = UTType.text.identifier + metadata.size = 12 + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(item.contentType, UTType.text) + } + + func testMetadataExtensionContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.pdf", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + // Don't set the content type in metadata, test the extension uttype discovery + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(item.contentType, UTType.pdf) + } + + func testMetadataFolderContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + metadata.directory = true + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(item.contentType, UTType.folder) + } + + func testMetadataPackageContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.zip", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + metadata.directory = true + metadata.contentType = UTType.package.identifier + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(item.contentType, UTType.package) + } + + func testMetadataBundleContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.key", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + metadata.directory = true + metadata.contentType = UTType.bundle.identifier + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(item.contentType, UTType.bundle) + } + + func testMetadataUnixFolderContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + metadata.directory = true + metadata.contentType = "httpd/unix-directory" + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(item.contentType, UTType.folder) + } + + func testPredictedBundleContentType() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.app", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + metadata.directory = true + metadata.contentType = "httpd/unix-directory" + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertTrue(item.contentType.conforms(to: .bundle)) + } + + func testItemUserInfoLockingPropsFileLocked() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadata.etag = "test-etag" + metadata.size = 12 + metadata.lock = true + metadata.lockOwner = Self.account.username + metadata.lockOwnerEditor = "testEditor" + metadata.lockTime = .init() + metadata.lockTimeOut = .init().addingTimeInterval(6_000_000) + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + + XCTAssertNotNil(item.userInfo?["locked"]) + + let fileproviderItems = ["fileproviderItems": [item]] + let lockPredicate = NSPredicate( + format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.locked != nil ).@count > 0" + ) + XCTAssertTrue(lockPredicate.evaluate(with: fileproviderItems)) + } + + func testItemUserInfoLockingPropsFileUnlocked() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadata.etag = "test-etag" + metadata.date = .init() + metadata.size = 12 + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + + XCTAssertNil(item.userInfo?["locked"]) + + let fileproviderItems = ["fileproviderItems": [item]] + let lockPredicate = NSPredicate( + format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.locked == nil ).@count > 0" + ) + XCTAssertTrue(lockPredicate.evaluate(with: fileproviderItems)) + } + + func testItemUserInfoDisplayEvictState() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadata.downloaded = true + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + + XCTAssertNotNil(item.userInfo?["displayEvict"]) + + let fileproviderItems = ["fileproviderItems": [item]] + let canEvictPredicate = NSPredicate( + format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayEvict == true ).@count > 0" + ) + XCTAssertTrue(canEvictPredicate.evaluate(with: fileproviderItems)) + + metadata.keepDownloaded = true + let keepDownloadedItem = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertNotNil(keepDownloadedItem.userInfo?["displayEvict"]) + + let fileproviderKeepDownloadedItems = ["fileproviderItems": [keepDownloadedItem]] + let cannotEvictPredicate = NSPredicate( + format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayEvict == true ).@count > 0" + ) + XCTAssertFalse(cannotEvictPredicate.evaluate(with: fileproviderKeepDownloadedItems)) + } + + func testItemUserInfoNoDisplayEvictState() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadata.downloaded = false + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + + XCTAssertNotNil(item.userInfo?["displayEvict"]) + + let fileproviderItems = ["fileproviderItems": [item]] + let undownloadedPredicate = NSPredicate( + format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayEvict == false ).@count > 0" + ) + XCTAssertTrue(undownloadedPredicate.evaluate(with: fileproviderItems)) + } + + func testItemUserInfoKeepDownloadedProperties() { + var metadataA = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadataA.keepDownloaded = true + + let itemA = Item( + metadata: metadataA, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(itemA.userInfo?["displayKeepDownloaded"] as? Bool, false) + XCTAssertEqual(itemA.userInfo?["displayAllowAutoEvicting"] as? Bool, true) + XCTAssertEqual(itemA.userInfo?["displayEvict"] as? Bool, false) + + let metadataB = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + let itemB = Item( + metadata: metadataB, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertTrue(itemB.userInfo?["displayKeepDownloaded"] as? Bool == true) + XCTAssertTrue(itemB.userInfo?["displayAllowAutoEvicting"] as? Bool == false) + XCTAssertEqual(itemB.userInfo?["displayEvict"] as? Bool, false) + + var metadataC = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadataC.keepDownloaded = true + metadataC.downloaded = true + + let itemC = Item( + metadata: metadataC, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(itemC.userInfo?["displayKeepDownloaded"] as? Bool, false) + XCTAssertEqual(itemC.userInfo?["displayAllowAutoEvicting"] as? Bool, true) + XCTAssertEqual(itemC.userInfo?["displayEvict"] as? Bool, false) + + var metadataD = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadataD.downloaded = true + + let itemD = Item( + metadata: metadataD, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(itemD.userInfo?["displayKeepDownloaded"] as? Bool, true) + XCTAssertEqual(itemD.userInfo?["displayAllowAutoEvicting"] as? Bool, false) + XCTAssertEqual(itemD.userInfo?["displayEvict"] as? Bool, true) + } + + func testItemUserInfoDisplayShare() { + var metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadata.permissions = "GDNVW" // No "R" for shareable + + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + + XCTAssertNil(item.userInfo?["displayShare"]) + + let fileproviderItems = ["fileproviderItems": [item]] + let lockPredicate = NSPredicate( + format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayShare == nil ).@count > 0" + ) + XCTAssertTrue(lockPredicate.evaluate(with: fileproviderItems)) + } + + func testItemLockFileUntrashable() { + let metadata = SendableItemMetadata( + ocId: "test-id", fileName: ".~lock.test.doc#", account: Self.account + ) + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertFalse(item.capabilities.contains(.allowsTrashing)) + } + + func testItemTrashabilityAffectedByCapabilities() async { + let remoteInterface = MockRemoteInterface(account: Self.account) + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + let remoteSupportsTrash = await remoteInterface.supportsTrash(account: Self.account) + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + remoteSupportsTrash: remoteSupportsTrash, + log: FileProviderLogMock() + ) + XCTAssertTrue(item.capabilities.contains(.allowsTrashing)) + } + + func testStoredItemTrashabilityFalseAffectedByCapabilities() async { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface(account: Self.account) + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + remoteInterface.capabilities = + remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + Self.dbManager.addItemMetadata(metadata) + let item = await Item.storedItem( + identifier: .init(metadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + XCTAssertEqual(item?.capabilities.contains(.allowsTrashing), false) + } + + func testStoredItemTrashabilityTrueAffectedByCapabilities() async { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface(account: Self.account) + XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) + let metadata = + SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) + Self.dbManager.addItemMetadata(metadata) + let item = await Item.storedItem( + identifier: .init(metadata.ocId), + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + XCTAssertEqual(item?.capabilities.contains(.allowsTrashing), true) + } + + func testCapabilitiesReadingFile() { + // 1. Setup metadata for a readable file + var metadata = SendableItemMetadata( + ocId: "reading-file-id", fileName: "readable.txt", account: Self.account + ) + metadata.permissions = "G" + metadata.directory = false + + // 2. Create the item + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + + // 3. Assertions + XCTAssertTrue( + item.capabilities.contains(.allowsReading), + "Item with 'G' permission should be readable." + ) + } + + func testCapabilitiesEnumeratingDirectory() { + // 1. Setup metadata for a readable directory + var metadata = SendableItemMetadata( + ocId: "enum-dir-id", fileName: "MyFolder", account: Self.account + ) + metadata.permissions = "G" + metadata.directory = true + + // 2. Create the item + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + + // 3. Assertions + XCTAssertTrue( + item.capabilities.contains(.allowsContentEnumerating), + "Directory with 'G' permission should allow enumerating." + ) + } + + func testCapabilitiesDeleting() { + // Case 1: Deletable + var deletableMetadata = SendableItemMetadata( + ocId: "deletable-id", fileName: "deletable.txt", account: Self.account + ) + deletableMetadata.permissions = "D" + let canDeleteItem = Item( + metadata: deletableMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertTrue( + canDeleteItem.capabilities.contains(.allowsDeleting), + "Item with 'D' permission should be deletable." + ) + + // Case 2: Locked + var lockedMetadata = SendableItemMetadata( + ocId: "locked-deletable-id", fileName: "locked.txt", account: Self.account + ) + lockedMetadata.permissions = "D" + lockedMetadata.lock = true + let cannotDeleteLockedItem = Item( + metadata: lockedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotDeleteLockedItem.capabilities.contains(.allowsDeleting), + "Locked item should not be deletable." + ) + + // Case 3: No permission + var noPermsMetadata = SendableItemMetadata( + ocId: "no-del-perm-id", fileName: "readonly.txt", account: Self.account + ) + noPermsMetadata.permissions = "G" + let cannotDeleteNoPermsItem = Item( + metadata: noPermsMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotDeleteNoPermsItem.capabilities.contains(.allowsDeleting), + "Item without 'D' permission should not be deletable." + ) + } + + func testCapabilitiesTrashing() { + // Case 1: Can be trashed + let trashableMetadata = SendableItemMetadata( + ocId: "trashable-id", fileName: "trashable.txt", account: Self.account + ) + let canTrashItem = Item( + metadata: trashableMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertTrue( + canTrashItem.capabilities.contains(.allowsTrashing), + "Item should be trashable if server supports it." + ) + + // Case 2: Server does not support trash + let cannotTrashItem = Item( + metadata: trashableMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: false, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotTrashItem.capabilities.contains(.allowsTrashing), + "Item should not be trashable if server does not support it." + ) + + // Case 3: Item is locked + var lockedMetadata = SendableItemMetadata( + ocId: "locked-trash-id", fileName: "locked.txt", account: Self.account + ) + lockedMetadata.lock = true + let cannotTrashLockedItem = Item( + metadata: lockedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotTrashLockedItem.capabilities.contains(.allowsTrashing), + "Locked item should not be trashable." + ) + + // Case 4: Item is a lock file + let lockFileMetadata = SendableItemMetadata( + ocId: "lockfile-id", fileName: ".~lock.file.docx#", account: Self.account + ) + let cannotTrashLockFileItem = Item( + metadata: lockFileMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotTrashLockFileItem.capabilities.contains(.allowsTrashing), + "Office lock files should not be trashable." + ) + } + + func testCapabilitiesWriting() { + // Case 1: Writable + var writableMetadata = SendableItemMetadata( + ocId: "writable-id", fileName: "writable.txt", account: Self.account + ) + writableMetadata.permissions = "W" + let canWriteItem = Item( + metadata: writableMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertTrue( + canWriteItem.capabilities.contains(.allowsWriting), + "File with 'W' permission should be writable." + ) + + // Case 2: Locked + var lockedMetadata = writableMetadata + lockedMetadata.ocId = "locked-writable-id" + lockedMetadata.lock = true + let cannotWriteLockedItem = Item( + metadata: lockedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotWriteLockedItem.capabilities.contains(.allowsWriting), + "Locked file should not be writable." + ) + + // Case 3: Is a directory + var directoryMetadata = writableMetadata + directoryMetadata.ocId = "dir-writable-id" + directoryMetadata.directory = true + let cannotWriteDirectoryItem = Item( + metadata: directoryMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotWriteDirectoryItem.capabilities.contains(.allowsWriting), + "Directory should not be writable." + ) + } + + func testCapabilitiesRenamingAndReparenting() { + let expected: NSFileProviderItemCapabilities = [.allowsRenaming, .allowsReparenting] + + // Case 1: Can be modified + var modifiableMetadata = SendableItemMetadata( + ocId: "modifiable-id", fileName: "moveme.txt", account: Self.account + ) + modifiableMetadata.permissions = "NV" + let canModifyItem = Item( + metadata: modifiableMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertTrue( + canModifyItem.capabilities.isSuperset(of: expected), + "Item with 'NV' permission should be renamable and reparentable." + ) + + // Case 2: Is locked + var lockedMetadata = modifiableMetadata + lockedMetadata.ocId = "locked-modifiable-id" + lockedMetadata.lock = true + let cannotModifyLockedItem = Item( + metadata: lockedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotModifyLockedItem.capabilities.isSuperset(of: expected), + "Locked item should not be renamable or reparentable." + ) + } + + func testCapabilitiesAddingSubItems() { + // Case 1: Can add sub-items to a directory + var dirMetadata = + SendableItemMetadata(ocId: "dir-add-id", fileName: "MyFolder", account: Self.account) + dirMetadata.permissions = "NV" + dirMetadata.directory = true + let canAddSubItemsItem = Item( + metadata: dirMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertTrue( + canAddSubItemsItem.capabilities.contains(.allowsAddingSubItems), + "Directory with 'NV' should allow adding sub-items." + ) + + // Case 2: Cannot add sub-items to a file + var fileMetadata = dirMetadata + fileMetadata.ocId = "file-add-id" + fileMetadata.directory = false + let cannotAddToFileItem = Item( + metadata: fileMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotAddToFileItem.capabilities.contains(.allowsAddingSubItems), + "File should not allow adding sub-items." + ) + + // Case 3: Cannot add sub-items to a locked directory + var lockedDirMetadata = dirMetadata + lockedDirMetadata.ocId = "locked-dir-add-id" + lockedDirMetadata.lock = true + let cannotAddToLockedDirItem = Item( + metadata: lockedDirMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + XCTAssertFalse( + cannotAddToLockedDirItem.capabilities.contains(.allowsAddingSubItems), + "Locked directory should not allow adding sub-items." + ) + } + + func testCapabilitiesFullPermissionsFile() { + var metadata = SendableItemMetadata( + ocId: "full-perms-file", fileName: "do-it-all.txt", account: Self.account + ) + metadata.permissions = "RGDNVW" + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + + let expected: NSFileProviderItemCapabilities = [ + .allowsReading, + .allowsDeleting, + .allowsTrashing, + .allowsWriting, + .allowsRenaming, + .allowsReparenting + ] + + // Excluding from sync is macOS-specific and always added if available + var platformExpected = expected + #if os(macOS) + if #available(macOS 11.3, *) { + platformExpected.insert(.allowsExcludingFromSync) + } + #endif + + XCTAssertEqual(item.capabilities, platformExpected) + } + + func testCapabilitiesFullPermissionsFolder() { + var metadata = SendableItemMetadata( + ocId: "full-perms-folder", fileName: "SuperFolder", account: Self.account + ) + metadata.permissions = "RGDNVW" + metadata.directory = true + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + + let expected: NSFileProviderItemCapabilities = [ + .allowsContentEnumerating, + .allowsDeleting, + .allowsTrashing, + .allowsRenaming, + .allowsReparenting, + .allowsAddingSubItems + ] + + var platformExpected = expected + #if os(macOS) + if #available(macOS 11.3, *) { + platformExpected.insert(.allowsExcludingFromSync) + } + #endif + + XCTAssertEqual(item.capabilities, platformExpected) + } + + func testCapabilitiesNoPermissions() { + var metadata = + SendableItemMetadata(ocId: "no-perms", fileName: "nothing.txt", account: Self.account) + metadata.permissions = "" // No permissions from server + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + + // Trashing and Excluding from Sync might still be allowed as they don't depend on the + // permission string + var expected: NSFileProviderItemCapabilities = [.allowsTrashing] + + #if os(macOS) + if #available(macOS 11.3, *) { + expected.insert(.allowsExcludingFromSync) + } + #endif + + XCTAssertEqual(item.capabilities, expected) + } + + #if os(macOS) + func testCapabilitiesMacOSExclusion() { + if #available(macOS 11.3, *) { + var metadata = SendableItemMetadata( + ocId: "macos-exclusion", fileName: "file.txt", account: Self.account + ) + metadata.permissions = "" + let item = Item( + metadata: metadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager, + remoteSupportsTrash: true, + log: FileProviderLogMock() + ) + + XCTAssertTrue( + item.capabilities.contains(.allowsExcludingFromSync), + "Should allow excluding from sync on supported macOS versions." + ) + } + } + #endif + + func testItemShared() { + var sharedMetadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + sharedMetadata.shareType = [ShareType.publicLink.rawValue] + sharedMetadata.ownerId = Self.account.id + sharedMetadata.ownerDisplayName = "Mr. Tester Testarino" + let sharedItem = Item( + metadata: sharedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertFalse(sharedItem.isShared) + XCTAssertFalse(sharedItem.isSharedByCurrentUser) + XCTAssertNil(sharedItem.ownerNameComponents) // Should be nil if it is shared by us + + var sharedByOtherMetadata = sharedMetadata + sharedByOtherMetadata.ownerId = "claucambra" + sharedByOtherMetadata.ownerDisplayName = "Claudio Cambra" + let sharedByOtherTime = Item( + metadata: sharedByOtherMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertFalse(sharedByOtherTime.isShared) + XCTAssertFalse(sharedByOtherTime.isSharedByCurrentUser) + XCTAssertNil(sharedByOtherTime.ownerNameComponents) + + var notSharedMetadata = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + notSharedMetadata.ownerId = Self.account.id + notSharedMetadata.ownerDisplayName = "Mr. Tester Testarino" + let notSharedItem = Item( + metadata: notSharedMetadata, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + debugPrint(notSharedMetadata.shareType) + XCTAssertFalse(notSharedItem.isShared) + XCTAssertFalse(notSharedItem.isSharedByCurrentUser) + XCTAssertNil(notSharedItem.ownerNameComponents) + } + + func testContentPolicy() { + var metadataA = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + metadataA.keepDownloaded = true + + let itemA = Item( + metadata: metadataA, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(itemA.contentPolicy, .downloadEagerlyAndKeepDownloaded) + + let metadataB = + SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account) + let itemB = Item( + metadata: metadataB, + parentItemIdentifier: .rootContainer, + account: Self.account, + remoteInterface: MockRemoteInterface(account: Self.account), + dbManager: Self.dbManager + ) + XCTAssertEqual(itemB.contentPolicy, .inherited) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift new file mode 100644 index 0000000000000..5a6ac4a0c39f9 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import RealmSwift +import TestInterface +import XCTest + +final class MaterialisedEnumerationObserverTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd" + ) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + + func testMaterialisedObserverWithNoPreexistingState() async { + let dbManager = FilesDatabaseManager(account: Self.account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + // The database is intentionally left empty. + + let remoteInterface = MockRemoteInterface(account: Self.account) + + let enumeratedFile = + SendableItemMetadata(ocId: "file1", fileName: "file1.txt", account: Self.account) + var enumeratedDir = + SendableItemMetadata(ocId: "dir1", fileName: "dir1", account: Self.account) + enumeratedDir.directory = true + + let expect = XCTestExpectation(description: "Enumerator completion handler called") + + // The observer's logic requires metadata to exist in the DB to update it. + let observer = MaterializedEnumerationObserver(account: Self.account, dbManager: dbManager, log: FileProviderLogMock()) { newlyMaterialisedIds, unmaterialisedIds in + XCTAssertTrue( + unmaterialisedIds.isEmpty, + "Unmaterialised set should be empty when DB starts empty." + ) + + // The items are correctly identified as newly materialized because they weren't in the + // DB's materialized list (which was empty). + XCTAssertEqual( + newlyMaterialisedIds.count, + 2, + "Both enumerated items should be identified as newly materialised." + ) + XCTAssertTrue(newlyMaterialisedIds.contains(NSFileProviderItemIdentifier("file1"))) + XCTAssertTrue(newlyMaterialisedIds.contains(NSFileProviderItemIdentifier("dir1"))) + + // Verify that the database state is NOT updated + let fileMetadata = dbManager.itemMetadata(ocId: "file1") + XCTAssertNil( + fileMetadata, + "Metadata should NOT be in the DB, as the observer does not add missing items." + ) + + let dirMetadata = dbManager.itemMetadata(ocId: "dir1") + XCTAssertNil( + dirMetadata, + "Metadata should NOT be in the DB, as the observer does not add missing items." + ) + + expect.fulfill() + } + + let enumerator = MockEnumerator( + account: Self.account, dbManager: dbManager, remoteInterface: remoteInterface + ) + enumerator.enumeratorItems = [enumeratedFile, enumeratedDir] + enumerator.enumerateItems(for: observer, startingAt: NSFileProviderPage(Data(count: 1))) + + await fulfillment(of: [expect], timeout: 1) + } + + func testMaterialisedObserverWithMixedState() async { + // Setup a DB with a mix of materialized and non-materialised items. + var itemA = SendableItemMetadata(ocId: "itemA", fileName: "itemA", account: Self.account) + itemA.downloaded = true // Was materialised + + var itemB = SendableItemMetadata(ocId: "itemB", fileName: "itemB", account: Self.account) + itemB.downloaded = false // Was NOT materialised + + var itemC = SendableItemMetadata(ocId: "itemC", fileName: "itemC", account: Self.account) + itemC.downloaded = true // Was materialised + + var dirD = SendableItemMetadata(ocId: "dirD", fileName: "dirD", account: Self.account) + dirD.directory = true + dirD.visitedDirectory = true // Was materialised + + let dbManager = FilesDatabaseManager(account: Self.account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + dbManager.addItemMetadata(itemA) + dbManager.addItemMetadata(itemB) + dbManager.addItemMetadata(itemC) + dbManager.addItemMetadata(dirD) + + let remoteInterface = MockRemoteInterface(account: Self.account) + let expect = XCTestExpectation(description: "Enumerator completion handler called") + let enumeratorItemsToReturn = [itemB, itemC] + + let observer = MaterializedEnumerationObserver(account: Self.account, dbManager: dbManager, log: FileProviderLogMock()) { newlyMaterialisedIds, unmaterialisedIds in + // Unmaterialised: itemA and dirD were materialized but not in the latest enumeration. + XCTAssertEqual( + unmaterialisedIds.count, 2, "itemA and dirD should be reported as unmaterialised." + ) + XCTAssertTrue(unmaterialisedIds.contains(NSFileProviderItemIdentifier("itemA"))) + XCTAssertTrue(unmaterialisedIds.contains(NSFileProviderItemIdentifier("dirD"))) + + // Newly Materialised: itemB was NOT materialized but WAS in the latest enumeration. + XCTAssertEqual( + newlyMaterialisedIds.count, 1, "itemB should be reported as newly materialised." + ) + XCTAssertEqual(newlyMaterialisedIds.first, NSFileProviderItemIdentifier("itemB")) + + // Check final database state + let finalItemA = dbManager.itemMetadata(ocId: "itemA") + XCTAssertFalse( + finalItemA?.downloaded ?? true, "itemA should now be marked as not downloaded." + ) + + let finalItemB = dbManager.itemMetadata(ocId: "itemB") + XCTAssertTrue( + finalItemB?.downloaded ?? false, "itemB should now be marked as downloaded." + ) + + let finalItemC = dbManager.itemMetadata(ocId: "itemC") + XCTAssertTrue(finalItemC?.downloaded ?? false, "itemC should remain downloaded.") + + let finalDirD = dbManager.itemMetadata(ocId: "dirD") + XCTAssertFalse( + finalDirD?.visitedDirectory ?? true, "dirD should now be marked as not visited." + ) + + expect.fulfill() + } + + let enumerator = MockEnumerator( + account: Self.account, dbManager: dbManager, remoteInterface: remoteInterface + ) + enumerator.enumeratorItems = enumeratorItemsToReturn + enumerator.enumerateItems(for: observer, startingAt: NSFileProviderPage(Data(count: 1))) + + await fulfillment(of: [expect], timeout: 1) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift new file mode 100644 index 0000000000000..d0e8cbcf45307 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudKit +import XCTest + +final class NKFileExtensionsTests: NextcloudFileProviderKitTestCase { + static let account = Account(user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd") + + /// + /// Mock an `NKFile` for the tests in here. + /// + private func createNKFile(ocId: String = "id1", serverUrl: String? = nil, fileName: String = "file.txt", directory: Bool = false, userId: String? = nil, urlBase: String? = nil) -> NKFile { + var file = NKFile() + file.ocId = ocId + file.serverUrl = serverUrl ?? Self.account.davFilesUrl + file.fileName = fileName + file.directory = directory + file.userId = userId ?? Self.account.id + file.urlBase = urlBase ?? Self.account.serverUrl + // Add other necessary properties with default values + file.account = Self.account.ncKitAccount + file.date = Date() + file.creationDate = Date() + file.etag = "etag" + + return file + } + + // MARK: - toItemMetadata() Tests + + func testToItemMetadataHandlesRootFixupCorrectly() { + // 1. Arrange: Create an NKFile representing the root container, + // which has a special serverUrl and fileName. + let rootNKFile = createNKFile( + ocId: "rootId", + serverUrl: "https://mock.nc.com/remote.php/dav/files/testUserId", // Special root value + fileName: NextcloudKit.shared.nkCommonInstance.rootFileName, // Special root value + directory: false // NextcloudKit sometimes marks the root as not a directory + ) + + // 2. Act + let metadata = rootNKFile.toItemMetadata() + + // 3. Assert + // The `rootRequiresFixup` logic should trigger. + XCTAssertEqual(metadata.serverUrl, Self.account.davFilesUrl, "The serverUrl for the root should be corrected to the full WebDAV path.") + XCTAssertEqual(metadata.ocId, NSFileProviderItemIdentifier.rootContainer.rawValue, "The ocId for the root should be mapped to a file provider root container.") + } + + func testToItemMetadataHandlesStandardFileCorrectly() { + // 1. Arrange: Create a standard NKFile for a regular file. + let standardNKFile = createNKFile( + ocId: "file123", + serverUrl: Self.account.davFilesUrl + "/photos", + fileName: "image.jpg" + ) + + // 2. Act + let metadata = standardNKFile.toItemMetadata() + + // 3. Assert + // The `rootRequiresFixup` logic should NOT trigger. + XCTAssertEqual( + metadata.serverUrl, + Self.account.davFilesUrl + "/photos", + "The serverUrl for a standard file should remain unchanged." + ) + XCTAssertEqual( + metadata.fileName, + "image.jpg", + "The fileName for a standard file should remain unchanged." + ) + } + + // MARK: - isDirectoryToRead(_:directoryToRead:) + + func testIsDirectoryToReadForRoot() { + let item = createNKFile(ocId: NextcloudKit.shared.nkCommonInstance.rootFileName, serverUrl: Self.account.davFilesUrl, fileName: NextcloudKit.shared.nkCommonInstance.rootFileName) + let items = [NKFile]() + let result = items.isDirectoryToRead(item, directoryToRead: Self.account.davFilesUrl) + XCTAssertTrue(result) + } + + func testIsDirectoryToReadForDirectoryInRoot() { + let item = createNKFile(ocId: "some", serverUrl: Self.account.davFilesUrl, fileName: "Subdirectory", directory: true) + let items = [NKFile]() + let result = items.isDirectoryToRead(item, directoryToRead: "\(Self.account.davFilesUrl)/Subdirectory") + XCTAssertTrue(result) + } + + func testIsDirectoryToReadForFileInRoot() { + let item = createNKFile(ocId: "some", serverUrl: Self.account.davFilesUrl, fileName: "File", directory: false) + let items = [NKFile]() + let result = items.isDirectoryToRead(item, directoryToRead: Self.account.davFilesUrl) + XCTAssertFalse(result) + } + + func testIsDirectoryToReadForFileInSubdirectory() { + let subdirectoryURL = "\(Self.account.davFilesUrl)/Subdirectory" + let item = createNKFile(ocId: "some", serverUrl: "\(subdirectoryURL)/File", fileName: "File", directory: false) + let items = [NKFile]() + let result = items.isDirectoryToRead(item, directoryToRead: subdirectoryURL) + XCTAssertFalse(result) + } + + // MARK: - toSendableDirectoryMetadata(account:directoryToRead:) Tests + + func testToSendableDirectoryMetadataHandlesRootAsTargetCorrectly() async throws { + // 1. Arrange: Create an array of NKFiles where the first item is the special root. + let rootNKFile = createNKFile( + ocId: "rootId", // This will be overridden by the logic + serverUrl: Self.account.davFilesUrl, + fileName: NextcloudKit.shared.nkCommonInstance.rootFileName, + directory: false // Mimic NextcloudKit behavior + ) + + let childNKFile = createNKFile( + ocId: "child1", + serverUrl: "\(Self.account.davFilesUrl)/document.txt", + fileName: "document.txt" + ) + + let files = [rootNKFile, childNKFile] + + // 2. Act + let result = await files.toSendableDirectoryMetadata(account: Self.account, directoryToRead: Self.account.davFilesUrl) + + // 3. Assert + let unwrappedResult = try XCTUnwrap(result) + + // The logic should identify the first item as the root and fix its properties. + XCTAssertEqual(unwrappedResult.root.ocId, NSFileProviderItemIdentifier.rootContainer.rawValue, "The target directory's ocId should be corrected to the root container identifier.") + XCTAssertTrue(unwrappedResult.root.directory, "The target directory should be correctly marked as a directory, even if the NKFile was not.") + + // Ensure the child item is processed correctly. + XCTAssertEqual(unwrappedResult.files.count, 1, "There should be one file metadata object.") + XCTAssertEqual(unwrappedResult.files.first?.ocId, "child1") + XCTAssertTrue(unwrappedResult.directories.isEmpty, "The child is a file, so child directories should be empty.") + } + + func testToSendableDirectoryMetadataHandlesNormalFolderAsTarget() async throws { + // 1. Arrange: Create an array for a normal folder and its children. + let parentFolderNKFile = createNKFile( + ocId: "folder1", + serverUrl: Self.account.davFilesUrl, + fileName: "MyFolder", + directory: true + ) + + let childFileNKFile = createNKFile( + ocId: "file1", + serverUrl: "\(Self.account.davFilesUrl)/MyFolder", + fileName: "report.docx" + ) + + let childDirNKFile = createNKFile( + ocId: "dir2", + serverUrl: "\(Self.account.davFilesUrl)/MyFolder", + fileName: "Subfolder", + directory: true + ) + + let files = [parentFolderNKFile, childFileNKFile, childDirNKFile] + + // 2. Act + let result = await files.toSendableDirectoryMetadata(account: Self.account, directoryToRead: "\(Self.account.davFilesUrl)/MyFolder") + + // 3. Assert + let unwrappedResult = try XCTUnwrap(result) + + // The root fixup logic should NOT trigger for a normal folder. + XCTAssertEqual(unwrappedResult.root.ocId, "folder1") + XCTAssertEqual(unwrappedResult.root.fileName, "MyFolder") + XCTAssertTrue(unwrappedResult.root.directory) + + // Check children processing + XCTAssertEqual(unwrappedResult.files.count, 2, "Should have two child metadata objects.") + XCTAssertEqual(unwrappedResult.directories.count, 1, "Should identify one child directory.") + XCTAssertEqual(unwrappedResult.directories.first?.ocId, "dir2") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift new file mode 100644 index 0000000000000..515bfbfe0635c --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import XCTest + +/// +/// Common base class for all tests in this target. +/// +class NextcloudFileProviderKitTestCase: XCTestCase { + /// + /// Create a unique and temporary directory for Realm database testing purposes. + /// + /// - Returns: A URL pointing to a temporary directory which also contains a UUID to distinguish it clearly from any other calls. + /// + static func makeDatabaseDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try! FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + + return url + } + + /// + /// Instance wrapper for ``makeDatabaseDirectory`` for convenience and brevity. + /// + func makeDatabaseDirectory() -> URL { + Self.makeDatabaseDirectory() + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift new file mode 100644 index 0000000000000..5c4b9368942dd --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import RealmSwift +import TestInterface +import XCTest + +@available(macOS 14.0, iOS 17.0, *) +final class RemoteChangeObserverEtagOptimizationTests: NextcloudFileProviderKitTestCase { + static let account = Account( + user: "testUser", id: "testUserId", serverUrl: "localhost", password: "abcd" + ) + + var dbManager: FilesDatabaseManager! + var mockRemoteInterface: MockRemoteInterface! + + override func setUp() { + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + dbManager = FilesDatabaseManager(account: Self.account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + mockRemoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + } + + func testUnchangedDirectoryShouldNotBeEnumerated() async throws { + // This test demonstrates the original issue where multiple working set checks + // would repeatedly enumerate the same unchanged folders + + // 1. Setup: Create a materialized root directory and subdirectory + var rootFolder = SendableItemMetadata( + ocId: "root", fileName: "", account: Self.account + ) + rootFolder.directory = true + rootFolder.visitedDirectory = true + rootFolder.etag = "rootetag123" + rootFolder.serverUrl = Self.account.davFilesUrl + dbManager.addItemMetadata(rootFolder) + + var customersFolder = SendableItemMetadata( + ocId: "customers", fileName: "Customers", account: Self.account + ) + customersFolder.directory = true + customersFolder.visitedDirectory = true + customersFolder.etag = "68662da77122d" // The etag from the logs + dbManager.addItemMetadata(customersFolder) + + // Add some child files + var childFile1 = SendableItemMetadata( + ocId: "child1", fileName: "child1.txt", account: Self.account + ) + childFile1.downloaded = true + childFile1.serverUrl = Self.account.davFilesUrl + "/Customers" + dbManager.addItemMetadata(childFile1) + + var childFile2 = SendableItemMetadata( + ocId: "child2", fileName: "child2.txt", account: Self.account + ) + childFile2.downloaded = true + childFile2.serverUrl = Self.account.davFilesUrl + "/Customers" + dbManager.addItemMetadata(childFile2) + + // 2. Setup server to return same etag (no changes) + let serverCustomersFolder = MockRemoteItem( + identifier: "customers", + versionIdentifier: "68662da77122d", // Same etag - no changes + name: "Customers", + remotePath: Self.account.davFilesUrl + "/Customers", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + // Add the same children to server + let serverChild1 = MockRemoteItem( + identifier: "child1", name: "child1.txt", + remotePath: serverCustomersFolder.remotePath + "/child1.txt", + account: Self.account.ncKitAccount, + username: Self.account.username, userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + let serverChild2 = MockRemoteItem( + identifier: "child2", name: "child2.txt", + remotePath: serverCustomersFolder.remotePath + "/child2.txt", + account: Self.account.ncKitAccount, + username: Self.account.username, userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + serverCustomersFolder.children = [serverChild1, serverChild2] + mockRemoteInterface.rootItem?.children = [serverCustomersFolder] + + // 3. Track how many times enumerate is called and for which paths + var enumerateCallCount = 0 + var enumeratedPaths: [String] = [] + mockRemoteInterface.enumerateCallHandler = { remotePath, depth, _, _, _, _, _, _ in + enumerateCallCount += 1 + enumeratedPaths.append(remotePath) + print("ENUMERATE CALLED #\(enumerateCallCount) for: \(remotePath) (depth: \(depth))") + } + + // 4. Create observer and manually trigger working set check + let changeNotificationInterface = MockChangeNotificationInterface() + let remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: mockRemoteInterface, + changeNotificationInterface: changeNotificationInterface, + domain: nil, + dbManager: dbManager, + log: FileProviderLogMock() + ) + + // 5. Debug: Check what materialized items we have + let materializedItems = dbManager.materialisedItemMetadatas(account: Self.account.ncKitAccount) + print("Materialized items found: \(materializedItems.count)") + for item in materializedItems { + print(" - \(item.fileName) (ocId: \(item.ocId), directory: \(item.directory), etag: \(item.etag))") + } + + // 6. Simulate the original issue: multiple working set checks in quick succession + // This would happen when multiple notify_file messages arrive or polling occurs frequently + print("\n=== Running multiple working set checks ===") + + // First working set check + let firstWorkingSetCheckCompleted = expectation(description: "First working set check completed.") + + remoteChangeObserver.startWorkingSetCheck { + firstWorkingSetCheckCompleted.fulfill() + } + + await fulfillment(of: [firstWorkingSetCheckCompleted]) + + // Second working set check (simulating rapid notify_file messages) + let secondWorkingSetCheckCompleted = expectation(description: "Second working set check completed.") + + remoteChangeObserver.startWorkingSetCheck { + secondWorkingSetCheckCompleted.fulfill() + } + + await fulfillment(of: [secondWorkingSetCheckCompleted]) + + // Third working set check + let thirdWorkingSetCheckCompleted = expectation(description: "Third working set check completed.") + + remoteChangeObserver.startWorkingSetCheck { + thirdWorkingSetCheckCompleted.fulfill() + } + + await fulfillment(of: [thirdWorkingSetCheckCompleted]) + + // Wait for all operations to complete + + print("\n=== Results ===") + print("Total enumerate calls: \(enumerateCallCount)") + print("Enumerated paths: \(enumeratedPaths)") + + // 7. Assert: With the optimization, we should not make excessive enumerate calls + // Each unique path should only be enumerated once or very few times + XCTAssertGreaterThan(enumerateCallCount, 0, "At least one enumerate call should be made") + + // Count how many times the Customers folder was enumerated + let customersEnumerateCount = enumeratedPaths.count(where: { + $0.contains("Customers") + }) + + print("Customers folder enumerated \(customersEnumerateCount) times") + + // The key optimization we want: the same folder with unchanged etag shouldn't be + // enumerated repeatedly. Ideally, it should be enumerated only once. + // However, without optimization, it might be enumerated 3 times (once per working set check) + XCTAssertLessThanOrEqual(customersEnumerateCount, 1, "Customers folder with unchanged etag should not be enumerated repeatedly") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift new file mode 100644 index 0000000000000..af2ddb78990f2 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift @@ -0,0 +1,529 @@ +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +import Foundation +import NextcloudCapabilitiesKit +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import RealmSwift +import Testing +import TestInterface +import XCTest + +private let mockCapabilities = ##"{"ocs":{"meta":{"status":"ok","statuscode":100,"message":"OK","totalitems":"","itemsperpage":""},"data":{"version":{"major":28,"minor":0,"micro":4,"string":"28.0.4","edition":"","extendedSupport":false},"capabilities":{"core":{"pollinterval":60,"webdav-root":"remote.php\/webdav","reference-api":true,"reference-regex":"(\\s|\\n|^)(https?:\\\/\\\/)((?:[-A-Z0-9+_]+\\.)+[-A-Z]+(?:\\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\\n|$)"},"bruteforce":{"delay":0,"allow-listed":false},"files":{"bigfilechunking":true,"blacklisted_files":[".htaccess"],"directEditing":{"url":"localhost\/ocs\/v2.php\/apps\/files\/api\/v1\/directEditing","etag":"c748e8fc588b54fc5af38c4481a19d20","supportsFileId":true},"comments":true,"undelete":true,"versioning":true,"version_labeling":true,"version_deletion":true},"activity":{"apiv2":["filters","filters-api","previews","rich-strings"]},"circles":{"version":"28.0.0","status":{"globalScale":false},"settings":{"frontendEnabled":true,"allowedCircles":262143,"allowedUserTypes":31,"membersLimit":-1},"circle":{"constants":{"flags":{"1":"Single","2":"Personal","4":"System","8":"Visible","16":"Open","32":"Invite","64":"Join Request","128":"Friends","256":"Password Protected","512":"No Owner","1024":"Hidden","2048":"Backend","4096":"Local","8192":"Root","16384":"Circle Invite","32768":"Federated","65536":"Mount point"},"source":{"core":{"1":"Nextcloud Account","2":"Nextcloud Group","4":"Email Address","8":"Contact","16":"Circle","10000":"Nextcloud App"},"extra":{"10001":"Circles App","10002":"Admin Command Line"}}},"config":{"coreFlags":[1,2,4],"systemFlags":[512,1024,2048]}},"member":{"constants":{"level":{"1":"Member","4":"Moderator","8":"Admin","9":"Owner"}},"type":{"0":"single","1":"user","2":"group","4":"mail","8":"contact","16":"circle","10000":"app"}}},"ocm":{"enabled":true,"apiVersion":"1.0-proposal1","endPoint":"localhost\/ocm","resourceTypes":[{"name":"file","shareTypes":["user","group"],"protocols":{"webdav":"\/public.php\/webdav\/"}}]},"dav":{"chunking":"1.0","bulkupload":"1.0"},"deck":{"version":"1.12.2","canCreateBoards":true,"apiVersions":["1.0","1.1"]},"files_sharing":{"api_enabled":true,"public":{"enabled":true,"password":{"enforced":false,"askForOptionalPassword":false},"expire_date":{"enabled":true,"days":7,"enforced":true},"multiple_links":true,"expire_date_internal":{"enabled":false},"expire_date_remote":{"enabled":false},"send_mail":false,"upload":true,"upload_files_drop":true},"resharing":true,"user":{"send_mail":false,"expire_date":{"enabled":true}},"group_sharing":true,"group":{"enabled":true,"expire_date":{"enabled":true}},"default_permissions":31,"federation":{"outgoing":true,"incoming":true,"expire_date":{"enabled":true},"expire_date_supported":{"enabled":true}},"sharee":{"query_lookup_default":false,"always_show_unique":true},"sharebymail":{"enabled":true,"send_password_by_mail":true,"upload_files_drop":{"enabled":true},"password":{"enabled":true,"enforced":false},"expire_date":{"enabled":true,"enforced":true}}},"fulltextsearch":{"remote":true,"providers":[{"id":"deck","name":"Deck"},{"id":"files","name":"Files"}]},"notes":{"api_version":["0.2","1.3"],"version":"4.9.4"},"notifications":{"ocs-endpoints":["list","get","delete","delete-all","icons","rich-strings","action-web","user-status","exists"],"push":["devices","object-data","delete"],"admin-notifications":["ocs","cli"]},"notify_push":{"type":["files","activities","notifications"],"endpoints":{"websocket":"ws:\/\/localhost:8888\/websocket","pre_auth":"localhost\/apps\/notify_push\/pre_auth"}},"password_policy":{"minLength":10,"enforceNonCommonPassword":true,"enforceNumericCharacters":false,"enforceSpecialCharacters":false,"enforceUpperLowerCase":false,"api":{"generate":"localhost\/ocs\/v2.php\/apps\/password_policy\/api\/v1\/generate","validate":"localhost\/ocs\/v2.php\/apps\/password_policy\/api\/v1\/validate"}},"provisioning_api":{"version":"1.18.0","AccountPropertyScopesVersion":2,"AccountPropertyScopesFederatedEnabled":true,"AccountPropertyScopesPublishedEnabled":true},"richdocuments":{"version":"8.3.4","mimetypes":["application\/vnd.oasis.opendocument.text","application\/vnd.oasis.opendocument.spreadsheet","application\/vnd.oasis.opendocument.graphics","application\/vnd.oasis.opendocument.presentation","application\/vnd.oasis.opendocument.text-flat-xml","application\/vnd.oasis.opendocument.spreadsheet-flat-xml","application\/vnd.oasis.opendocument.graphics-flat-xml","application\/vnd.oasis.opendocument.presentation-flat-xml","application\/vnd.lotus-wordpro","application\/vnd.visio","application\/vnd.ms-visio.drawing","application\/vnd.wordperfect","application\/rtf","text\/rtf","application\/msonenote","application\/msword","application\/vnd.openxmlformats-officedocument.wordprocessingml.document","application\/vnd.openxmlformats-officedocument.wordprocessingml.template","application\/vnd.ms-word.document.macroEnabled.12","application\/vnd.ms-word.template.macroEnabled.12","application\/vnd.ms-excel","application\/vnd.openxmlformats-officedocument.spreadsheetml.sheet","application\/vnd.openxmlformats-officedocument.spreadsheetml.template","application\/vnd.ms-excel.sheet.macroEnabled.12","application\/vnd.ms-excel.template.macroEnabled.12","application\/vnd.ms-excel.addin.macroEnabled.12","application\/vnd.ms-excel.sheet.binary.macroEnabled.12","application\/vnd.ms-powerpoint","application\/vnd.openxmlformats-officedocument.presentationml.presentation","application\/vnd.openxmlformats-officedocument.presentationml.template","application\/vnd.openxmlformats-officedocument.presentationml.slideshow","application\/vnd.ms-powerpoint.addin.macroEnabled.12","application\/vnd.ms-powerpoint.presentation.macroEnabled.12","application\/vnd.ms-powerpoint.template.macroEnabled.12","application\/vnd.ms-powerpoint.slideshow.macroEnabled.12","text\/csv"],"mimetypesNoDefaultOpen":["image\/svg+xml","application\/pdf","text\/plain","text\/spreadsheet"],"mimetypesSecureView":[],"collabora":{"convert-to":{"available":true,"endpoint":"\/cool\/convert-to"},"hasMobileSupport":true,"hasProxyPrefix":false,"hasTemplateSaveAs":false,"hasTemplateSource":true,"hasWASMSupport":false,"hasZoteroSupport":true,"productName":"Collabora Online Development Edition","productVersion":"23.05.10.1","productVersionHash":"baa6eef","serverId":"8bee4df3"},"direct_editing":true,"templates":true,"productName":"Nextcloud Office","editonline_endpoint":"localhost\/apps\/richdocuments\/editonline","config":{"wopi_url":"localhost\/","public_wopi_url":"localhost","wopi_callback_url":"","disable_certificate_verification":null,"edit_groups":null,"use_groups":null,"doc_format":null,"timeout":15}},"spreed":{"features":["audio","video","chat-v2","conversation-v4","guest-signaling","empty-group-room","guest-display-names","multi-room-users","favorites","last-room-activity","no-ping","system-messages","delete-messages","mention-flag","in-call-flags","conversation-call-flags","notification-levels","invite-groups-and-mails","locked-one-to-one-rooms","read-only-rooms","listable-rooms","chat-read-marker","chat-unread","webinary-lobby","start-call-flag","chat-replies","circles-support","force-mute","sip-support","sip-support-nopin","chat-read-status","phonebook-search","raise-hand","room-description","rich-object-sharing","temp-user-avatar-api","geo-location-sharing","voice-message-sharing","signaling-v3","publishing-permissions","clear-history","direct-mention-flag","notification-calls","conversation-permissions","rich-object-list-media","rich-object-delete","unified-search","chat-permission","silent-send","silent-call","send-call-notification","talk-polls","breakout-rooms-v1","recording-v1","avatar","chat-get-context","single-conversation-status","chat-keep-notifications","typing-privacy","remind-me-later","bots-v1","markdown-messages","media-caption","session-state","note-to-self","recording-consent","sip-support-dialout","message-expiration","reactions","chat-reference-id"],"config":{"attachments":{"allowed":true,"folder":"\/Talk"},"call":{"enabled":true,"breakout-rooms":true,"recording":false,"recording-consent":0,"supported-reactions":["\u2764\ufe0f","\ud83c\udf89","\ud83d\udc4f","\ud83d\udc4d","\ud83d\udc4e","\ud83d\ude02","\ud83e\udd29","\ud83e\udd14","\ud83d\ude32","\ud83d\ude25"],"sip-enabled":false,"sip-dialout-enabled":false,"predefined-backgrounds":["1_office.jpg","2_home.jpg","3_abstract.jpg","4_beach.jpg","5_park.jpg","6_theater.jpg","7_library.jpg","8_space_station.jpg"],"can-upload-background":true,"can-enable-sip":true},"chat":{"max-length":32000,"read-privacy":0,"has-translation-providers":false,"typing-privacy":0},"conversations":{"can-create":true},"previews":{"max-gif-size":3145728},"signaling":{"session-ping-limit":200,"hello-v2-token-key":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECOu2NBMo4juGx6hHNIGa550gGaxN\nzqe\/TPxsX3QRjCrkyvdQaltjuRt\/9PddhpbMxcJSzwVLqZRVHylfllD8pg==\n-----END PUBLIC KEY-----\n"}},"version":"18.0.7"},"systemtags":{"enabled":true},"theming":{"name":"Nextcloud","url":"https:\/\/nextcloud.com","slogan":"a safe home for all your data","color":"#6ea68f","color-text":"#000000","color-element":"#6ea68f","color-element-bright":"#6ea68f","color-element-dark":"#6ea68f","logo":"localhost\/core\/img\/logo\/logo.svg?v=1","background":"#6ea68f","background-plain":true,"background-default":true,"logoheader":"localhost\/core\/img\/logo\/logo.svg?v=1","favicon":"localhost\/core\/img\/logo\/logo.svg?v=1"},"user_status":{"enabled":true,"restore":true,"supports_emoji":true},"weather_status":{"enabled":true}}}}}"## + +private let username = "testUser" +private let userId = "testUserId" +private let serverUrl = "localhost" +private let password = "abcd" + +@available(macOS 14.0, iOS 17.0, *) +final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { + static let timeout = 5000 // tries + static let account = Account( + user: username, id: userId, serverUrl: serverUrl, password: password + ) + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + static let notifyPushServer = MockNotifyPushServer( + host: serverUrl, + port: 8888, + username: username, + password: password, + eventLoopGroup: .singleton + ) + var remoteChangeObserver: RemoteChangeObserver? + + override func setUp() async throws { + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + let server = Self.notifyPushServer + + Task { + try await server.run() + } + } + + override func tearDown() async throws { + remoteChangeObserver?.resetWebSocket() + remoteChangeObserver = nil + Self.notifyPushServer.reset() + } + + /// Helper to wait for an expectation with a standard timeout. + private func wait(for expectation: XCTestExpectation, description: String) async { + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: 10.0) + + if result != .completed { + XCTFail("Timeout waiting for \(description)") + } + } + + func testAuthentication() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities + + let authenticated = expectation(description: "authenticated") + authenticated.assertForOverFulfill = false + + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authenticated.fulfill() + } + + remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: MockChangeNotificationInterface(), + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + await fulfillment(of: [authenticated]) + } + + func testRetryAuthentication() async throws { + Self.notifyPushServer.delay = 1_000_000 + + let authenticated = expectation(description: "authenticated") + authenticated.assertForOverFulfill = false + + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authenticated.fulfill() + } + + let incorrectAccount = Account(user: username, id: userId, serverUrl: serverUrl, password: "wrong!") + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities + + remoteChangeObserver = RemoteChangeObserver( + account: incorrectAccount, + remoteInterface: remoteInterface, + changeNotificationInterface: MockChangeNotificationInterface(), + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + let remoteChangeObserver = remoteChangeObserver! + + for _ in 0 ... Self.timeout { + try await Task.sleep(nanoseconds: 1_000_001) + if remoteChangeObserver.webSocketAuthenticationFailCount > 0 { + break + } + } + + let count = remoteChangeObserver.webSocketAuthenticationFailCount + XCTAssertTrue(count > 0) + + remoteChangeObserver.replaceAccount(with: Self.account) + + await fulfillment(of: [authenticated]) + remoteChangeObserver.resetWebSocket() + } + + func testStopRetryingConnection() async throws { + let incorrectAccount = + Account(user: username, id: userId, serverUrl: serverUrl, password: "wrong!") + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities + let remoteChangeObserver = RemoteChangeObserver( + account: incorrectAccount, + remoteInterface: remoteInterface, + changeNotificationInterface: MockChangeNotificationInterface(), + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + for _ in 0 ... Self.timeout { + try await Task.sleep(nanoseconds: 1_000_000) + if remoteChangeObserver.webSocketAuthenticationFailCount == + remoteChangeObserver.webSocketAuthenticationFailLimit + { + break + } + } + + let count = remoteChangeObserver.webSocketAuthenticationFailCount + let limit = remoteChangeObserver.webSocketAuthenticationFailLimit + let active = remoteChangeObserver.webSocketTaskActive + + XCTAssertEqual(count, limit) + XCTAssertFalse(active) + } + + func testChangeRecognised() async throws { + // 1. Arrange + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let testStartDate = Date() + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + remoteInterface.capabilities = mockCapabilities + + // --- DB State (What the app thinks is true) --- + // A materialized file in the root that will be updated. + var rootFileToUpdate = SendableItemMetadata(ocId: "rootFile", fileName: "root-file.txt", account: Self.account) + rootFileToUpdate.downloaded = true + rootFileToUpdate.etag = "ETAG_OLD_ROOTFILE" + Self.dbManager.addItemMetadata(rootFileToUpdate) + + // A materialized folder that will have its contents changed. + var folderA = SendableItemMetadata(ocId: "folderA", fileName: "FolderA", account: Self.account) + folderA.directory = true + folderA.visitedDirectory = true + folderA.etag = "ETAG_OLD_FOLDERA" + Self.dbManager.addItemMetadata(folderA) + + // A materialized file inside FolderA that will be deleted. + var fileInAToDelete = SendableItemMetadata(ocId: "fileInA", fileName: "file-in-a.txt", account: Self.account) + fileInAToDelete.downloaded = true + fileInAToDelete.serverUrl = Self.account.davFilesUrl + "/FolderA" + // Set an explicit old sync time to verify it gets updated during deletion + fileInAToDelete.syncTime = Date(timeIntervalSince1970: 1000) + Self.dbManager.addItemMetadata(fileInAToDelete) + + // A materialized folder that will be deleted entirely. + var folderBToDelete = SendableItemMetadata(ocId: "folderB", fileName: "FolderB", account: Self.account) + folderBToDelete.directory = true + folderBToDelete.visitedDirectory = true + // Set an explicit old sync time to verify it gets updated during deletion + folderBToDelete.syncTime = Date(timeIntervalSince1970: 2000) + Self.dbManager.addItemMetadata(folderBToDelete) + + // Record original sync times to verify they are updated during deletion + let originalFileInASyncTime = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "fileInA")).syncTime + let originalFolderBSyncTime = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderB")).syncTime + + // --- Server State (The new "remote truth") --- + let rootItem = remoteInterface.rootItem! + + // Update the root file on the server. + let serverRootFile = MockRemoteItem( + identifier: "rootFile", + versionIdentifier: "ETAG_NEW_ROOTFILE", + name: "root-file.txt", + remotePath: Self.account.davFilesUrl + "/root-file.txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.davFilesUrl + ) + rootItem.children.append(serverRootFile) + + // Update FolderA on the server and modify its contents. + let serverFolderA = MockRemoteItem( + identifier: "folderA", + versionIdentifier: "ETAG_NEW_FOLDERA", + name: "FolderA", + remotePath: Self.account.davFilesUrl + "/FolderA", + directory: true, + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.davFilesUrl + ) + rootItem.children.append(serverFolderA) + + // Add a new file inside FolderA on the server. + let newFileInA = MockRemoteItem( + identifier: "newFileInA", + name: "new-file.txt", + remotePath: serverFolderA.remotePath + "/new-file.txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: serverFolderA.remotePath + ) + serverFolderA.children.append(newFileInA) + + let authExpectation = + XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) + let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified") + + let notificationInterface = MockChangeNotificationInterface { + changeNotifiedExpectation.fulfill() + } + + remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: notificationInterface, + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + // 2. Act & Assert + await wait(for: authExpectation, description: "authentication") + + Self.notifyPushServer.send(message: "notify_file") + + await wait(for: changeNotifiedExpectation, description: "change notification") + + // 3. Assert Database State + // Check updated items + let finalRootFile = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "rootFile")) + XCTAssertEqual(finalRootFile.etag, "ETAG_NEW_ROOTFILE") + XCTAssertTrue(finalRootFile.syncTime >= testStartDate) + + let finalFolderA = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderA")) + XCTAssertEqual(finalFolderA.etag, "ETAG_NEW_FOLDERA") + XCTAssertTrue(finalFolderA.syncTime >= testStartDate) + + // Check new item + let finalNewFile = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "newFileInA")) + XCTAssertEqual(finalNewFile.fileName, "new-file.txt") + XCTAssertNotNil(finalNewFile.syncTime) + + // Check deleted items + let deletedFileInA = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "fileInA")) + XCTAssertTrue( + deletedFileInA.deleted, "File inside updated folder should be marked as deleted." + ) + XCTAssertTrue(deletedFileInA.syncTime >= testStartDate, + "Deleted file's sync time should be updated to current time") + XCTAssertGreaterThan(deletedFileInA.syncTime, originalFileInASyncTime, + "Deleted file's sync time should be newer than original sync time") + + let deletedFolderB = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "folderB")) + XCTAssertTrue(deletedFolderB.deleted, "The entire folder should be marked as deleted.") + XCTAssertTrue(deletedFolderB.syncTime >= testStartDate, + "Deleted folder's sync time should be updated to current time") + XCTAssertGreaterThan(deletedFolderB.syncTime, originalFolderBSyncTime, + "Deleted folder's sync time should be newer than original sync time") + } + + func testIgnoreNonFileNotifications() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities + + let authenticated = expectation(description: "authenticated") + authenticated.assertForOverFulfill = false + + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authenticated.fulfill() + } + + let notificationInterface = MockChangeNotificationInterface { + XCTFail("This notification should not happen!") + } + + remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: notificationInterface, + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + await fulfillment(of: [authenticated]) + + Self.notifyPushServer.send(message: "random") + Self.notifyPushServer.send(message: "notify_activity") + Self.notifyPushServer.send(message: "notify_notification") + } + + func testPolling() async throws { + // 1. Arrange + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + // No capabilities -> will force polling. + remoteInterface.capabilities = "" + + // DB State: A materialized file with an old ETag. + var fileToUpdate = SendableItemMetadata(ocId: "item1", fileName: "file.txt", account: Self.account) + fileToUpdate.downloaded = true + fileToUpdate.etag = "ETAG_OLD" + Self.dbManager.addItemMetadata(fileToUpdate) + + // Server State: The same file now has a new ETag. + let serverItem = MockRemoteItem(identifier: "item1", versionIdentifier: "ETAG_NEW", name: "file.txt", remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account.ncKitAccount, username: Self.account.username, userId: Self.account.id, serverUrl: Self.account.serverUrl) + remoteInterface.rootItem?.children = [serverItem] + + let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified via Polling") + + let notificationInterface = MockChangeNotificationInterface { + changeNotifiedExpectation.fulfill() + } + + remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: notificationInterface, + domain: nil, + dbManager: Self.dbManager, + pollInterval: 0.5, + log: FileProviderLogMock() + ) + + // 2. Act & Assert + // The observer will fail to connect to websocket and start polling. + // We just need to wait for the poll to fire and detect the change. + await wait(for: changeNotifiedExpectation, description: "polling to trigger change") + + let pollingActive = remoteChangeObserver?.pollingActive ?? false + XCTAssertTrue(pollingActive, "Polling should be active.") + } + + func testRetryOnRemoteClose() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities + + let authenticated = expectation(description: "authenticated") + let reauthenticated = expectation(description: "reauthenticated") + let fulfillments = ExpectationFulfillmentCounter(authenticated, reauthenticated) + + NotificationCenter.default.addObserver( + forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil + ) { _ in + Task { + await fulfillments.next() + } + } + + remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: MockChangeNotificationInterface(), + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + await fulfillment(of: [authenticated]) + + Self.notifyPushServer.resetCredentialsState() + Self.notifyPushServer.closeConnections() + + await fulfillment(of: [reauthenticated]) + } + + func testPinging() async throws { + let authentication = expectation(description: "authentication") + authentication.assertForOverFulfill = false + + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities + + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authentication.fulfill() + } + + remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: MockChangeNotificationInterface(), + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + let pingIntervalNsecs = 500_000_000 + remoteChangeObserver?.setWebSocketPingInterval(to: UInt64(pingIntervalNsecs)) + await wait(for: authentication, description: "authentication") + + let measurementStart = Date() + let firstPing = expectation(description: "First Ping") + let secondPing = expectation(description: "Second Ping") + let thirdPing = expectation(description: "Third Ping") + let pings = ExpectationFulfillmentCounter(firstPing, secondPing, thirdPing) + + Self.notifyPushServer.pingHandler = { + Task { + await pings.next() + } + } + + await fulfillment(of: [firstPing, secondPing, thirdPing]) + let measurementEnd = Date() + let pingTimeInterval = measurementEnd.timeIntervalSince(measurementStart) + + XCTAssertGreaterThan(pingTimeInterval, Double(pingIntervalNsecs / 1_000_000_000) * 3) + } + + func testRetryOnConnectionLoss() async throws { + // 1. Arrange + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + remoteInterface.capabilities = mockCapabilities + + // Setup a change scenario + var fileToUpdate = + SendableItemMetadata(ocId: "item1", fileName: "file.txt", account: Self.account) + fileToUpdate.downloaded = true + fileToUpdate.etag = "ETAG_OLD" + Self.dbManager.addItemMetadata(fileToUpdate) + let serverItem = MockRemoteItem( + identifier: "item1", + versionIdentifier: "ETAG_NEW", + name: "file.txt", + remotePath: Self.account.davFilesUrl + "/file.txt", + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + remoteInterface.rootItem?.children = [serverItem] + + let change1 = XCTestExpectation(description: "First change notification") + let change2 = XCTestExpectation(description: "Second change notification") + + let fulfillments = ExpectationFulfillmentCounter(change1, change2) + + let notificationInterface = MockChangeNotificationInterface { + Task { + await fulfillments.next() + } + } + + let remoteChangeObserver = RemoteChangeObserver( + account: Self.account, + remoteInterface: remoteInterface, + changeNotificationInterface: notificationInterface, + domain: nil, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + self.remoteChangeObserver = remoteChangeObserver + + // --- Phase 1: Test connection and change notification --- + let authExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) + remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi) + await wait(for: authExpectation, description: "initial authentication") + + Self.notifyPushServer.send(message: "notify_file") + await wait(for: change1, description: "first change") + + // --- Phase 2: Test connection loss --- + remoteChangeObserver.networkReachabilityObserver(.notReachable) + // Give it a moment to process the disconnection + try await Task.sleep(nanoseconds: 200_000_000) + let webSocketTaskActive = remoteChangeObserver.webSocketTaskActive + + XCTAssertFalse(webSocketTaskActive, "Websocket should be inactive after connection loss.") + Self.notifyPushServer.reset() + + // --- Phase 3: Test reconnection and change notification --- + let reauthExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) + + // Trigger the reconnection logic. + remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi) + + // Now, wait for the expectation to be fulfilled. + await wait(for: reauthExpectation, description: "re-authentication") + let webSocketTaskActiveAfterReconnect = remoteChangeObserver.webSocketTaskActive + + XCTAssertTrue(webSocketTaskActiveAfterReconnect, "Websocket should be active again after reconnection.") + + Self.notifyPushServer.send(message: "notify_file") + await wait(for: change2, description: "second change") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift new file mode 100644 index 0000000000000..d73ddef55f0fc --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Alamofire +import Foundation +import NextcloudCapabilitiesKit +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import NextcloudKit +import Testing +@testable import TestInterface + +@Suite("RemoteInterface Extension Tests", .serialized) +struct RemoteInterfaceExtensionTests { + let testAccount = Account(user: "a1", id: "1", serverUrl: "example.com", password: "pass") + let otherAccount = Account(user: "a2", id: "2", serverUrl: "example.com", password: "word") + + func capabilitiesFromMockJSON(jsonString: String = mockCapabilities) -> (Capabilities, Data) { + let data = jsonString.data(using: .utf8)! + let caps = Capabilities(data: data)! + return (caps, data) + } + + @Test func currentCapabilitiesReturnsFreshCache() async { + await RetrievedCapabilitiesActor.shared.reset() + let remoteInterface = TestableRemoteInterface { _, _, _ in + Issue.record("fetchCapabilities should NOT be called when cache is fresh.") + return (testAccount.ncKitAccount, nil, nil, .invalidResponseError) + } + + let (freshCaps, _) = capabilitiesFromMockJSON() + let freshDate = Date() // Now + + // Setup: Put fresh data into the shared actor + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: freshCaps, + retrievedAt: freshDate + ) + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(result.error == .success) + #expect(result.capabilities == freshCaps) + #expect(result.data == nil, "Data should be nil as no fetch occurred") + #expect(result.account == testAccount.ncKitAccount) + } + + @Test func currentCapabilitiesFetchesOnNoCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (fetchedCaps, fetchedData) = capabilitiesFromMockJSON() + + await confirmation("fetcherCalled") { fetcherCalled in + let remoteInterface = TestableRemoteInterface { acc, _, _ in + fetcherCalled() + #expect(acc.ncKitAccount == testAccount.ncKitAccount) + return (acc.ncKitAccount, fetchedCaps, fetchedData, .success) + } + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(result.error == .success) + #expect(result.capabilities == fetchedCaps) + #expect(result.data == fetchedData) + } + + let actorCache = await RetrievedCapabilitiesActor.shared.getCapabilities(for: testAccount.ncKitAccount) + #expect(actorCache?.capabilities == fetchedCaps) + } + + @Test func currentCapabilitiesFetchesOnStaleCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (staleCaps, _) = capabilitiesFromMockJSON(jsonString: """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "files": { + "undelete": false + } + } + } + } + } + """) // Different caps + let staleDate = Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 300)) // Definitely stale + + // Setup: Put stale data into the actor + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: staleCaps, + retrievedAt: staleDate + ) + + let (newCaps, newData) = capabilitiesFromMockJSON() // Fresh data to be fetched + + await confirmation("fetcherCalled") { fetcherCalled in + let remoteInterface = TestableRemoteInterface { acc, _, _ in + fetcherCalled() + return (acc.ncKitAccount, newCaps, newData, .success) + } + + let result = await remoteInterface.currentCapabilities(account: testAccount) + + #expect(result.error == .success) + #expect(result.capabilities == newCaps, "Should return newly fetched capabilities.") + #expect(result.data == newData) + } + + let actorCache = await RetrievedCapabilitiesActor.shared.getCapabilities(for: testAccount.ncKitAccount) + #expect(actorCache?.capabilities == newCaps) + #expect((actorCache?.retrievedAt ?? .distantPast) > staleDate) + } + + @Test func currentCapabilitiesAwaitsAndUsesCache() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let (cachedCaps, cachedData) = capabilitiesFromMockJSON() + + let remoteInterface = TestableRemoteInterface { acc, _, _ in + Issue.record("fetchCapabilities should NOT be called when cache is fresh after await.") + return (acc.ncKitAccount, cachedCaps, cachedData, .success) + } + + // 1. Simulate an external process starting a fetch for testAccount + await RetrievedCapabilitiesActor.shared.setOngoingFetch(forAccount: testAccount.ncKitAccount, ongoing: true) + + await confirmation("currentCapabilitiesReturned") { currentCapabilitiesReturned in + let currentCapabilitiesTask = Task { @Sendable in + // 2. This call to currentCapabilities should await the ongoing fetch. + let result = await remoteInterface.currentCapabilities(account: testAccount) + currentCapabilitiesReturned() + // Assertions on the result will be done after the task. + #expect(result.capabilities == cachedCaps) + #expect(result.error == .success) + } + + // 3. Now, the "external" fetch completes and populates the cache. + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: cachedCaps, + retrievedAt: Date() // Fresh date + ) + + await RetrievedCapabilitiesActor.shared.setOngoingFetch(forAccount: testAccount.ncKitAccount, ongoing: false) + + await currentCapabilitiesTask.value + } + } + + @Test func supportsTrashTrue() async throws { + await RetrievedCapabilitiesActor.shared.reset() // Reset shared actor + + // JSON where files.undelete is true (default mockCapabilitiesJSON) + let (capsWithTrash, dataWithTrash) = capabilitiesFromMockJSON() + #expect(capsWithTrash.files?.undelete == true) + + let remoteInterface = TestableRemoteInterface { acc, _, _ in + (acc.ncKitAccount, capsWithTrash, dataWithTrash, .success) + } + + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: capsWithTrash, // any capability + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) // Stale + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(result == true) + } + + @Test func supportsTrashFalse() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let jsonNoUndelete = """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "files": { + "undelete": false + } + } + } + } + } + """ + let (capsNoTrash, dataNoTrash) = capabilitiesFromMockJSON(jsonString: jsonNoUndelete) + #expect(capsNoTrash.files?.undelete == false) + + let remoteInterface = TestableRemoteInterface { acc, _, _ in + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: acc.ncKitAccount, capabilities: capsNoTrash, retrievedAt: Date() + ) + return (acc.ncKitAccount, capsNoTrash, dataNoTrash, .success) + } + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry + forAccount: testAccount.ncKitAccount, + capabilities: capsNoTrash, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(result == false) + } + + @Test func supportsTrashNilCapabilities() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let remoteInterface = TestableRemoteInterface { acc, _, _ in + (acc.ncKitAccount, nil, nil, .invalidResponseError) + } + + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: capabilitiesFromMockJSON().0, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result) + } + + @Test func supportsTrashNilFilesSection() async throws { + await RetrievedCapabilitiesActor.shared.reset() + let jsonNoFilesSection = """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK" + }, + "data": { + "capabilities": { + "core": { + "pollinterval": 60 + } + } + } + } + } + """ + // This JSON will result in `Capabilities.files` being nil + let (capsNoFiles, dataNoFiles) = capabilitiesFromMockJSON(jsonString: jsonNoFilesSection) + #expect(capsNoFiles.files?.undelete != true) // Check our parsing logic + + let remoteInterface = TestableRemoteInterface { acc, _, _ in + (acc.ncKitAccount, capsNoFiles, dataNoFiles, .success) + } + + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry + forAccount: testAccount.ncKitAccount, + capabilities: capsNoFiles, + retrievedAt: Date(timeIntervalSinceNow: -(CapabilitiesFetchInterval + 100)) + ) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result) + } + + @Test func supportsTrashHandlesErrorFromCurrentCapabilities() async throws { + await RetrievedCapabilitiesActor.shared.reset() + + let remoteInterface = TestableRemoteInterface { acc, _, _ in + (acc.ncKitAccount, nil, nil, .invalidResponseError) + } + // Ensure fetch is triggered + // (e.g., actor has no data or stale data for testAccount.ncKitAccount) + + let result = await remoteInterface.supportsTrash(account: testAccount) + #expect(!result, "supportsTrash should return false if currentCapabilities errors.") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift new file mode 100644 index 0000000000000..31344d3c44ab1 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +import NextcloudCapabilitiesKit +@testable import NextcloudFileProviderKit +import Testing +@testable import TestInterface +import XCTest + +@Suite("RetrievedCapabilitiesActor tests") +struct RetrievedCapabilitiesActorTests { + let account1 = "acc1" + let account2 = "acc2" + + @Test func setCapabilitiesCompletes() async { + let actor = RetrievedCapabilitiesActor() // New instance for the test + let capsData = mockCapabilities.data(using: .utf8)! + let caps = Capabilities(data: capsData)! + let specificDate = Date(timeIntervalSince1970: 1_234_567_890) + + // We call the public API. + await actor.setCapabilities(forAccount: account1, capabilities: caps, retrievedAt: specificDate) + let setCaps = await actor.getCapabilities(for: account1) + + #expect(setCaps?.retrievedAt == specificDate) + #expect(setCaps?.capabilities != nil) + } + + @Test func setOngoingFetchTrueCausesSuspension() async throws { + let actor = RetrievedCapabilitiesActor() + let awaiterDidProceed = Expectation("awaiterDidProceed") + + // 1. Mark fetch as ongoing + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + // 2. Attempt to await in a separate task + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + await awaiterDidProceed.fulfill() + } + + // 3. Give the awaitingTask a moment to potentially run and suspend + try await Task.sleep(for: .milliseconds(100)) + #expect(await awaiterDidProceed.isFulfilled == false, "`awaitFetchCompletion` should suspend if fetch is ongoing.") + + // 4. Clean up: complete the fetch to allow the task to finish + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + await awaitingTask.value // Ensure the task fully completes + #expect(await awaiterDidProceed.isFulfilled, "Awaiter should proceed after fetch is no longer ongoing.") + } + + @Test func setOngoingFetchFalseResumesAwaiter() async throws { + let actor = RetrievedCapabilitiesActor() + let awaiterCompleted = Expectation("awaiterCompleted") + + // 1. Mark fetch as ongoing and start an awaiter + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + await awaiterCompleted.fulfill() + } + + // 2. Ensure it's waiting + try await Task.sleep(for: .milliseconds(100)) + #expect(await awaiterCompleted.isFulfilled == false, "Awaiter should be suspended initially.") + + // 3. Mark fetch as not ongoing, which should resume the awaiter + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + // 4. Await the task's completion and check the flag + await awaitingTask.value + #expect(await awaiterCompleted.isFulfilled, "Awaiter should complete after `setOngoingFetch(false)`.") + } + + @Test func awaitFetchCompletionReturnsImmediately() async throws { + let actor = RetrievedCapabilitiesActor() + + await confirmation("did awaiter complete immediately") { didAwaiterCompleteImmediately in + await actor.awaitFetchCompletion(forAccount: account1) + didAwaiterCompleteImmediately() + } + } + + @Test func awaitFetchCompletion_suspendsAndResumes_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + let didAwaiterComplete = Expectation("didAwaiterComplete") + + // 1. Mark fetch as ongoing + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + // 2. Start task that awaits + let awaitingTask = Task { + await actor.awaitFetchCompletion(forAccount: account1) + await didAwaiterComplete.fulfill() + } + + // 3. Check for suspension (indirectly) + try await Task.sleep(for: .milliseconds(100)) + #expect(await didAwaiterComplete.isFulfilled == false, "Awaiter should be suspended while fetch is ongoing.") + + // 4. Mark fetch as completed + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + // 5. Awaiter should complete + await awaitingTask.value + #expect(await didAwaiterComplete.isFulfilled, "Awaiter should complete after fetch is no longer ongoing.") + } + + @Test func awaitFetchCompletion_multipleAwaiters_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + let awaiter1Complete = Expectation("awaiter1Complete") + let awaiter2Complete = Expectation("awaiter2Complete") + + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + + let task1 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + await awaiter1Complete.fulfill() + } + let task2 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + await awaiter2Complete.fulfill() + } + + try await Task.sleep(for: .milliseconds(100)) + + var firstFulfillment = await awaiter1Complete.isFulfilled + var secondFulfillment = await awaiter2Complete.isFulfilled + #expect(await firstFulfillment == false && secondFulfillment == false, "Both awaiters should be suspended.") + + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + + await task1.value + await task2.value + + firstFulfillment = await awaiter1Complete.isFulfilled + secondFulfillment = await awaiter2Complete.isFulfilled + #expect(firstFulfillment && secondFulfillment, "Both awaiters should complete after fetch is no longer ongoing.") + } + + @Test func setOngoingFetch_false_isolatesAccountResumption_behavioral() async throws { + let actor = RetrievedCapabilitiesActor() + let acc1AwaiterDone = Expectation("acc1AwaiterDone") + let acc2AwaiterDone = Expectation("acc2AwaiterDone") + + // Start fetches for both accounts + await actor.setOngoingFetch(forAccount: account1, ongoing: true) + await actor.setOngoingFetch(forAccount: account2, ongoing: true) + + // Setup awaiters + let taskAcc1 = Task { + await actor.awaitFetchCompletion(forAccount: account1) + await acc1AwaiterDone.fulfill() + } + let taskAcc2 = Task { + await actor.awaitFetchCompletion(forAccount: account2) + await acc2AwaiterDone.fulfill() + } + + try await Task.sleep(for: .milliseconds(100)) // Allow tasks to suspend + + let firstFulfillment = await acc1AwaiterDone.isFulfilled + let secondFulfillment = await acc2AwaiterDone.isFulfilled + #expect(await firstFulfillment == false && secondFulfillment == false, "Both awaiters initially suspended.") + + // Complete fetch for account1 ONLY + await actor.setOngoingFetch(forAccount: account1, ongoing: false) + await taskAcc1.value + + #expect(await acc1AwaiterDone.isFulfilled, "Awaiter for account1 should complete.") + #expect(await acc2AwaiterDone.isFulfilled == false, "Awaiter for account2 should still be suspended.") + + // Complete fetch for account2 + await actor.setOngoingFetch(forAccount: account2, ongoing: false) + await taskAcc2.value // Wait for acc2's awaiter to complete + + #expect(await acc2AwaiterDone.isFulfilled, "Awaiter for account2 should now complete.") + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift new file mode 100644 index 0000000000000..f3a46db054400 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Foundation +@testable import NextcloudFileProviderKit +import Testing + +struct SafeFilenameUrlTests { + @Test func safeFilenameFull() { + let urlString = "https://example.com.cn/something/goes/here.html" + let expected = "example_com_cn_something_goes_here_html" + let url = URL(string: urlString) + #expect(url?.safeFilenameFromURLString() == expected) + } + + @Test func safeFilenameHostOnly() { + let urlString = "https://example.com.cn" + let expected = "example_com_cn" + let url = URL(string: urlString) + #expect(url?.safeFilenameFromURLString() == expected) + } + + @Test func safeFilenameWithQuery() { + let urlString = "https://www.example.com.cn/path/to/file.html?query=string¶m=value&" + let expected = "www_example_com_cn_path_to_file_html_query=string¶m=value&" + let url = URL(string: urlString) + #expect(url?.safeFilenameFromURLString() == expected) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UploadTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UploadTests.swift new file mode 100644 index 0000000000000..4a4d7fcc368b0 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/UploadTests.swift @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider +@testable import NextcloudFileProviderKit +import NextcloudFileProviderKitMocks +import RealmSwift +import TestInterface +import XCTest + +final class UploadTests: NextcloudFileProviderKitTestCase { + static let account = Account(user: "user", id: "id", serverUrl: "test.cloud.com", password: "1234") + static let dbManager = FilesDatabaseManager(account: account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) + + override func setUp() { + super.setUp() + Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name + } + + func testStandardUpload() async throws { + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: 8) + try data.write(to: fileUrl) + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + let remotePath = Self.account.davFilesUrl + "/file.txt" + let result = await NextcloudFileProviderKit.upload( + fileLocatedAt: fileUrl.path, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: Self.account, + dbManager: Self.dbManager, + log: FileProviderLogMock() + ) + + XCTAssertEqual(result.remoteError, .success) + XCTAssertNil(result.chunks) + XCTAssertEqual(result.size, Int64(data.count)) + XCTAssertNotNil(result.ocId) + XCTAssertNotNil(result.etag) + } + + func testChunkedUpload() async throws { + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: 8) + try data.write(to: fileUrl) + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + let remotePath = Self.account.davFilesUrl + "/file.txt" + let chunkSize = 3 + var uploadedChunks = [RemoteFileChunk]() + let result = await NextcloudFileProviderKit.upload( + fileLocatedAt: fileUrl.path, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: Self.account, + inChunksSized: chunkSize, + dbManager: Self.dbManager, + log: FileProviderLogMock(), + chunkUploadCompleteHandler: { uploadedChunks.append($0) } + ) + let resultChunks = try XCTUnwrap(result.chunks) + let expectedChunkCount = Int(ceil(Double(data.count) / Double(chunkSize))) + + XCTAssertEqual(result.remoteError, .success) + XCTAssertEqual(resultChunks.count, expectedChunkCount) + XCTAssertEqual(result.size, Int64(data.count)) + XCTAssertNotNil(result.ocId) + XCTAssertNotNil(result.etag) + + XCTAssertEqual(uploadedChunks.count, resultChunks.count) + + let firstUploadedChunk = try XCTUnwrap(uploadedChunks.first) + let firstUploadedChunkNameInt = try XCTUnwrap(Int(firstUploadedChunk.fileName)) + let lastUploadedChunk = try XCTUnwrap(uploadedChunks.last) + let lastUploadedChunkNameInt = try XCTUnwrap(Int(lastUploadedChunk.fileName)) + XCTAssertEqual(firstUploadedChunkNameInt, 1) + XCTAssertEqual(lastUploadedChunkNameInt, expectedChunkCount) + XCTAssertEqual(Int(firstUploadedChunk.size), chunkSize) + XCTAssertEqual( + Int(lastUploadedChunk.size), data.count - ((lastUploadedChunkNameInt - 1) * chunkSize) + ) + } + + func testResumingInterruptedChunkedUpload() async throws { + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: 8) + try data.write(to: fileUrl) + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + let chunkSize = 3 + let uploadUuid = UUID().uuidString + let previousUploadedChunkNum = 1 + let previousUploadedChunk = RemoteFileChunk( + fileName: String(previousUploadedChunkNum), + size: Int64(chunkSize), + remoteChunkStoreFolderName: uploadUuid + ) + let previousUploadedChunks = [previousUploadedChunk] + remoteInterface.currentChunks = [uploadUuid: previousUploadedChunks] + + let db = Self.dbManager.ncDatabase() + try db.write { + db.add([ + RemoteFileChunk( + fileName: String(previousUploadedChunkNum + 1), + size: Int64(chunkSize), + remoteChunkStoreFolderName: uploadUuid + ), + RemoteFileChunk( + fileName: String(previousUploadedChunkNum + 2), + size: Int64(data.count - (chunkSize * (previousUploadedChunkNum + 1))), + remoteChunkStoreFolderName: uploadUuid + ) + ]) + } + + let remotePath = Self.account.davFilesUrl + "/file.txt" + var uploadedChunks = [RemoteFileChunk]() + let result = await NextcloudFileProviderKit.upload( + fileLocatedAt: fileUrl.path, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: Self.account, + inChunksSized: chunkSize, + usingChunkUploadId: uploadUuid, + dbManager: Self.dbManager, + log: FileProviderLogMock(), + chunkUploadCompleteHandler: { uploadedChunks.append($0) } + ) + let resultChunks = try XCTUnwrap(result.chunks) + let expectedChunkCount = Int(ceil(Double(data.count) / Double(chunkSize))) + + XCTAssertEqual(result.remoteError, .success) + XCTAssertEqual(resultChunks.count, expectedChunkCount) + XCTAssertEqual(result.size, Int64(data.count)) + XCTAssertNotNil(result.ocId) + XCTAssertNotNil(result.etag) + + XCTAssertEqual(uploadedChunks.count, resultChunks.count - previousUploadedChunks.count) + + let firstUploadedChunk = try XCTUnwrap(uploadedChunks.first) + let firstUploadedChunkNameInt = try XCTUnwrap(Int(firstUploadedChunk.fileName)) + let lastUploadedChunk = try XCTUnwrap(uploadedChunks.last) + let lastUploadedChunkNameInt = try XCTUnwrap(Int(lastUploadedChunk.fileName)) + XCTAssertEqual(firstUploadedChunkNameInt, previousUploadedChunkNum + 1) + XCTAssertEqual(lastUploadedChunkNameInt, previousUploadedChunkNum + 2) + print(uploadedChunks) + XCTAssertEqual(Int(firstUploadedChunk.size), chunkSize) + XCTAssertEqual( + Int(lastUploadedChunk.size), data.count - ((lastUploadedChunkNameInt - 1) * chunkSize) + ) + } + + func testUsingServerCapabilitiesChunkSize() async throws { + let capabilities = ##""" + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK", + "totalitems": "", + "itemsperpage": "" + }, + "data": { + "version": { + "major": 28, + "minor": 0, + "micro": 4, + "string": "28.0.4", + "edition": "", + "extendedSupport": false + }, + "capabilities": { + "core": { + "pollinterval": 60, + "webdav-root": "remote.php/webdav", + "reference-api": true, + "reference-regex": "(\\s|\n|^)(https?:\\/\\/)((?:[-A-Z0-9+_]+\\.)+[-A-Z]+(?:\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\n|$)" + }, + "files": { + "bigfilechunking": true, + "blacklisted_files": [ + ".htaccess" + ], + "chunked_upload": { + "max_size": 4, + "max_parallel_count": 5 + }, + "directEditing": { + "url": "https://mock.nc.com/ocs/v2.php/apps/files/api/v1/directEditing", + "etag": "c748e8fc588b54fc5af38c4481a19d20", + "supportsFileId": true + }, + "comments": true, + "undelete": true, + "versioning": true, + "version_labeling": true, + "version_deletion": true + }, + "dav": { + "chunking": "1.0", + "bulkupload": "1.0" + } + } + } + } + } + """## + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: 8) + try data.write(to: fileUrl) + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + remoteInterface.capabilities = capabilities + + let remotePath = Self.account.davFilesUrl + "/file.txt" + var uploadedChunks = [RemoteFileChunk]() + let result = await NextcloudFileProviderKit.upload( + fileLocatedAt: fileUrl.path, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: Self.account, + dbManager: Self.dbManager, + log: FileProviderLogMock(), + chunkUploadCompleteHandler: { uploadedChunks.append($0) } + ) + let resultChunks = try XCTUnwrap(result.chunks) + + XCTAssertEqual(result.remoteError, .success) + XCTAssertEqual(resultChunks.count, 2) + XCTAssertEqual(result.size, Int64(data.count)) + XCTAssertNotNil(result.ocId) + XCTAssertNotNil(result.etag) + + XCTAssertEqual(uploadedChunks.count, resultChunks.count) + XCTAssertEqual(uploadedChunks.first?.size, 4) + XCTAssertEqual(uploadedChunks.last?.size, 4) + } + + func testUsingServerCapabilitiesWithoutChunkSize() async throws { + let capabilities = ##""" + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 100, + "message": "OK", + "totalitems": "", + "itemsperpage": "" + }, + "data": { + "version": { + "major": 28, + "minor": 0, + "micro": 4, + "string": "28.0.4", + "edition": "", + "extendedSupport": false + }, + "capabilities": { + "core": { + "pollinterval": 60, + "webdav-root": "remote.php/webdav", + "reference-api": true, + "reference-regex": "(\\s|\n|^)(https?:\\/\\/)((?:[-A-Z0-9+_]+\\.)+[-A-Z]+(?:\\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\\s|\n|$)" + }, + "files": { + "bigfilechunking": true, + "blacklisted_files": [ + ".htaccess" + ], + "comments": true, + "undelete": true, + "versioning": true, + "version_labeling": true, + "version_deletion": true + }, + "dav": { + "chunking": "1.0", + "bulkupload": "1.0" + } + } + } + } + } + """## + let fileUrl = + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let data = Data(repeating: 1, count: defaultFileChunkSize + 1) + try data.write(to: fileUrl) + + let remoteInterface = + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) + remoteInterface.capabilities = capabilities + + let remotePath = Self.account.davFilesUrl + "/file.txt" + var uploadedChunks = [RemoteFileChunk]() + let result = await NextcloudFileProviderKit.upload( + fileLocatedAt: fileUrl.path, + toRemotePath: remotePath, + usingRemoteInterface: remoteInterface, + withAccount: Self.account, + dbManager: Self.dbManager, + log: FileProviderLogMock(), + chunkUploadCompleteHandler: { uploadedChunks.append($0) } + ) + let resultChunks = try XCTUnwrap(result.chunks) + + XCTAssertEqual(result.remoteError, .success) + XCTAssertEqual(resultChunks.count, 2) + XCTAssertEqual(result.size, Int64(data.count)) + XCTAssertNotNil(result.ocId) + XCTAssertNotNil(result.etag) + + XCTAssertEqual(uploadedChunks.count, resultChunks.count) + XCTAssertEqual(uploadedChunks.first?.size, Int64(defaultFileChunkSize)) + XCTAssertEqual(uploadedChunks.last?.size, 1) + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/Utilities/Expectation.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/Utilities/Expectation.swift new file mode 100644 index 0000000000000..197447fd53876 --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/Utilities/Expectation.swift @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import Testing + +/// +/// Concurrency safe expectation implementation inspired by the XCTest framework. +/// +/// Usually, the Swift Testing confirmations are used but some use cases of testing actor states concurrently require something like this instead. +/// +actor Expectation { + /// + /// Human-readable description of the explanation to make more sense of how it is being used. + /// + let description: String + + /// + /// Present state of the expectation. + /// + private(set) var isFulfilled = false + + init(_ description: String) { + self.description = description + } + + /// + /// Changes the present state to be fulfilled, if not already. + /// + /// Records an issue in case of overfulfillment. + /// + func fulfill(sourceLocation: SourceLocation = #_sourceLocation) { + guard !isFulfilled else { + Issue.record("Overfulfillment of expectation: \(description)", sourceLocation: sourceLocation) + return + } + + isFulfilled = true + } +} diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/Utilities/ExpectationFulfillmentCounter.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/Utilities/ExpectationFulfillmentCounter.swift new file mode 100644 index 0000000000000..cc7ced9eedc8d --- /dev/null +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/Utilities/ExpectationFulfillmentCounter.swift @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import XCTest + +/// +/// A concurrency-safe counter for the sequential fulfillment of multiple expectations. +/// +actor ExpectationFulfillmentCounter { + /// + /// The number of increments during the lifetime of this object. + /// + private(set) var count = 0 + + let expectations: [XCTestExpectation] + + init(_ expectations: XCTestExpectation...) { + self.expectations = expectations + } + + /// + /// Increase the state by one. + /// + func next(file: StaticString = #filePath, line: UInt = #line) { + guard expectations.count > count else { + XCTFail("Insufficient expectations to fulfill!", file: file, line: line) + return + } + + expectations[count].fulfill() + count += 1 + } +} diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj index 76d495e664ecc..b5af734e0482b 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -38,8 +38,6 @@ 53903D37295618A400D0B308 /* LineProcessor.h in Headers */ = {isa = PBXBuildFile; fileRef = 53903D36295618A400D0B308 /* LineProcessor.h */; settings = {ATTRIBUTES = (Public, ); }; }; 539158AC27BE71A900816F56 /* FinderSyncSocketLineProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 539158AB27BE71A900816F56 /* FinderSyncSocketLineProcessor.m */; }; 53B979812B84C81F002DA742 /* DocumentActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53B979802B84C81F002DA742 /* DocumentActionViewController.swift */; }; - 53C331B22BCD28C30093D38B /* NextcloudFileProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = 53C331B12BCD28C30093D38B /* NextcloudFileProviderKit */; }; - 53C331B62BCD3AFF0093D38B /* NextcloudFileProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = 53C331B52BCD3AFF0093D38B /* NextcloudFileProviderKit */; }; 53D666612B70C9A70042C03D /* FileProviderDomainDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53D666602B70C9A70042C03D /* FileProviderDomainDefaults.swift */; }; 53ED473029C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift */; }; 53FE14502B8E0658006C4193 /* ShareTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FE144F2B8E0658006C4193 /* ShareTableViewDataSource.swift */; }; @@ -49,6 +47,8 @@ 53FE14672B8F78B6006C4193 /* ShareOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FE14662B8F78B6006C4193 /* ShareOptionsView.swift */; }; AA02B2AB2E7048C800C72B34 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA02B2AA2E7048C600C72B34 /* Keychain.swift */; }; AA560CE72EDDB84000CD423E /* ServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA560CE62EDDB82900CD423E /* ServiceResolver.swift */; }; + AA5FB9E62EF54F410018BDAE /* NextcloudFileProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA5FB9E52EF54F410018BDAE /* NextcloudFileProviderKit */; }; + AA5FB9E82EF54FEA0018BDAE /* NextcloudFileProviderKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA5FB9E72EF54FEA0018BDAE /* NextcloudFileProviderKit */; }; AA7F17E12E7017230000E928 /* Authentication.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA7F17E02E7017230000E928 /* Authentication.storyboard */; }; AA7F17E32E70173E0000E928 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7F17E22E70173E0000E928 /* AuthenticationViewController.swift */; }; AA7F17E72E7038370000E928 /* NSError+FileProviderErrorCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7F17E62E7038340000E928 /* NSError+FileProviderErrorCode.swift */; }; @@ -182,7 +182,6 @@ 538E396927F4765000FA63D5 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; }; 538E396C27F4765000FA63D5 /* FileProviderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderExtension.swift; sourceTree = ""; }; 538E397227F4765000FA63D5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 538E397327F4765000FA63D5 /* FileProviderExt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FileProviderExt.entitlements; sourceTree = ""; }; 53903D0C2956164F00D0B308 /* NCDesktopClientSocketKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NCDesktopClientSocketKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 53903D0E2956164F00D0B308 /* NCDesktopClientSocketKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NCDesktopClientSocketKit.h; sourceTree = ""; }; 53903D36295618A400D0B308 /* LineProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LineProcessor.h; sourceTree = ""; }; @@ -197,7 +196,6 @@ 53D666602B70C9A70042C03D /* FileProviderDomainDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProviderDomainDefaults.swift; sourceTree = ""; }; 53ED472F29C9CE0B00795DB1 /* FileProviderExtension+ClientInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProviderExtension+ClientInterface.swift"; sourceTree = ""; }; 53FE144F2B8E0658006C4193 /* ShareTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareTableViewDataSource.swift; sourceTree = ""; }; - 53FE14572B8E3A7C006C4193 /* FileProviderUIExt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FileProviderUIExt.entitlements; sourceTree = ""; }; 53FE14582B8E3F6C006C4193 /* ShareTableItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareTableItemView.swift; sourceTree = ""; }; 53FE145A2B8F1305006C4193 /* NKShare+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NKShare+Extensions.swift"; sourceTree = ""; }; 53FE14642B8F6700006C4193 /* ShareViewDataSourceUIDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewDataSourceUIDelegate.swift; sourceTree = ""; }; @@ -221,7 +219,6 @@ C2B573B91B1CD91E00303B36 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; C2B573D71B1CD9CE00303B36 /* FinderSyncExt.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FinderSyncExt.appex; sourceTree = BUILT_PRODUCTS_DIR; }; C2B573DA1B1CD9CE00303B36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - C2B573DB1B1CD9CE00303B36 /* FinderSyncExt.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = FinderSyncExt.entitlements; sourceTree = ""; }; C2B573DC1B1CD9CE00303B36 /* FinderSync.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FinderSync.h; sourceTree = ""; }; C2B573DD1B1CD9CE00303B36 /* FinderSync.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FinderSync.m; sourceTree = ""; }; C2B573EB1B1DAD6400303B36 /* error.iconset */ = {isa = PBXFileReference; lastKnownFileType = folder.iconset; name = error.iconset; path = ../../icons/nopadding/error.iconset; sourceTree = SOURCE_ROOT; }; @@ -237,8 +234,8 @@ buildActionMask = 2147483647; files = ( 538E396A27F4765000FA63D5 /* UniformTypeIdentifiers.framework in Frameworks */, + AA5FB9E62EF54F410018BDAE /* NextcloudFileProviderKit in Frameworks */, 53903D302956173F00D0B308 /* NCDesktopClientSocketKit.framework in Frameworks */, - 53C331B22BCD28C30093D38B /* NextcloudFileProviderKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -253,8 +250,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 53C331B62BCD3AFF0093D38B /* NextcloudFileProviderKit in Frameworks */, 53651E442BBC0CA300ECAC29 /* SuggestionsTextFieldKit in Frameworks */, + AA5FB9E82EF54FEA0018BDAE /* NextcloudFileProviderKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -350,7 +347,6 @@ 5352B36B29DC44B50011CE03 /* FileProviderExtension+Thumbnailing.swift */, 536EFBF6295CF58100F4CB13 /* FileProviderSocketLineProcessor.swift */, AA02B2AA2E7048C600C72B34 /* Keychain.swift */, - 538E397327F4765000FA63D5 /* FileProviderExt.entitlements */, 538E397227F4765000FA63D5 /* Info.plist */, 5350E4EA2B0C9CE100F276CB /* FileProviderExt-Bridging-Header.h */, AAA69D922E3BB09900BBD44D /* Localizable.xcstrings */, @@ -380,7 +376,6 @@ 53B979802B84C81F002DA742 /* DocumentActionViewController.swift */, AA560CE62EDDB82900CD423E /* ServiceResolver.swift */, 537BD6812C58F72E00446ED0 /* MetadataProvider.swift */, - 53FE14572B8E3A7C006C4193 /* FileProviderUIExt.entitlements */, 53B979852B84C81F002DA742 /* Info.plist */, AAC00D292E37B29D006010FE /* Localizable.xcstrings */, AAF19A792E8D5B63005FE5B0 /* Assets.xcassets */, @@ -473,7 +468,6 @@ C2B573EF1B1DAD6400303B36 /* sync.iconset */, C2B573F11B1DAD6400303B36 /* warning.iconset */, C2B573DA1B1CD9CE00303B36 /* Info.plist */, - C2B573DB1B1CD9CE00303B36 /* FinderSyncExt.entitlements */, ); name = "Supporting Files"; sourceTree = ""; @@ -529,7 +523,7 @@ ); name = FileProviderExt; packageProductDependencies = ( - 53C331B12BCD28C30093D38B /* NextcloudFileProviderKit */, + AA5FB9E52EF54F410018BDAE /* NextcloudFileProviderKit */, ); productName = FileProviderExt; productReference = 538E396727F4765000FA63D5 /* FileProviderExt.appex */; @@ -568,7 +562,7 @@ name = FileProviderUIExt; packageProductDependencies = ( 53651E432BBC0CA300ECAC29 /* SuggestionsTextFieldKit */, - 53C331B52BCD3AFF0093D38B /* NextcloudFileProviderKit */, + AA5FB9E72EF54FEA0018BDAE /* NextcloudFileProviderKit */, ); productName = FileProviderUIExt; productReference = 53B9797E2B84C81F002DA742 /* FileProviderUIExt.appex */; @@ -771,7 +765,7 @@ mainGroup = C2B573941B1CD88000303B36; packageReferences = ( 53651E422BBC0C7F00ECAC29 /* XCRemoteSwiftPackageReference "SuggestionsTextFieldKit" */, - 53C331B02BCD28C30093D38B /* XCRemoteSwiftPackageReference "NextcloudFileProviderKit" */, + AA5FB9E42EF54F410018BDAE /* XCLocalSwiftPackageReference "../NextcloudFileProviderKit" */, ); productRefGroup = C2B573B21B1CD91E00303B36 /* Products */; projectDirPath = ""; @@ -1706,6 +1700,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AA5FB9E42EF54F410018BDAE /* XCLocalSwiftPackageReference "../NextcloudFileProviderKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../NextcloudFileProviderKit; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 53651E422BBC0C7F00ECAC29 /* XCRemoteSwiftPackageReference "SuggestionsTextFieldKit" */ = { isa = XCRemoteSwiftPackageReference; @@ -1715,14 +1716,6 @@ minimumVersion = 1.0.0; }; }; - 53C331B02BCD28C30093D38B /* XCRemoteSwiftPackageReference "NextcloudFileProviderKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/nextcloud/NextcloudFileProviderKit.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.0.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1731,14 +1724,13 @@ package = 53651E422BBC0C7F00ECAC29 /* XCRemoteSwiftPackageReference "SuggestionsTextFieldKit" */; productName = SuggestionsTextFieldKit; }; - 53C331B12BCD28C30093D38B /* NextcloudFileProviderKit */ = { + AA5FB9E52EF54F410018BDAE /* NextcloudFileProviderKit */ = { isa = XCSwiftPackageProductDependency; - package = 53C331B02BCD28C30093D38B /* XCRemoteSwiftPackageReference "NextcloudFileProviderKit" */; productName = NextcloudFileProviderKit; }; - 53C331B52BCD3AFF0093D38B /* NextcloudFileProviderKit */ = { + AA5FB9E72EF54FEA0018BDAE /* NextcloudFileProviderKit */ = { isa = XCSwiftPackageProductDependency; - package = 53C331B02BCD28C30093D38B /* XCRemoteSwiftPackageReference "NextcloudFileProviderKit" */; + package = AA5FB9E42EF54F410018BDAE /* XCLocalSwiftPackageReference "../NextcloudFileProviderKit" */; productName = NextcloudFileProviderKit; }; /* End XCSwiftPackageProductDependency section */ diff --git a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ac8d029e18ec1..d1d262262753a 100644 --- a/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/shell_integration/MacOSX/NextcloudIntegration/NextcloudIntegration.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "74a3274bff88648671702b7d1dfa0c8ea5acb44280987019f6c5bb7055b848b2", + "originHash" : "8ed0de7f57594dcc5e2c6f77ae68f568f8272187d12257ccf4aa50e70e374323", "pins" : [ { "identity" : "alamofire", @@ -19,15 +19,6 @@ "version" : "2.4.5" } }, - { - "identity" : "nextcloudfileproviderkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nextcloud/NextcloudFileProviderKit.git", - "state" : { - "revision" : "cb9c976ccc040946067f98079ca9091d8c6cb05b", - "version" : "4.0.0" - } - }, { "identity" : "nextcloudkit", "kind" : "remoteSourceControl",