diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index e16974c9..a07f9656 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: Nextcloud GmbH # SPDX-FileCopyrightText: 2025 Iva Horn -# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-License-Identifier: LGPL-3.0-or-later name: Documentation @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true jobs: - build-docs: + Build: runs-on: macos-latest steps: - uses: maxim-lobanov/setup-xcode@v1 @@ -55,8 +55,8 @@ jobs: uses: actions/upload-pages-artifact@v3 with: path: ./docs - deploy-docs: # See: https://github.com/actions/deploy-pages - needs: build-docs + Deploy: # See: https://github.com/actions/deploy-pages + needs: Build permissions: pages: write id-token: write diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml new file mode 100644 index 00000000..97404b73 --- /dev/null +++ b/.github/workflows/reuse.yml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2022 Free Software Foundation Europe e.V. +# +# SPDX-License-Identifier: CC0-1.0 +--- +name: REUSE Compliance Check + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + Check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: REUSE Compliance Check + uses: fsfe/reuse-action@v6 \ No newline at end of file diff --git a/.github/workflows/swiftformat.yml b/.github/workflows/swiftformat.yml index d63137da..5c7e5207 100644 --- a/.github/workflows/swiftformat.yml +++ b/.github/workflows/swiftformat.yml @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-FileCopyrightText: 2025 Iva Horn +# SPDX-License-Identifier: LGPL-3.0-or-later + name: SwiftFormat on: pull_request diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index ac361c7b..072a57b3 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -1,5 +1,6 @@ -# This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift +# SPDX-FileCopyrightText: Nextcloud GmbH +# SPDX-FileCopyrightText: 2025 Iva Horn +# SPDX-License-Identifier: LGPL-3.0-or-later name: Xcode @@ -10,7 +11,7 @@ on: branches: [ "main" ] jobs: - test: + Tests: runs-on: macos-latest @@ -22,13 +23,4 @@ jobs: xcode-version: latest-stable - name: Run Tests - run: xcodebuild clean build test -scheme NextcloudFileProviderKit -destination "platform=macOS,name=My Mac" -enableCodeCoverage YES -derivedDataPath NKFPK/.derivedData - - - name: Gather code coverage - run: | - cd NKFPK/.derivedData/Build/ProfileData - cd $(ls -d */|head -n 1) - directory=${PWD##*/} - pathCoverage=NKFPK/.derivedData/Build/ProfileData/${directory}/Coverage.profdata - cd ../../../../../ - xcrun llvm-cov export -format="lcov" -instr-profile $pathCoverage NKFPK/.derivedData/Build/Products/Debug/NextcloudFileProviderKitTests.xctest/Contents/MacOS/NextcloudFileProviderKitTests > coverage_report.lcov + run: xcodebuild clean build test -scheme NextcloudFileProviderKit -destination "platform=macOS,name=My Mac" diff --git a/.swift-version b/.swift-version index f0933d48..913671cd 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.10 \ No newline at end of file +6.2 \ No newline at end of file diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 00000000..0e259d42 --- /dev/null +++ b/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/LICENSE b/LICENSES/LGPL-3.0-or-later.txt similarity index 59% rename from LICENSE rename to LICENSES/LGPL-3.0-or-later.txt index 153d416d..f68378b4 100644 --- a/LICENSE +++ b/LICENSES/LGPL-3.0-or-later.txt @@ -1,4 +1,4 @@ - GNU LESSER GENERAL PUBLIC LICENSE +GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. @@ -7,50 +7,50 @@ 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. + 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. + 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. + 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. + 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". + 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. + 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. + 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. + 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: + 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 @@ -63,11 +63,11 @@ version: 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 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 @@ -79,10 +79,10 @@ layouts and accessors, or small macros, inline functions and templates 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: + 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 @@ -128,10 +128,10 @@ the following: 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: + 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, @@ -144,22 +144,22 @@ choice, if you do both of the following: 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. + 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. + 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. \ No newline at end of file + 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/LICENSES/LicenseRef-NextcloudTrademarks.txt b/LICENSES/LicenseRef-NextcloudTrademarks.txt new file mode 100644 index 00000000..464a30b5 --- /dev/null +++ b/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/Package.swift b/Package.swift index 804ca364..b953100b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.10 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -18,7 +18,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/nextcloud/NextcloudCapabilitiesKit.git", from: "2.3.0"), + .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"), diff --git a/README.md b/README.md index 503b3c49..200e6bbc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + +
Logo of NextcloudFileProviderKit
@@ -41,7 +46,7 @@ Before submitting a pull request, please ensure that your code changes comply wi 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 --swift-version 5.9 +swift package plugin --allow-writing-to-package-directory swiftformat --verbose --cache ignore ``` ## License diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 00000000..01a9b0f3 --- /dev/null +++ b/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/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift index 00392deb..1e18e44a 100644 --- a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift +++ b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Directories.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import RealmSwift diff --git a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift index 73e6aba4..32f47d6a 100644 --- a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift +++ b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+KeepDownloaded.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import RealmSwift diff --git a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift index 09fe78d6..1543366a 100644 --- a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager+Trash.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import RealmSwift diff --git a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift index 4298d79b..47622e1e 100644 --- a/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift +++ b/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import RealmSwift @@ -683,7 +683,7 @@ public final class FilesDatabaseManager: Sendable { $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]) + logger.info("Excluding item from deletion because it is a lock file from local origin.", [.item: metadata.ocId]) return } diff --git a/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift b/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift index ff79c29c..8019e210 100644 --- a/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift +++ b/Sources/NextcloudFileProviderKit/Database/SchemaVersion.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later /// /// Different schema versions shipped with this project. diff --git a/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md b/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md index af9acd99..485c9a1e 100644 --- a/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md +++ b/Sources/NextcloudFileProviderKit/Documentation.docc/Documentation.md @@ -1,3 +1,8 @@ + + # ``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. diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift index 00404861..1f018954 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit extension Enumerator { diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift index 0b814ef3..d868a3d3 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+Trash.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit extension Enumerator { diff --git a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index ad20a88b..99cbaa76 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -1,15 +1,19 @@ // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit /// /// The `NSFileProviderEnumerator` implementation to enumerate file provider items and related change sets. /// -public class Enumerator: NSObject, NSFileProviderEnumerator { +public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { let enumeratedItemIdentifier: NSFileProviderItemIdentifier - private var enumeratedItemMetadata: SendableItemMetadata? + private let enumeratedItemMetadata: SendableItemMetadata? + + private var enumeratingSystemIdentifier: Bool { + Self.isSystemIdentifier(enumeratedItemIdentifier) + } let domain: NSFileProviderDomain? let dbManager: FilesDatabaseManager @@ -19,8 +23,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { let logger: FileProviderLogger let account: Account let remoteInterface: RemoteInterface - var isInvalidated = false - private(set) var serverUrl: String = "" + let serverUrl: String private static func isSystemIdentifier(_ identifier: NSFileProviderItemIdentifier) -> Bool { identifier == .rootContainer || identifier == .trashContainer || identifier == .workingSet @@ -46,6 +49,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { 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( @@ -54,6 +58,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { if let enumeratedItemMetadata { serverUrl = enumeratedItemMetadata.serverUrl + "/" + enumeratedItemMetadata.fileName } else { + serverUrl = "" logger.error("Could not find itemMetadata for file with identifier.", [.item: enumeratedItemIdentifier]) } } @@ -64,7 +69,6 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { public func invalidate() { logger.debug("Enumerator is being invalidated.", [.item: enumeratedItemIdentifier]) - isInvalidated = true } // MARK: - Protocol methods @@ -88,8 +92,12 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { if enumeratedItemIdentifier == .trashContainer { logger.info("Enumerating trash.", [.account: account.ncKitAccount, .url: serverUrl]) - Task { - let (_, capabilities, _, error) = await remoteInterface.currentCapabilities(account: account, options: .init(), taskHandler: { _ in }) + 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]) @@ -103,14 +111,19 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { return } - let (_, trashedItems, _, trashReadError) = await remoteInterface.trashedItems( - account: account, + 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 = self.domain { + if let domain { NSFileProviderManager(for: domain)?.register( task, - forItemWithIdentifier: self.enumeratedItemIdentifier, + forItemWithIdentifier: enumeratedItemIdentifier, completionHandler: { _ in } ) } @@ -118,9 +131,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { ) guard trashReadError == .success else { - let error = trashReadError.fileProviderError( - handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier - ) ?? NSFileProviderError(.cannotSynchronize) + let error = trashReadError.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) observer.finishEnumeratingWithError(error) return } @@ -131,10 +142,11 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { remoteInterface: remoteInterface, dbManager: dbManager, numPage: 1, - trashItems: trashedItems, + trashItems: trashedItems ?? [], log: logger.log ) } + return } @@ -271,15 +283,19 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { } else if enumeratedItemIdentifier == .trashContainer { logger.debug("Enumerating changes in trash.", [.account: account.ncKitAccount]) - Task { - let (_, capabilities, _, error) = await remoteInterface.currentCapabilities( - account: account, options: .init(), taskHandler: { _ in } - ) + 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.") @@ -289,14 +305,19 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { return } - let (_, trashedItems, _, trashReadError) = await remoteInterface.trashedItems( - account: account, + 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 = self.domain { + if let domain { NSFileProviderManager(for: domain)?.register( task, - forItemWithIdentifier: self.enumeratedItemIdentifier, + forItemWithIdentifier: enumeratedItemIdentifier, completionHandler: { _ in } ) } @@ -304,9 +325,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { ) guard trashReadError == .success else { - let error = trashReadError.fileProviderError( - handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier - ) ?? NSFileProviderError(.cannotSynchronize) + let error = trashReadError.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) observer.finishEnumeratingWithError(error) return } @@ -317,7 +336,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { account: account, remoteInterface: remoteInterface, dbManager: dbManager, - trashItems: trashedItems, + trashItems: trashedItems ?? [], log: logger.log ) } @@ -330,7 +349,11 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { // 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 { + Task { [weak self] in + guard let self else { + return + } + let ( _, newMetadatas, updatedMetadatas, deletedMetadatas, _, readError ) = await Self.readServerUrl( @@ -348,14 +371,14 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { } guard readError == nil else { - logger.error("Finished enumerating changes.", [.url: self.serverUrl, .error: readError]) + logger.error("Finished enumerating changes.", [.url: serverUrl, .error: readError]) - let error = readError?.fileProviderError(handlingNoSuchItemErrorUsingItemIdentifier: self.enumeratedItemIdentifier) ?? NSFileProviderError(.cannotSynchronize) + 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: self.serverUrl]) + logger.info("404 error means item no longer exists. Deleting metadata and reporting deletion without error.", [.url: serverUrl]) - guard let itemMetadata = self.enumeratedItemMetadata else { + guard let itemMetadata = enumeratedItemMetadata else { logger.error("Invalid enumeratedItemMetadata. Could not delete metadata nor report deletion.") observer.finishEnumeratingWithError(error) return @@ -374,7 +397,7 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { completeChangesObserver( observer, anchor: anchor, - enumeratedItemIdentifier: self.enumeratedItemIdentifier, + enumeratedItemIdentifier: enumeratedItemIdentifier, account: account, remoteInterface: remoteInterface, dbManager: dbManager, @@ -393,12 +416,12 @@ public class Enumerator: NSObject, NSFileProviderEnumerator { return } - logger.info("Finished reading remote changes.", [.account: self.account.ncKitAccount, .url: self.serverUrl]) + logger.info("Finished reading remote changes.", [.account: account.ncKitAccount, .url: serverUrl]) completeChangesObserver( observer, anchor: anchor, - enumeratedItemIdentifier: self.enumeratedItemIdentifier, + enumeratedItemIdentifier: enumeratedItemIdentifier, account: account, remoteInterface: remoteInterface, dbManager: dbManager, diff --git a/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift b/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift index 98f07dd8..ed2fa9d8 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/EnumeratorPageResponse.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation diff --git a/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift b/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift index 72d1e70c..92fb4130 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/MaterializedEnumerationObserver.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import FileProvider import Foundation diff --git a/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift b/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift index f34d858c..3b8a2690 100644 --- a/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift +++ b/Sources/NextcloudFileProviderKit/Enumeration/RemoteChangeObserver.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire @preconcurrency import FileProvider @@ -9,18 +9,23 @@ import NextcloudKit public let NotifyPushAuthenticatedNotificationName = Notification.Name("NotifyPushAuthenticated") -public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSessionWebSocketDelegate, @unchecked Sendable { +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 var webSocketReconfigureIntervalNanoseconds: UInt64 = 1 * 1_000_000_000 - public var webSocketPingFailLimit = 8 - public var webSocketAuthenticationFailLimit = 3 - public var webSocketTaskActive: Bool { webSocketTask != nil } + 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 @@ -35,16 +40,12 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess private(set) var webSocketAuthenticationFailCount = 0 private(set) var pollingTimer: Timer? - public var pollInterval: TimeInterval = 60 { - didSet { - if pollingActive { - stopPollingTimer() - startPollingTimer() - } - } - } - public var pollingActive: Bool { pollingTimer != nil } + let pollInterval: TimeInterval + + public var pollingActive: Bool { + pollingTimer != nil + } private(set) var networkReachability: NKTypeReachability = .unknown { didSet { @@ -66,6 +67,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess changeNotificationInterface: ChangeNotificationInterface, domain: NSFileProviderDomain?, dbManager: FilesDatabaseManager, + pollInterval: TimeInterval = 60, log: any FileProviderLogging ) { self.account = account @@ -73,26 +75,38 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess self.changeNotificationInterface = changeNotificationInterface self.domain = domain self.dbManager = dbManager + self.pollInterval = pollInterval logger = FileProviderLogger(category: "RemoteChangeObserver", log: log) super.init() - connect() + + // 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 { return } + 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 + 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 { @MainActor in + Task { logger.info("Stopping polling timer.") pollingTimer?.invalidate() pollingTimer = nil @@ -100,30 +114,27 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } public func invalidate() { + logger.debug("Invalidating.") invalidated = true resetWebSocket() } - public func connect() { - // 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 - reconnectWebSocket() - } - 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() @@ -131,6 +142,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } public func resetWebSocket() { + logger.debug("Resetting web socket...") webSocketTask?.cancel() webSocketUrlSession = nil webSocketTask = nil @@ -142,7 +154,13 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } private func configureNotifyPush() async { - guard !invalidated else { return } + 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(), @@ -187,69 +205,12 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess logger.info("Successfully configured push notifications for \(account.ncKitAccount)", [.account: account.ncKitAccount]) } - public func authenticationChallenge( - _: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - 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) - } + func incrementWebSocketPingFailCount() { + webSocketPingFailCount += 1 } - public func urlSession( - _: URLSession, - webSocketTask _: URLSessionWebSocketTask, - didOpenWithProtocol _: String? - ) { - guard !invalidated else { return } - logger.debug("Websocket connected \(account.ncKitAccount) sending auth details", [.account: account.ncKitAccount]) - Task { await authenticateWebSocket() } - } - - public func urlSession( - _: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith _: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - guard !invalidated 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 for \(account.ncKitAccount).", [.account: account.ncKitAccount]) - - if let reason { - logger.debug("Reason: \(String(data: reason, encoding: .utf8) ?? "")") - } - - logger.debug("Retrying websocket connection for \(account.ncKitAccount).", [.account: account.ncKitAccount]) - reconnectWebSocket() + func setNetworkReachability(_ typeReachability: NKTypeReachability) { + networkReachability = typeReachability } private func authenticateWebSocket() async { @@ -268,64 +229,101 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } private func startNewWebSocketPingTask() { - guard !Task.isCancelled, !invalidated else { return } + 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: self.account.ncKitAccount, .error: error]) + self.logger.error("Could not sleep websocket ping.", [.account: account, .error: error]) + } + + guard !Task.isCancelled else { + return + } + + Task { + self.pingWebSocket() } - guard !Task.isCancelled else { return } - self.pingWebSocket() } } private func pingWebSocket() { // Keep the socket connection alive - guard !invalidated else { return } + guard !invalidated else { + return + } + guard networkReachability != .notReachable else { logger.error("Not pinging because network is unreachable.", [.account: account.ncKitAccount]) return } - webSocketTask?.sendPing { [weak self] error in - guard let self, !self.invalidated else { return } - guard error == nil else { - logger.error("Websocket ping failed.", [.error: error]) - webSocketPingFailCount += 1 - if webSocketPingFailCount > webSocketPingFailLimit { - Task.detached(priority: .medium) { self.reconnectWebSocket() } - } else { - startNewWebSocketPingTask() + webSocketTask?.sendPing { error in + Task { [weak self] in + guard let self else { + return } - return - } - startNewWebSocketPingTask() + 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 } + guard !invalidated else { + return + } + webSocketTask?.receive { result in - switch result { - case .failure: - self.logger.debug("Failed to read websocket \(self.account.ncKitAccount)", [.account: self.account.ncKitAccount]) - // Do not reconnect here, delegate methods will handle reconnecting - case let .success(message): - switch message { - case let .data(data): - self.processWebsocket(data: data) - case let .string(string): - self.processWebsocket(string: string) - @unknown default: - self.logger.error("Unknown case encountered while reading websocket!") - } - self.readWebSocket() + 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() + } } } } @@ -368,15 +366,98 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } } - // MARK: - NextcloudKitDelegate methods + func replaceAccount(with account: Account) { + self.account = account + } + + func setWebSocketPingInterval(to nanoseconds: UInt64) { + webSocketPingIntervalNanoseconds = nanoseconds + } +} - public func networkReachabilityObserver(_ typeReachability: NKTypeReachability) { - networkReachability = typeReachability +// 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 func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) {} + public nonisolated func networkReachabilityObserver(_ typeReachability: NKTypeReachability) { + Task { [weak self] in + guard let self else { + return + } - public func downloadProgress( + setNetworkReachability(typeReachability) + } + } + + public nonisolated func downloadProgress( _: Float, totalBytes _: Int64, totalBytesExpected _: Int64, @@ -386,7 +467,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess task _: URLSessionTask ) {} - public func uploadProgress( + public nonisolated func uploadProgress( _: Float, totalBytes _: Int64, totalBytesExpected _: Int64, @@ -396,13 +477,13 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess task _: URLSessionTask ) {} - public func downloadingFinish( + public nonisolated func downloadingFinish( _: URLSession, downloadTask _: URLSessionDownloadTask, didFinishDownloadingTo _: URL ) {} - public func downloadComplete( + public nonisolated func downloadComplete( fileName _: String, serverUrl _: String, etag _: String?, @@ -413,7 +494,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess error _: NKError ) {} - public func uploadComplete( + public nonisolated func uploadComplete( fileName _: String, serverUrl _: String, ocId _: String?, @@ -424,9 +505,7 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess error _: NKError ) {} - public func request( - _: Alamofire.DataRequest, didParseResponse _: Alamofire.AFDataResponse - ) {} + public nonisolated func request(_: Alamofire.DataRequest, didParseResponse _: Alamofire.AFDataResponse) {} /// /// Dispatches the asynchronous working set check. @@ -434,8 +513,12 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess /// - Parameters: /// - completionHandler: An optional closure to call after the working set check completed. /// - func startWorkingSetCheck(completionHandler: (() -> Void)? = nil) { - guard !workingSetCheckOngoing, !invalidated else { return } + 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?() @@ -443,9 +526,11 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } private func checkWorkingSet() async { + logger.debug("Checking working set...") workingSetCheckOngoing = true defer { + logger.debug("Working set check no longer ongoing.") workingSetCheckOngoing = false } @@ -578,15 +663,13 @@ public final class RemoteChangeObserver: NSObject, NextcloudKitDelegate, URLSess } allDeletedMetadatas = checkedDeletedMetadatas - let task = Task { @MainActor in - for deletedMetadata in allDeletedMetadatas { - var deleteMarked = deletedMetadata - deleteMarked.deleted = true - deleteMarked.syncTime = Date() - dbManager.addItemMetadata(deleteMarked) - } + + for deletedMetadata in allDeletedMetadatas { + var deleteMarked = deletedMetadata + deleteMarked.deleted = true + deleteMarked.syncTime = Date() + dbManager.addItemMetadata(deleteMarked) } - _ = await task.result logger.info("Finished change enumeration of working set. Examined item IDs: \(examinedItemIds), materialized item IDs: \(materialisedItems.map(\.ocId))") diff --git a/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift b/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift index a384d2d5..2ce0d85d 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainLogDirectory.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation public extension FileManager { diff --git a/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift b/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift index c56a8ab4..3e3275a5 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/FileManager+FileProviderDomainSupportDirectory.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation public extension FileManager { diff --git a/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift index 58e447a5..8bc58b8a 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/NKError+Extensions.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift index 66b1a18f..73ff8e4b 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/NKFile+Extensions.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudKit import RealmSwift diff --git a/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift index a66a5240..e9ad7658 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/NKRequestOptions+Extensions.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit extension NKRequestOptions { diff --git a/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift index 65d52244..19ca9c68 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/NKTrash+Extensions.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift index 206ca355..189af09e 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/Progress+Extensions.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation diff --git a/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift index 6a26f6d3..ef3b1fbf 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/RandomAccessCollection+Extensions.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation @@ -31,33 +31,34 @@ extension RandomAccessCollection { } /// Performs an asynchronous `forEach` operation on the collection in concurrent chunks. - func concurrentChunkedForEach( - into size: Int = defaultChunkSize, operation: @escaping (Element) async -> Void - ) async { + 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) { - group.addTask { - for element in chunk { + 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 (Element) throws -> T? - ) async throws -> [T] { + 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) { - group.addTask { - try chunk.compactMap { try transform($0) } - } + 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 { diff --git a/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift index ba8c8cdc..19dd9b94 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/Results+Extensions.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Realm import RealmSwift diff --git a/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift index 405a3418..5aef53ef 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/Substring+Extensions.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later public extension Substring { func toString() -> String { String(self) } diff --git a/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift b/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift index 906f7ddb..53578866 100644 --- a/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift +++ b/Sources/NextcloudFileProviderKit/Extensions/URL+Extensions.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation diff --git a/Sources/NextcloudFileProviderKit/Interface/AuthenticationAttemptResultState.swift b/Sources/NextcloudFileProviderKit/Interface/AuthenticationAttemptResultState.swift new file mode 100644 index 00000000..3d4b48be --- /dev/null +++ b/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/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift b/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift index 5cf59d1e..4961c8d7 100644 --- a/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/ChangeNotificationInterface.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation diff --git a/Sources/NextcloudFileProviderKit/Interface/EnumerateDepth.swift b/Sources/NextcloudFileProviderKit/Interface/EnumerateDepth.swift new file mode 100644 index 00000000..516042ef --- /dev/null +++ b/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/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift b/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift index 74a0be9e..66497e32 100644 --- a/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/FileProviderChangeNotificationInterface.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later @preconcurrency import FileProvider import Foundation diff --git a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift index 3d5aacfc..6e89fa53 100644 --- a/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/NextcloudKit+RemoteInterface.swift @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudCapabilitiesKit import NextcloudKit @@ -221,46 +221,6 @@ extension NextcloudKit: RemoteInterface { } } - public func downloadAsync( - remotePath: String, - localPath: String, - account: Account, - options: NKRequestOptions = .init(), - requestHandler: @escaping (DownloadRequest) -> Void = { _ in }, - taskHandler: @escaping (URLSessionTask) -> Void = { _ in }, - progressHandler: @escaping (Progress) -> Void = { _ in } - ) async -> ( - account: String, - etag: String?, - date: Date?, - length: Int64, - headers: [AnyHashable: any Sendable]?, - afError: AFError?, - nkError: NKError - ) { - await withCheckedContinuation { continuation in - download( - serverUrlFileName: remotePath, - fileNameLocalPath: localPath, - account: account.ncKitAccount, - options: options, - requestHandler: requestHandler, - taskHandler: taskHandler, - progressHandler: progressHandler - ) { account, etag, date, length, headers, afError, nkError in - continuation.resume(returning: ( - account, - etag, - date, - length, - headers, - afError, - nkError - )) - } - } - } - public func enumerate( remotePath: String, depth: EnumerateDepth, @@ -308,20 +268,6 @@ extension NextcloudKit: RemoteInterface { try await lockUnlockFile(serverUrlFileName: serverUrlFileName, type: type, shouldLock: shouldLock, account: account.ncKitAccount, options: options, taskHandler: taskHandler) } - public func trashedItems( - account: Account, - options _: NKRequestOptions = .init(), - taskHandler _: @escaping (URLSessionTask) -> Void - ) async -> (account: String, trashedItems: [NKTrash], data: Data?, error: NKError) { - await withCheckedContinuation { continuation in - listingTrash( - showHiddenFiles: true, account: account.ncKitAccount - ) { account, items, data, error in - continuation.resume(returning: (account, items ?? [], data?.data, error)) - } - } - } - public func restoreFromTrash( filename: String, account: Account, @@ -385,20 +331,6 @@ extension NextcloudKit: RemoteInterface { return result } - public func fetchUserProfile( - account: Account, - options: NKRequestOptions = .init(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, userProfile: NKUserProfile?, data: Data?, error: NKError) { - await withCheckedContinuation { continuation in - getUserProfile( - account: account.ncKitAccount, options: options, taskHandler: taskHandler - ) { account, userProfile, data, error in - continuation.resume(returning: (account, userProfile, data?.data, error)) - } - } - } - public func tryAuthenticationAttempt( account: Account, options _: NKRequestOptions = .init(), diff --git a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift index f14e54f9..85223751 100644 --- a/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift +++ b/Sources/NextcloudFileProviderKit/Interface/RemoteInterface.swift @@ -1,22 +1,12 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudCapabilitiesKit import NextcloudKit -public enum EnumerateDepth: String { - case target = "0" - case targetAndDirectChildren = "1" - case targetAndAllChildren = "infinity" -} - -public enum AuthenticationAttemptResultState: Int { - case authenticationError, connectionError, success -} - /// /// Abstraction of the Nextcloud server APIs to call from the file provider extension. /// @@ -30,7 +20,7 @@ public protocol RemoteInterface: Sendable { remotePath: String, account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, ocId: String?, date: NSDate?, error: NKError) func upload( @@ -41,7 +31,7 @@ public protocol RemoteInterface: Sendable { account: Account, options: NKRequestOptions, requestHandler: @escaping (_ request: UploadRequest) -> Void, - taskHandler: @escaping (_ task: URLSessionTask) -> Void, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void, progressHandler: @escaping (_ progress: Progress) -> Void ) async -> ( account: String, @@ -68,7 +58,7 @@ public protocol RemoteInterface: Sendable { log: any FileProviderLogging, chunkUploadStartHandler: @escaping (_ filesChunk: [RemoteFileChunk]) -> Void, requestHandler: @escaping (_ request: UploadRequest) -> Void, - taskHandler: @escaping (_ task: URLSessionTask) -> Void, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void, progressHandler: @escaping (Progress) -> Void, chunkUploadCompleteHandler: @escaping (_ fileChunk: RemoteFileChunk) -> Void ) async -> ( @@ -84,7 +74,7 @@ public protocol RemoteInterface: Sendable { overwrite: Bool, account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, data: Data?, error: NKError) func downloadAsync( @@ -93,7 +83,7 @@ public protocol RemoteInterface: Sendable { account: String, options: NKRequestOptions, requestHandler: @escaping (_ request: DownloadRequest) -> Void, - taskHandler: @escaping (_ task: URLSessionTask) -> Void, + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void, progressHandler: @escaping (_ progress: Progress) -> Void ) async -> ( account: String, @@ -113,54 +103,66 @@ public protocol RemoteInterface: Sendable { requestBody: Data?, account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, files: [NKFile], data: AFDataResponse?, error: NKError) func delete( remotePath: String, account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + 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: @escaping (_ task: URLSessionTask) -> Void) async throws -> NKLock? + func lockUnlockFile(serverUrlFileName: String, type: NKLockType?, shouldLock: Bool, account: Account, options: NKRequestOptions, taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void) async throws -> NKLock? - func trashedItems( - account: Account, + func listingTrashAsync( + filename: String?, + showHiddenFiles: Bool, + account: String, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void - ) async -> (account: String, trashedItems: [NKTrash], data: Data?, error: NKError) + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + items: [NKTrash]?, + responseData: AFDataResponse?, + error: NKError + ) func restoreFromTrash( filename: String, account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, data: Data?, error: NKError) func downloadThumbnail( url: URL, account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, data: Data?, error: NKError) func fetchCapabilities( account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - func fetchUserProfile( - account: Account, + func getUserProfileAsync( + account: String, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void - ) async -> (account: String, userProfile: NKUserProfile?, data: Data?, error: NKError) + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void + ) async -> ( + account: String, + userProfile: NKUserProfile?, + responseData: AFDataResponse?, + error: NKError + ) func tryAuthenticationAttempt( account: Account, options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void ) async -> AuthenticationAttemptResultState } @@ -168,24 +170,23 @@ public extension RemoteInterface { func currentCapabilities( account: Account, options: NKRequestOptions = .init(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + 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.data[ncKitAccount], - lastRetrieval.retrievedAt.timeIntervalSince(Date()) > -CapabilitiesFetchInterval + + 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 await fetchCapabilities(account: account, options: options, taskHandler: taskHandler) } + return (account.ncKitAccount, lastRetrieval.capabilities, nil, .success) } func supportsTrash( account: Account, options _: NKRequestOptions = .init(), - taskHandler _: @escaping (_ task: URLSessionTask) -> Void = { _ in } + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> Bool { var remoteSupportsTrash = false diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift index 1847fd92..05f6ae3e 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Create.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Create.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudCapabilitiesKit import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift b/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift index 88e1426d..e955b709 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Delete.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudCapabilitiesKit import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift b/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift index cb6ce2fc..0c62ce67 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Fetch.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudKit @@ -63,6 +63,8 @@ public extension Item { attributes: nil ) } else { + let identifier = NSFileProviderItemIdentifier(metadata.ocId) + let (_, _, _, _, _, _, error) = await remoteInterface.downloadAsync( serverUrlFileName: remotePath, fileNameLocalPath: childLocalPath, @@ -71,12 +73,7 @@ public extension Item { requestHandler: { progress.setHandlersFromAfRequest($0) }, taskHandler: { task in if let domain { - NSFileProviderManager(for: domain)?.register( - task, - forItemWithIdentifier: - NSFileProviderItemIdentifier(metadata.ocId), - completionHandler: { _ in } - ) + NSFileProviderManager(for: domain)?.register(task, forItemWithIdentifier: identifier, completionHandler: { _ in }) } }, progressHandler: { _ in } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift index f69c0f3a..8f30b6d7 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Ignored.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit extension Item { diff --git a/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift b/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift index 5fedff3b..4c8d13a4 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+KeepDownloaded.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider public extension Item { func toggle(keepDownloadedIn domain: NSFileProviderDomain) async throws { diff --git a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift index 8c67f8b6..8c5b993c 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+LockFile.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudCapabilitiesKit import NextcloudKit @@ -73,7 +73,7 @@ extension Item { 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, .name: itemTemplate.filename]) + 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) diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift index 2d1536a8..749aadf6 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Modify.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift index 30c28558..63cc4c01 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Trash.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit extension Item { @@ -44,8 +44,10 @@ extension Item { ) // The server may have renamed the trashed file so we need to scan the entire trash - let (_, files, _, error) = await modifiedItem.remoteInterface.trashedItems( - account: account, + let (_, files, _, error) = await modifiedItem.remoteInterface.listingTrashAsync( + filename: nil, + showHiddenFiles: true, + account: account.ncKitAccount, options: .init(), taskHandler: { task in if let domain { @@ -59,16 +61,12 @@ extension Item { ) guard error == .success else { - logger.error( - """ - Received bad error from post-trashing remote scan: - \(error.errorDescription) \(files) - """ - ) + logger.error("Received error from post-trashing remote scan.", [.error: error]) + return (dirtyItem, error.fileProviderError) } - guard let targetItemNKTrash = files.first( + 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 || @@ -76,17 +74,8 @@ extension Item { } ) else { - logger.error( - """ - Did not find trashed item: - \(modifiedItem.filename) - \(modifiedItem.itemIdentifier.rawValue) - in trash. Asking for a rescan. Found trashed files were: - \(files.map { - ($0.ocId, $0.fileId, $0.fileName, $0.trashbinFileName) - }) - """ - ) + 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 { @@ -129,12 +118,7 @@ extension Item { ) guard error == .success else { - logger.error( - """ - Received bad error or files from post-trashing child items remote scan: - \(error.errorDescription) \(files) - """ - ) + logger.error("Received error or files from post-trashing child items remote scan.", [.error: error]) return (postDeleteItem, childError.fileProviderError) } diff --git a/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift b/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift index 490e5d18..4ebe872a 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item+Unuploaded.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider extension Item { // Creates a file that was previously unuploaded (e.g. a previously ignored/lock file) on server diff --git a/Sources/NextcloudFileProviderKit/Item/Item.swift b/Sources/NextcloudFileProviderKit/Item/Item.swift index dcfda6e4..c96a0e88 100644 --- a/Sources/NextcloudFileProviderKit/Item/Item.swift +++ b/Sources/NextcloudFileProviderKit/Item/Item.swift @@ -1,14 +1,14 @@ // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import NextcloudKit import UniformTypeIdentifiers /// /// Data model implementation for file provider items as defined by the file provider framework and `NSFileProviderItemProtocol`. /// -public class Item: NSObject, NSFileProviderItem { +public final class Item: NSObject, NSFileProviderItem, Sendable { public enum FileProviderItemTransferError: Error { case downloadError case uploadError diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift index 505b523b..48715468 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift @@ -1,4 +1,7 @@ -import FileProvider +// 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 @@ -209,7 +212,7 @@ public actor FileProviderLog: FileProviderLogging { } } - private func writeToUnifiedLoggingSystem(level: OSLogType, message: String, details: [FileProviderLogDetailKey: Any?]) { + private func writeToUnifiedLoggingSystem(level: OSLogType, message: String, details: [FileProviderLogDetailKey: (any Sendable)?]) { if details.isEmpty { logger.log(level: level, "\(message, privacy: .public)") return @@ -249,7 +252,7 @@ public actor FileProviderLog: FileProviderLogging { 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?]) { + public func write(category: String, level: OSLogType, message: String, details: [FileProviderLogDetailKey: (any Sendable)?]) { #if DEBUG writeToUnifiedLoggingSystem(level: level, message: message, details: details) diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift index bbee067c..265e4cfc 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetail.swift @@ -1,4 +1,7 @@ -import FileProvider +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +@preconcurrency import FileProvider import Foundation import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift index 9bab1db9..caa657d1 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLogDetailKey.swift @@ -1,7 +1,10 @@ +// 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 { +public enum FileProviderLogDetailKey: String, Sendable { /// /// The identifier for an account. /// diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift index ac890714..22f8e17e 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLogMessage.swift @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + import Foundation /// diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift index c914a1d6..72c939fd 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLogger.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import os @@ -40,7 +40,7 @@ public struct FileProviderLogger: Sendable { /// - message: The main text message of the entry in the logs. /// - details: Additional contextual data. /// - public func debug(_ message: String, _ details: [FileProviderLogDetailKey: Any?] = [:]) { + public func debug(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { Task { await log.write(category: category, level: .debug, message: message, details: details) } @@ -53,7 +53,7 @@ public struct FileProviderLogger: Sendable { /// - message: The main text message of the entry in the logs. /// - details: Additional contextual data. /// - public func info(_ message: String, _ details: [FileProviderLogDetailKey: Any?] = [:]) { + public func info(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { Task { await log.write(category: category, level: .info, message: message, details: details) } @@ -66,7 +66,7 @@ public struct FileProviderLogger: Sendable { /// - message: The main text message of the entry in the logs. /// - details: Additional contextual data. /// - public func error(_ message: String, _ details: [FileProviderLogDetailKey: Any?] = [:]) { + public func error(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { Task { await log.write(category: category, level: .error, message: message, details: details) } @@ -79,7 +79,7 @@ public struct FileProviderLogger: Sendable { /// - message: The main text message of the entry in the logs. /// - details: Additional contextual data. /// - public func fault(_ message: String, _ details: [FileProviderLogDetailKey: Any?] = [:]) { + public func fault(_ message: String, _ details: [FileProviderLogDetailKey: (any Sendable)?] = [:]) { Task { await log.write(category: category, level: .fault, message: message, details: details) } diff --git a/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift b/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift index 39f1818b..7870750a 100644 --- a/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift +++ b/Sources/NextcloudFileProviderKit/Log/FileProviderLogging.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import os @@ -12,5 +12,5 @@ public protocol FileProviderLogging: Actor { /// /// 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?]) + func write(category: String, level: OSLogType, message: String, details: [FileProviderLogDetailKey: (any Sendable)?]) } diff --git a/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift b/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift index e0c27a26..bf702103 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation /// diff --git a/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift b/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift index 4db7fbfc..dd63bb52 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import RealmSwift @@ -62,7 +62,7 @@ class RealmItemMetadata: Object, ItemMetadata { @Persisted var sessionTaskIdentifier: Int? @Persisted var storedShareType = List() var shareType: [Int] { - get { storedShareType.map { $0 } } + get { storedShareType.map(\.self) } set { storedShareType = List() storedShareType.append(objectsIn: newValue) @@ -73,7 +73,7 @@ class RealmItemMetadata: Object, ItemMetadata { // TODO: Find a way to compare these two below in remote state check @Persisted var storedSharePermissionsCloudMesh = List() var sharePermissionsCloudMesh: [String] { - get { storedSharePermissionsCloudMesh.map { $0 } } + get { storedSharePermissionsCloudMesh.map(\.self) } set { storedSharePermissionsCloudMesh = List() storedSharePermissionsCloudMesh.append(objectsIn: newValue) @@ -84,7 +84,7 @@ class RealmItemMetadata: Object, ItemMetadata { @Persisted var status: Int = 0 @Persisted var storedTags = List() var tags: [String] { - get { storedTags.map { $0 } } + get { storedTags.map(\.self) } set { storedTags = List() storedTags.append(objectsIn: newValue) diff --git a/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift b/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift index eca4e8bc..50836365 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/RemoteFileChunk.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import NextcloudKit diff --git a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift index fa25e47a..24da9e1b 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata+Array.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation diff --git a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift index 1afc9c57..de81f4d4 100644 --- a/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift +++ b/Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation diff --git a/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift b/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift index 03afdc70..33ea02bd 100644 --- a/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift +++ b/Sources/NextcloudFileProviderKit/Sharing/ShareType.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later public enum ShareType: Int { case user = 0 diff --git a/Sources/NextcloudFileProviderKit/Utilities/Account.swift b/Sources/NextcloudFileProviderKit/Utilities/Account.swift index 769f3cbd..6f891465 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/Account.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/Account.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation diff --git a/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift b/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift index ebf6f906..40611c32 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/IgnoredFilesMatcher.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation diff --git a/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift b/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift index 86cb3f5e..6f81ec6c 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/LocalFiles.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import OSLog diff --git a/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift index 23c14802..643696e4 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/RetrievedCapabilitiesActor.swift @@ -1,27 +1,24 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import NextcloudCapabilitiesKit let CapabilitiesFetchInterval: TimeInterval = 30 * 60 // 30mins -actor RetrievedCapabilitiesActor { - static let shared: RetrievedCapabilitiesActor = { - let instance = RetrievedCapabilitiesActor() - return instance - }() +actor RetrievedCapabilitiesActor: Sendable { + static let shared = RetrievedCapabilitiesActor() var ongoingFetches: Set = [] - var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] + private var data: [String: (capabilities: Capabilities, retrievedAt: Date)] = [:] private var ongoingFetchContinuations: [String: [CheckedContinuation]] = [:] - func setCapabilities( - forAccount account: String, - capabilities: Capabilities, - retrievedAt: Date = Date() - ) { + 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) } diff --git a/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift b/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift index f05921bc..e941222f 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/ThumbnailFetching.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudKit @@ -16,7 +16,7 @@ public func fetchThumbnails( account: Account, usingRemoteInterface remoteInterface: RemoteInterface, andDatabase dbManager: FilesDatabaseManager, - perThumbnailCompletionHandler: @escaping ( + perThumbnailCompletionHandler: @Sendable @escaping ( NSFileProviderItemIdentifier, Data?, Error? diff --git a/Sources/NextcloudFileProviderKit/Utilities/Upload.swift b/Sources/NextcloudFileProviderKit/Utilities/Upload.swift index 985dc284..bd5d7e65 100644 --- a/Sources/NextcloudFileProviderKit/Utilities/Upload.swift +++ b/Sources/NextcloudFileProviderKit/Utilities/Upload.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation @@ -22,7 +22,7 @@ func upload( options: NKRequestOptions = .init(queue: .global(qos: .utility)), log: any FileProviderLogging, requestHandler: @escaping (UploadRequest) -> Void = { _ in }, - taskHandler: @escaping (URLSessionTask) -> Void = { _ in }, + taskHandler: @Sendable @escaping (URLSessionTask) -> Void = { _ in }, progressHandler: @escaping (Progress) -> Void = { _ in }, chunkUploadCompleteHandler: @escaping (_ fileChunk: RemoteFileChunk) -> Void = { _ in } ) async -> ( @@ -43,9 +43,9 @@ func upload( uploadLogger.info("Using provided chunkSize: \(chunkSize)") return chunkSize } - let (_, capabilities, _, error) = await remoteInterface.currentCapabilities( - account: account, options: options, taskHandler: taskHandler - ) + + let (_, capabilities, _, error) = await remoteInterface.currentCapabilities(account: account, options: options, taskHandler: taskHandler) + guard error == .success, let capabilities, let serverChunkSize = capabilities.files?.chunkedUpload?.maxChunkSize, diff --git a/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift b/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift index 13ca1a91..65124516 100644 --- a/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift +++ b/Sources/NextcloudFileProviderKitMocks/FileProviderLogMock.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import NextcloudFileProviderKit @@ -12,7 +12,7 @@ public actor FileProviderLogMock: FileProviderLogging { logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FileProviderLogMock") } - public func write(category _: String, level _: OSLogType, message: String, details _: [FileProviderLogDetailKey: Any?]) { + public func write(category _: String, level _: OSLogType, message: String, details _: [FileProviderLogDetailKey: (any Sendable)?]) { logger.debug("\(message, privacy: .public)") } } diff --git a/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift b/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift index 9b2c3705..708517fb 100644 --- a/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift +++ b/Sources/NextcloudFileProviderKitMocks/TestableRemoteInterface.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation @@ -8,7 +8,13 @@ import NextcloudCapabilitiesKit import NextcloudKit public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { - public init() {} + 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) {} @@ -29,7 +35,7 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { account _: Account, options _: NKRequestOptions, requestHandler _: @escaping (UploadRequest) -> Void, - taskHandler _: @escaping (URLSessionTask) -> Void, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void, progressHandler _: @escaping (Progress) -> Void ) async -> ( account: String, @@ -56,7 +62,7 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { log _: any FileProviderLogging, chunkUploadStartHandler _: @escaping ([RemoteFileChunk]) -> Void, requestHandler _: @escaping (UploadRequest) -> Void, - taskHandler _: @escaping (URLSessionTask) -> Void, + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void, progressHandler _: @escaping (Progress) -> Void, chunkUploadCompleteHandler _: @escaping (RemoteFileChunk) -> Void ) async -> ( @@ -74,7 +80,7 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { overwrite _: Bool, account _: Account, options _: NKRequestOptions, - taskHandler _: @escaping (URLSessionTask) -> Void + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } public func downloadAsync( @@ -83,7 +89,7 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { account _: String, options _: NKRequestOptions, requestHandler _: @escaping (_ request: DownloadRequest) -> Void, - taskHandler _: @escaping (_ task: URLSessionTask) -> Void, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void, progressHandler _: @escaping (_ progress: Progress) -> Void ) async -> ( account: String, @@ -105,7 +111,7 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { requestBody _: Data?, account _: Account, options _: NKRequestOptions, - taskHandler _: @escaping (URLSessionTask) -> Void + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void ) async -> (account: String, files: [NKFile], data: AFDataResponse?, error: NKError) { ("", [], nil, .invalidResponseError) } @@ -114,18 +120,27 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { remotePath _: String, account _: Account, options _: NKRequestOptions, - taskHandler _: @escaping (URLSessionTask) -> Void + 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 _: @escaping (URLSessionTask) -> Void) async throws -> NKLock? { + 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 trashedItems( - account _: Account, options _: NKRequestOptions, taskHandler _: @escaping (URLSessionTask) -> Void - ) async -> (account: String, trashedItems: [NKTrash], data: Data?, error: NKError) { + 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) } @@ -133,35 +148,39 @@ public struct TestableRemoteInterface: RemoteInterface, @unchecked Sendable { filename _: String, account _: Account, options _: NKRequestOptions, - taskHandler _: @escaping (URLSessionTask) -> Void + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } public func downloadThumbnail( url _: URL, account _: Account, options _: NKRequestOptions, - taskHandler _: @escaping (URLSessionTask) -> Void + taskHandler _: @Sendable @escaping (URLSessionTask) -> Void ) async -> (account: String, data: Data?, error: NKError) { ("", nil, .invalidResponseError) } - public func fetchUserProfile( - account _: Account, options _: NKRequestOptions, taskHandler _: @escaping (URLSessionTask) -> Void - ) async -> (account: String, userProfile: NKUserProfile?, data: Data?, error: NKError) { + 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 _: @escaping (URLSessionTask) -> Void + account _: Account, options _: NKRequestOptions, taskHandler _: @Sendable @escaping (URLSessionTask) -> Void ) async -> AuthenticationAttemptResultState { .connectionError } public typealias FetchResult = (account: String, capabilities: Capabilities?, data: Data?, error: NKError) - public var fetchCapabilitiesHandler: - ((Account, NKRequestOptions, @escaping (URLSessionTask) -> Void) async -> FetchResult)? - public func fetchCapabilities( account: Account, options: NKRequestOptions = .init(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + taskHandler: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> FetchResult { let ncKitAccount = account.ncKitAccount await RetrievedCapabilitiesActor.shared.setOngoingFetch( diff --git a/Tests/Interface/Item+Init.swift b/Tests/Interface/Item+Init.swift index 8cf9fb37..eb2f1542 100644 --- a/Tests/Interface/Item+Init.swift +++ b/Tests/Interface/Item+Init.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudFileProviderKit import NextcloudFileProviderKitMocks diff --git a/Tests/Interface/ItemMetadata+Init.swift b/Tests/Interface/ItemMetadata+Init.swift index 536612c0..f12ca385 100644 --- a/Tests/Interface/ItemMetadata+Init.swift +++ b/Tests/Interface/ItemMetadata+Init.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import NextcloudFileProviderKit diff --git a/Tests/Interface/MockChangeNotificationInterface.swift b/Tests/Interface/MockChangeNotificationInterface.swift index fe605aa8..e5c5afa3 100644 --- a/Tests/Interface/MockChangeNotificationInterface.swift +++ b/Tests/Interface/MockChangeNotificationInterface.swift @@ -1,12 +1,15 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation import NextcloudFileProviderKit -public class MockChangeNotificationInterface: ChangeNotificationInterface, @unchecked Sendable { - public var changeHandler: (() -> Void)? - public init(changeHandler: (() -> Void)? = nil) { +public final class MockChangeNotificationInterface: ChangeNotificationInterface { + public typealias ChangeHandler = @Sendable () -> Void + + let changeHandler: ChangeHandler? + + public init(changeHandler: ChangeHandler? = nil) { self.changeHandler = changeHandler } diff --git a/Tests/Interface/MockChangeObserver.swift b/Tests/Interface/MockChangeObserver.swift index 83a23896..3581a95c 100644 --- a/Tests/Interface/MockChangeObserver.swift +++ b/Tests/Interface/MockChangeObserver.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation public class MockChangeObserver: NSObject, NSFileProviderChangeObserver { diff --git a/Tests/Interface/MockEnumerationObserver.swift b/Tests/Interface/MockEnumerationObserver.swift index 43c13936..3f07453f 100644 --- a/Tests/Interface/MockEnumerationObserver.swift +++ b/Tests/Interface/MockEnumerationObserver.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation public class MockEnumerationObserver: NSObject, NSFileProviderEnumerationObserver { diff --git a/Tests/Interface/MockEnumerator.swift b/Tests/Interface/MockEnumerator.swift index 34abfd0c..67aadd9b 100644 --- a/Tests/Interface/MockEnumerator.swift +++ b/Tests/Interface/MockEnumerator.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudFileProviderKit import NextcloudFileProviderKitMocks diff --git a/Tests/Interface/MockNotifyPushServer.swift b/Tests/Interface/MockNotifyPushServer.swift index e05f1c71..bf17ffa3 100644 --- a/Tests/Interface/MockNotifyPushServer.swift +++ b/Tests/Interface/MockNotifyPushServer.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import NIOCore import NIOHTTP1 @@ -60,10 +60,7 @@ public class MockNotifyPushServer: @unchecked Sendable { 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 + .bind(host: host, port: port) { channel in channel.eventLoop.makeCompletedFuture { let upgrader = NIOTypedWebSocketServerUpgrader( shouldUpgrade: { channel, _ in diff --git a/Tests/Interface/MockRemoteInterface.swift b/Tests/Interface/MockRemoteInterface.swift index 28f4f5a2..d93f0e19 100644 --- a/Tests/Interface/MockRemoteInterface.swift +++ b/Tests/Interface/MockRemoteInterface.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation @@ -576,10 +576,12 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { 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 @@ -621,12 +623,16 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { return sanitisedPath } - func item(remotePath: String, account: Account) -> MockRemoteItem? { + 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 { @@ -666,7 +672,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { func parentItem(path: String, account: Account) -> MockRemoteItem? { let parentRemotePath = parentPath(path: path, account: account) - return item(remotePath: parentRemotePath, account: account) + return item(remotePath: parentRemotePath, account: account.ncKitAccount) } func randomIdentifier() -> String { @@ -908,7 +914,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { return (account.ncKitAccount, nil, .urlError) } - guard let sourceItem = item(remotePath: remotePathSource, account: account) else { + 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) } @@ -944,7 +950,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { } let matchingNameChildCount = - destinationParent.children.filter { $0.name == sourceItem.name }.count + destinationParent.children.count(where: { $0.name == sourceItem.name }) if !overwrite, matchingNameChildCount > 0 { sourceItem.name += " (\(matchingNameChildCount))" @@ -998,7 +1004,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { account: String, options _: NKRequestOptions, requestHandler _: @escaping (_ request: DownloadRequest) -> Void = { _ in }, - taskHandler _: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + taskHandler _: @Sendable @escaping (_ task: URLSessionTask) -> Void = { _ in }, progressHandler _: @escaping (_ progress: Progress) -> Void = { _ in } ) async -> ( account: String, @@ -1017,7 +1023,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { return (account, nil, nil, 0, nil, nil, .urlError) } - guard let item = item(remotePath: serverUrlFileName, account: account) else { + guard let item = item(remotePath: serverUrlFileName, account: account.ncKitAccount) else { return (account.ncKitAccount, nil, nil, 0, nil, nil, .urlError) } @@ -1073,7 +1079,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { // 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) else { + guard let item = item(remotePath: remotePath, account: account.ncKitAccount) else { print("Item at \(remotePath) not found.") return ( account.ncKitAccount, @@ -1166,7 +1172,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { options _: NKRequestOptions = .init(), taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } ) async -> (account: String, response: HTTPURLResponse?, error: NKError) { - guard let item = item(remotePath: remotePath, account: account) else { + guard let item = item(remotePath: remotePath, account: account.ncKitAccount) else { return (account.ncKitAccount, nil, .urlError) } @@ -1185,7 +1191,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { } 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) else { + guard let item = item(remotePath: serverUrlFileName, account: account.ncKitAccount) else { throw NKError.urlError } @@ -1194,13 +1200,23 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { return nil } - public func trashedItems( - account: Account, - options _: NKRequestOptions = .init(), - taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } - ) async -> (account: String, trashedItems: [NKTrash], data: Data?, error: NKError) { - guard let rootTrashItem else { return (account.ncKitAccount, [], nil, .invalidData) } - return (account.ncKitAccount, rootTrashItem.children.map { $0.toNKTrash() }, nil, .success) + 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( @@ -1247,16 +1263,26 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable { return Capabilities(data: capsData ?? Data()) } - public func fetchUserProfile( - account: Account, - options _: NKRequestOptions = .init(), - taskHandler _: @escaping (URLSessionTask) -> Void = { _ in } - ) async -> (account: String, userProfile: NKUserProfile?, data: Data?, error: NKError) { + 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) } diff --git a/Tests/Interface/MockRemoteItem.swift b/Tests/Interface/MockRemoteItem.swift index 61de7a3d..98ff24f1 100644 --- a/Tests/Interface/MockRemoteItem.swift +++ b/Tests/Interface/MockRemoteItem.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudFileProviderKit import NextcloudKit diff --git a/Tests/InterfaceTests/MockRemoteInterfaceTests.swift b/Tests/InterfaceTests/MockRemoteInterfaceTests.swift index 1b55a80d..2a74dc7f 100644 --- a/Tests/InterfaceTests/MockRemoteInterfaceTests.swift +++ b/Tests/InterfaceTests/MockRemoteInterfaceTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later @testable import NextcloudFileProviderKit @testable import NextcloudFileProviderKitMocks @@ -20,7 +20,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testItemForRemotePath() { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -76,21 +76,21 @@ final class MockRemoteInterfaceTests: XCTestCase { XCTAssertEqual( remoteInterface.item( - remotePath: Self.account.davFilesUrl + "/a/b/target", account: Self.account + remotePath: Self.account.davFilesUrl + "/a/b/target", account: Self.account.ncKitAccount ), targetItem ) } func testItemForRootPath() { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) XCTAssertEqual( - remoteInterface.item(remotePath: Self.account.davFilesUrl, account: Self.account), rootItem + remoteInterface.item(remotePath: Self.account.davFilesUrl, account: Self.account.ncKitAccount), rootItem ) } func testPathParentPath() { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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" @@ -101,7 +101,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testRootPathParentPath() { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let testPath = Self.account.davFilesUrl + "/" let expectedPath = Self.account.davFilesUrl + "/" @@ -112,7 +112,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testNameFromPath() throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let testPath = Self.account.davFilesUrl + "/a/b/c/d" let expectedName = "d" @@ -121,7 +121,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testCreateFolder() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let newFolderAPath = Self.account.davFilesUrl + "/A" let newFolderA_BPath = Self.account.davFilesUrl + "/A/B/" @@ -136,19 +136,19 @@ final class MockRemoteInterfaceTests: XCTestCase { ) XCTAssertEqual(resultA_B.error, .success) - let itemA = remoteInterface.item(remotePath: newFolderAPath, account: Self.account) + 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) + 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(rootItem: rootItem) + 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) @@ -161,7 +161,7 @@ final class MockRemoteInterfaceTests: XCTestCase { XCTAssertEqual(result.remoteError, .success) let remoteItem = remoteInterface.item( - remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account + remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account.ncKitAccount ) XCTAssertNotNil(remoteItem) @@ -174,7 +174,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testUploadTargetName() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let fileName = UUID().uuidString let fileUrl = URL.temporaryDirectory.appendingPathComponent(fileName) @@ -187,11 +187,11 @@ final class MockRemoteInterfaceTests: XCTestCase { XCTAssertEqual(result.remoteError, .success) let remoteItem = remoteInterface.item( - remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account + remotePath: Self.account.davFilesUrl + "/file.txt", account: Self.account.ncKitAccount ) XCTAssertNotNil(remoteItem) let remoteItemIncorrectFileName = remoteInterface.item( - remotePath: Self.account.davFilesUrl + "/" + fileName, account: Self.account + remotePath: Self.account.davFilesUrl + "/" + fileName, account: Self.account.ncKitAccount ) XCTAssertNil(remoteItemIncorrectFileName) } @@ -202,9 +202,9 @@ final class MockRemoteInterfaceTests: XCTestCase { let data = Data(repeating: 1, count: 8) try data.write(to: fileUrl) - let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + 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]() @@ -261,7 +261,7 @@ final class MockRemoteInterfaceTests: XCTestCase { let previousUploadedChunks = [previousUploadedChunk] let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) debugPrint(remoteInterface) remoteInterface.currentChunks = [uploadUuid: previousUploadedChunks] @@ -317,7 +317,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testMove() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -382,7 +382,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testDownload() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteInterface.injectMock(Self.account) debugPrint(remoteInterface) @@ -415,7 +415,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testEnumerate() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -571,7 +571,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testDelete() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -636,8 +636,8 @@ final class MockRemoteInterfaceTests: XCTestCase { XCTAssertEqual(itemA_C_D.remotePath, Self.account.trashUrl + "/c (trashed)/d") } - func testTrashedItems() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + func testTrashedItems() async throws { + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -665,15 +665,18 @@ final class MockRemoteInterfaceTests: XCTestCase { itemA.parent = rootTrashItem itemB.parent = rootTrashItem - let (_, items, _, error) = await remoteInterface.trashedItems(account: Self.account) + let (_, items, _, error) = await remoteInterface.listingTrashAsync(filename: nil, showHiddenFiles: true, account: Self.account.ncKitAccount, options: .init(), taskHandler: { _ in }) + XCTAssertEqual(error, .success) - XCTAssertEqual(items.count, 2) - XCTAssertEqual(items[0].fileName, "a (trashed)") - XCTAssertEqual(items[1].fileName, "b (trashed)") - XCTAssertEqual(items[0].trashbinFileName, "a") - XCTAssertEqual(items[1].trashbinFileName, "b") - XCTAssertEqual(items[0].ocId, itemA.identifier) - XCTAssertEqual(items[1].ocId, itemB.identifier) + + 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 @@ -681,7 +684,7 @@ final class MockRemoteInterfaceTests: XCTestCase { // 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(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) debugPrint(remoteInterface) let folderOriginalIdentifier = "folder" let folder = MockRemoteItem( @@ -727,7 +730,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testRestoreFromTrash() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -756,7 +759,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testNoDirectMoveFromTrash() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) debugPrint(remoteInterface) let folder = MockRemoteItem( identifier: "folder", @@ -810,7 +813,7 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testEnforceOverwriteOnRestore() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) debugPrint(remoteInterface) let itemA = MockRemoteItem( identifier: "a", @@ -845,18 +848,22 @@ final class MockRemoteInterfaceTests: XCTestCase { } func testFetchUserProfile() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) - let (account, profile, _, error) = await remoteInterface.fetchUserProfile( - account: Self.account + + 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(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) debugPrint(remoteInterface) let state = await remoteInterface.tryAuthenticationAttempt(account: Self.account) XCTAssertEqual(state, .success) diff --git a/Tests/NextcloudFileProviderKitTests/AccountTests.swift b/Tests/NextcloudFileProviderKitTests/AccountTests.swift index bb593070..3a684963 100644 --- a/Tests/NextcloudFileProviderKitTests/AccountTests.swift +++ b/Tests/NextcloudFileProviderKitTests/AccountTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation @testable import NextcloudFileProviderKit diff --git a/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift b/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift index af76c458..5479fae2 100644 --- a/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ChunkedArrayTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later @testable import NextcloudFileProviderKit import XCTest @@ -54,27 +54,52 @@ final class ChunkedArrayTests: NextcloudFileProviderKitTestCase { // 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] - var results = [Int]() - let resultsQueue = - DispatchQueue(label: "com.claucambra.NextcloudFileProviderKitTests.resultsQueue") + let collector = ResultsCollector() await array.concurrentChunkedForEach(into: 2) { element in try? await Task.sleep(nanoseconds: 100_000_000) // Simulate work (100ms) - resultsQueue.sync { results.append(element * 2) } + await collector.append(element * 2) } - let sortedResults = results.sorted() + 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] = [] - var results = [Int]() + let collector = ResultsCollector() + await emptyArray.concurrentChunkedForEach(into: 2) { element in - results.append(element) + await collector.append(element) } - XCTAssertTrue(results.isEmpty) + + let isEmpty = await collector.isEmpty() + XCTAssertTrue(isEmpty) } // MARK: - concurrentChunkedCompactMap(into:transform:) diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift index ba6aea3f..b165aa06 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorPageResponseTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation diff --git a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index cd938dde..48b79da0 100644 --- a/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import NextcloudKit @@ -127,7 +127,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let enumerator = Enumerator( enumeratedItemIdentifier: .rootContainer, @@ -212,7 +212,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let enumerator = Enumerator( enumeratedItemIdentifier: .workingSet, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, log: FileProviderLogMock() ) @@ -265,7 +265,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let enumerator = Enumerator( enumeratedItemIdentifier: .workingSet, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, log: FileProviderLogMock() ) @@ -298,7 +298,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + 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. @@ -310,9 +310,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { // --- Scenario A: Initial Paginated Request (isFollowUpPaginatedRequest == false) --- // 2. Act: Call readServerUrl for the first page. - let ( - initialMetadatas, _, _, _, initialNextPage, initialError - ) = await Enumerator.readServerUrl( + let (initialMetadatas, _, _, _, initialNextPage, initialError) = await Enumerator.readServerUrl( remoteFolder.remotePath, pageSettings: (page: nil, index: 0, size: 5), // index is 0 account: Self.account, @@ -324,6 +322,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { // 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, @@ -334,27 +333,21 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { so count is (4). """ ) - XCTAssertFalse( - initialMetadatas?.contains(where: { $0.ocId == remoteFolder.identifier }) ?? false, - "The folder itself should not be in the initial results." - ) + + 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." - ) + 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( + + 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, @@ -540,7 +533,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let enumerator = Enumerator( enumeratedItemIdentifier: .workingSet, account: Self.account, - remoteInterface: MockRemoteInterface(), // Not needed and no remote calls should be made + remoteInterface: MockRemoteInterface(account: Self.account), // Not needed and no remote calls should be made dbManager: Self.dbManager, log: FileProviderLogMock() ) @@ -564,7 +557,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testFolderEnumeration() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let oldEtag = "OLD" var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) @@ -619,7 +612,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testEnumerateFile() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) var itemAMetadata = remoteItemA.toItemMetadata(account: Self.account) @@ -685,7 +678,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testFolderAndContentsChangeEnumeration() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteFolder.children.removeAll(where: { $0.identifier == remoteItemB.identifier }) remoteFolder.children.append(remoteItemC) @@ -799,7 +792,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testFileMoveChangeEnumeration() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteFolder.children.removeAll(where: { $0.identifier == remoteItemA.identifier }) rootItem.children.append(remoteItemA) @@ -894,7 +887,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testFileLockStateEnumeration() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteFolder.children.append(remoteItemC) remoteItemC.parent = remoteFolder @@ -989,7 +982,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteItemA.name = "" remoteItemA.parent = remoteInterface.rootItem @@ -1029,7 +1022,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let enumerator = Enumerator( enumeratedItemIdentifier: .trashContainer, account: Self.account, @@ -1087,7 +1080,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) rootTrashItem.children = [remoteTrashItemA] remoteTrashItemA.parent = rootTrashItem remoteTrashItemB.parent = nil @@ -1129,7 +1122,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { } func testTrashItemEnumerationFailWhenNoTrashInCapabilities() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + 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: "") @@ -1155,7 +1148,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testKeepDownloadedRetainedDuringEnumeration() async throws { let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let existingFolder = remoteFolder.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(existingFolder) @@ -1190,7 +1183,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { } func testTrashChangeEnumerationFailWhenNoTrashInCapabilities() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + 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: "") @@ -1216,7 +1209,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) rootItem.children = [remoteFolder] remoteFolder.parent = rootItem @@ -1253,7 +1246,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { func testCorrectEnumerateFileWithMissingParentInDb() async throws { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) var itemAMetadata = remoteItemA.toItemMetadata(account: Self.account) itemAMetadata.etag = "OLD" @@ -1323,7 +1316,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) let oldEtag = "OLD" var folderMetadata = remoteFolder.toItemMetadata(account: Self.account) @@ -1348,7 +1341,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { XCTAssertNotNil(Self.dbManager.itemMetadata(ocId: item.itemIdentifier.rawValue)) } XCTAssertEqual( - observer.items.filter { $0.contentType?.conforms(to: .folder) ?? false }.count, + observer.items.count(where: { $0.contentType?.conforms(to: .folder) ?? false }), 6 ) XCTAssertTrue(observer.items.last?.contentType?.conforms(to: .folder) ?? false) @@ -1368,7 +1361,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem, pagination: true) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) // 2. Create enumerator for the empty folder with a specific pageSize. let enumerator = Enumerator( @@ -1435,7 +1428,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem, pagination: true) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, pagination: true) // 2. Create enumerator with pageSize > number of children. let enumerator = Enumerator( @@ -1490,7 +1483,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { // existing folder metadata during enumeration, addressing the fix in FilesDatabaseManager let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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)) @@ -1532,7 +1525,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { // when a directory is the target of a depth-1 read operation let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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)) @@ -1571,7 +1564,7 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { // and that the state is preserved correctly across operations let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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)) diff --git a/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift b/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift index fe4ab456..ad3b22e8 100644 --- a/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift +++ b/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks @@ -341,7 +341,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { account: "TestAccount", underServerUrl: "https://example.com" ) XCTAssertEqual(remainingMetadatas.filter(\.deleted).count, 1) - XCTAssertEqual(remainingMetadatas.filter { !$0.deleted }.count, 2) + XCTAssertEqual(remainingMetadatas.count(where: { !$0.deleted }), 2) XCTAssertNotNil(remainingMetadatas.first { $0.ocId == "id-1" }) XCTAssertNotNil(remainingMetadatas.first { $0.ocId == "id-3" }) @@ -467,7 +467,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { // Check the actual database state after the write transaction XCTAssertEqual(remainingMetadatas.filter(\.deleted).count, 1) - XCTAssertEqual(remainingMetadatas.filter { !$0.deleted }.count, 2) + XCTAssertEqual(remainingMetadatas.count(where: { !$0.deleted }), 2) let survivingItem = remainingMetadatas.last XCTAssertNotNil(survivingItem, "An item should survive.") @@ -480,42 +480,6 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { XCTAssertTrue(results.updatedMetadatas?.isEmpty ?? true, "No items should have been updated.") } - func testConcurrencyOnDatabaseWrites() { - let semaphore = DispatchSemaphore(value: 0) - let count = 100 - Task { - for i in 0 ... count { - let metadata = SendableItemMetadata( - ocId: "concurrency-\(i)", - fileName: "name", - account: Account(user: "", id: "", serverUrl: "", password: "") - ) - Self.dbManager.addItemMetadata(metadata) - } - semaphore.signal() - } - - Task { - for i in 0 ... count { - let metadata = SendableItemMetadata( - ocId: "concurrency-\(count + 1 + i)", - fileName: "name", - account: Account(user: "", id: "", serverUrl: "", password: "") - ) - Self.dbManager.addItemMetadata(metadata) - } - semaphore.signal() - } - - semaphore.wait() - semaphore.wait() - - for i in 0 ... count * 2 + 1 { - let resultsI = Self.dbManager.itemMetadata(ocId: "concurrency-\(i)") - XCTAssertNotNil(resultsI, "Metadata \(i) should be saved even under concurrency") - } - } - func testDirectoryMetadataRetrieval() throws { let account = "TestAccount" let serverUrl = "https://cloud.example.com/files/documents" @@ -1076,6 +1040,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { func testParentItemIdentifierWithRemoteFallback() async throws { let rootItem = MockRemoteItem.rootItem(account: Self.account) + let remoteFolder = MockRemoteItem( identifier: "folder", versionIdentifier: "NEW", @@ -1087,6 +1052,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { userId: Self.account.id, serverUrl: Self.account.serverUrl ) + let remoteItem = MockRemoteItem( identifier: "item", versionIdentifier: "NEW", @@ -1097,6 +1063,7 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { userId: Self.account.id, serverUrl: Self.account.serverUrl ) + rootItem.children = [remoteFolder] remoteFolder.parent = rootItem remoteFolder.children = [remoteItem] @@ -1106,14 +1073,17 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { Self.dbManager.addItemMetadata(remoteItemMetadata) XCTAssertNil(Self.dbManager.parentItemIdentifierFromMetadata(remoteItemMetadata)) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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 ) - XCTAssertNotNil(retrievedParentIdentifier) - XCTAssertEqual(retrievedParentIdentifier?.rawValue, remoteFolder.identifier) + + let unwrappedParentIdentifier = try XCTUnwrap(retrievedParentIdentifier) + XCTAssertEqual(unwrappedParentIdentifier.rawValue, remoteFolder.identifier) } func testMaterialisedFiles() async throws { diff --git a/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift b/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift index 10ef5157..ea7b7412 100644 --- a/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift +++ b/Tests/NextcloudFileProviderKitTests/IgnoredFilesMatcherTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later @testable import NextcloudFileProviderKit import Testing diff --git a/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift b/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift index 035b14fd..91c9494b 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemCreateTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import NextcloudKit @@ -29,7 +29,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateFolder() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) var folderItemMetadata = SendableItemMetadata( ocId: "folder-id", fileName: "folder", account: Self.account ) @@ -83,7 +83,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateFile() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) var fileItemMetadata = SendableItemMetadata( ocId: "file-id", fileName: "file", account: Self.account ) @@ -137,7 +137,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateFileIntoFolder() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) var folderItemMetadata = SendableItemMetadata( ocId: "folder-id", fileName: "folder", account: Self.account @@ -231,7 +231,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { let keynoteBundleFilename = "test.key" - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) var bundleItemMetadata = SendableItemMetadata( ocId: "keynotebundleid", fileName: keynoteBundleFilename, account: Self.account ) @@ -371,7 +371,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateFileChunked() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) var fileItemMetadata = SendableItemMetadata( ocId: "file-id", fileName: "file", account: Self.account ) @@ -452,7 +452,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { ]) } - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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 @@ -491,6 +491,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { dbManager: Self.dbManager, log: FileProviderLogMock() ) + let createdItem = try XCTUnwrap(createdItemMaybe) XCTAssertNil(error) @@ -524,7 +525,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { func testCreateDoesNotPropagateIgnoredFile() async throws { let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["*.tmp", "/build/"]) - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) // We'll create a file that matches the ignored pattern let parentIdentifier = NSFileProviderItemIdentifier.rootContainer @@ -559,7 +560,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateLockFileTriggersRemoteLockInsteadOfUpload() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) // Setup remote folder and file let folderRemote = MockRemoteItem( @@ -639,7 +640,7 @@ final class ItemCreateTests: NextcloudFileProviderKitTestCase { } func testCreateLockFileUnactionableWithoutCapabilities() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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: "") diff --git a/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift b/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift index 8013fb89..236a1e4c 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemDeleteTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import NextcloudKit @@ -28,7 +28,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testDeleteFile() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let itemIdentifier = "file" let remoteItem = MockRemoteItem( identifier: itemIdentifier, @@ -64,7 +64,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testDeleteFolderAndContents() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let remoteFolder = MockRemoteItem( identifier: "folder", name: "folder", @@ -116,7 +116,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testDeleteWithTrashing() async { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let itemIdentifier = "file" let remoteItem = MockRemoteItem( identifier: itemIdentifier, @@ -174,7 +174,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(rootItem: rootItem), + remoteInterface: MockRemoteInterface(account: Self.account, rootItem: rootItem), dbManager: Self.dbManager ) let error = await item.delete( @@ -188,7 +188,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testDeleteLockFileUnlocksTargetFile() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) // Setup remote folder and file let folderRemote = MockRemoteItem( @@ -264,7 +264,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testDeleteLockFileWithoutCapabilitiesDoesNothing() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + 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: "") @@ -338,7 +338,7 @@ final class ItemDeleteTests: NextcloudFileProviderKitTestCase { } func testFailOnNonRecursiveNonEmptyDirDelete() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let remoteFolder = MockRemoteItem( identifier: "folder", name: "folder", diff --git a/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift b/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift index ce2b94fb..f228df58 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemFetchTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import NextcloudKit @@ -27,7 +27,7 @@ final class ItemFetchTests: NextcloudFileProviderKitTestCase { } func testFetchFileContents() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteInterface.injectMock(Self.account) let remoteItem = MockRemoteItem( @@ -73,7 +73,7 @@ final class ItemFetchTests: NextcloudFileProviderKitTestCase { } func testFetchDirectoryContents() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteInterface.injectMock(Self.account) let remoteDirectory = MockRemoteItem( diff --git a/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift b/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift index da65c78f..af894b83 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemMetadataTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation @testable import NextcloudFileProviderKit diff --git a/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift b/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift index 1bab2e4e..de71ef5b 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemModifyTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import NextcloudKit @@ -97,7 +97,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyFile() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let folderMetadata = remoteFolder.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(folderMetadata) @@ -157,7 +157,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyFolder() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let remoteFolderB = MockRemoteItem( identifier: "folder-b", @@ -243,7 +243,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() // Strong ref for in memory test db debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let keynoteBundleFilename = "test.key" let keynoteIndexZipFilename = "Index.zip" @@ -609,7 +609,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveFileToTrash() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let itemMetadata = remoteItem.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(itemMetadata) @@ -658,7 +658,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testRenameMoveFileToTrash() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let (_, _, initMoveError) = await remoteInterface.move( remotePathSource: remoteItem.remotePath, remotePathDestination: remoteFolder.remotePath + "/" + remoteItem.name, @@ -723,7 +723,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveFolderToTrash() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let remoteFolder = MockRemoteItem( identifier: "folder", name: "folder", @@ -816,7 +816,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveFolderToTrashWithRename() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let remoteFolder = MockRemoteItem( identifier: "folder", name: "folder", @@ -912,7 +912,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testTrashAndMoveFileOutOfTrash() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let itemMetadata = remoteItem.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(itemMetadata) @@ -953,7 +953,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveTrashedFileOutOfTrash() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let trashItemMetadata = remoteTrashItem.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(trashItemMetadata) @@ -989,7 +989,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveTrashedFileOutOfTrashAndRenameAndModifyContents() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let trashItemMetadata = remoteTrashItem.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(trashItemMetadata) @@ -1049,7 +1049,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { 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(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) remoteTrashItem.trashbinOriginalLocation = remoteItem.remotePath.replacingOccurrences(of: Self.account.davFilesUrl + "/", with: "") @@ -1089,7 +1089,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveFolderOutOfTrash() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let trashFolderMetadata = remoteTrashFolder.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(trashFolderMetadata) @@ -1137,7 +1137,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveFolderOutOfTrashAndRename() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem, rootTrashItem: rootTrashItem) let trashFolderMetadata = remoteTrashFolder.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(trashFolderMetadata) @@ -1195,7 +1195,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyFileContentsChunked() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let itemMetadata = remoteItem.toItemMetadata(account: Self.account) Self.dbManager.addItemMetadata(itemMetadata) @@ -1268,7 +1268,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { ]) } - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) remoteInterface.currentChunks = [chunkUploadId: [preexistingChunk]] var itemMetadata = remoteItem.toItemMetadata(account: Self.account) @@ -1334,7 +1334,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(rootItem: rootItem), + remoteInterface: MockRemoteInterface(account: Self.account, rootItem: rootItem), dbManager: Self.dbManager ) let (resultItem, error) = await item.modify( @@ -1354,7 +1354,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyCreatesFileThatWasPreviouslyIgnoredWithContentsUrlProvided() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) let ignoredMatcher = IgnoredFilesMatcher(ignoreList: ["/logs/"]) let tempFileName = UUID().uuidString @@ -1404,7 +1404,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyLockFileCompletesWithoutSyncing() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) // Construct lock file metadata let lockFileName = ".~lock.test.doc#" @@ -1463,7 +1463,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testModifyLockFileToNonLockFileCompletesWithSync() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) // Construct lock file metadata let lockFileName = ".~lock.test.doc#" @@ -1533,7 +1533,7 @@ final class ItemModifyTests: NextcloudFileProviderKitTestCase { } func testMoveToTrashFailsWhenNoTrashInCapabilities() async throws { - let remoteInterface = MockRemoteInterface(rootItem: rootItem, rootTrashItem: rootTrashItem) + 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: "") diff --git a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift index 0aaa321e..4c9df831 100644 --- a/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift +++ b/Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import NextcloudKit @@ -32,7 +32,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(item.contentType, UTType.text) @@ -49,7 +49,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(item.contentType, UTType.pdf) @@ -66,7 +66,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(item.contentType, UTType.folder) @@ -84,7 +84,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(item.contentType, UTType.package) @@ -102,7 +102,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(item.contentType, UTType.bundle) @@ -120,7 +120,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(item.contentType, UTType.folder) @@ -138,7 +138,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertTrue(item.contentType.conforms(to: .bundle)) @@ -159,7 +159,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) @@ -183,7 +183,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) @@ -205,7 +205,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) @@ -222,7 +222,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertNotNil(keepDownloadedItem.userInfo?["displayEvict"]) @@ -243,7 +243,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) @@ -265,7 +265,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadataA, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(itemA.userInfo?["displayKeepDownloaded"] as? Bool, false) @@ -278,7 +278,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadataB, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertTrue(itemB.userInfo?["displayKeepDownloaded"] as? Bool == true) @@ -294,7 +294,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadataC, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(itemC.userInfo?["displayKeepDownloaded"] as? Bool, false) @@ -309,7 +309,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadataD, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(itemD.userInfo?["displayKeepDownloaded"] as? Bool, true) @@ -326,7 +326,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) @@ -347,14 +347,14 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertFalse(item.capabilities.contains(.allowsTrashing)) } func testItemTrashabilityAffectedByCapabilities() async { - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) let remoteSupportsTrash = await remoteInterface.supportsTrash(account: Self.account) let metadata = @@ -375,7 +375,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) remoteInterface.capabilities = remoteInterface.capabilities.replacingOccurrences(of: ##""undelete": true,"##, with: "") @@ -396,7 +396,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) XCTAssert(remoteInterface.capabilities.contains(##""undelete": true,"##)) let metadata = SendableItemMetadata(ocId: "test-id", fileName: "test", account: Self.account) @@ -424,7 +424,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -450,7 +450,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -473,7 +473,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: deletableMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -493,7 +493,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: lockedMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -512,7 +512,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: noPermsMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -532,7 +532,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: trashableMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -547,7 +547,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: trashableMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: false, log: FileProviderLogMock() @@ -566,7 +566,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: lockedMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -584,7 +584,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: lockFileMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -605,7 +605,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: writableMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -623,7 +623,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: lockedMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -641,7 +641,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: directoryMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -664,7 +664,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: modifiableMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -682,7 +682,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: lockedMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -703,7 +703,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: dirMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -721,7 +721,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: fileMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -739,7 +739,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: lockedDirMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -759,7 +759,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -795,7 +795,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -828,7 +828,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -858,7 +858,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager, remoteSupportsTrash: true, log: FileProviderLogMock() @@ -882,7 +882,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: sharedMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertFalse(sharedItem.isShared) @@ -896,7 +896,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: sharedByOtherMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertFalse(sharedByOtherTime.isShared) @@ -911,7 +911,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: notSharedMetadata, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) debugPrint(notSharedMetadata.shareType) @@ -929,7 +929,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadataA, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(itemA.contentPolicy, .downloadEagerlyAndKeepDownloaded) @@ -940,7 +940,7 @@ final class ItemPropertyTests: NextcloudFileProviderKitTestCase { metadata: metadataB, parentItemIdentifier: .rootContainer, account: Self.account, - remoteInterface: MockRemoteInterface(), + remoteInterface: MockRemoteInterface(account: Self.account), dbManager: Self.dbManager ) XCTAssertEqual(itemB.contentPolicy, .inherited) diff --git a/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift b/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift index 122055d4..5a6ac4a0 100644 --- a/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift +++ b/Tests/NextcloudFileProviderKitTests/MaterialisedEnumerationObserverTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudFileProviderKit import NextcloudFileProviderKitMocks @@ -24,7 +24,7 @@ final class MaterialisedEnumerationObserverTests: NextcloudFileProviderKitTestCa let dbManager = FilesDatabaseManager(account: Self.account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) // The database is intentionally left empty. - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) let enumeratedFile = SendableItemMetadata(ocId: "file1", fileName: "file1.txt", account: Self.account) @@ -97,7 +97,7 @@ final class MaterialisedEnumerationObserverTests: NextcloudFileProviderKitTestCa dbManager.addItemMetadata(itemC) dbManager.addItemMetadata(dirD) - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) let expect = XCTestExpectation(description: "Enumerator completion handler called") let enumeratorItemsToReturn = [itemB, itemC] diff --git a/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift b/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift index 74c8dde8..d0e8cbcf 100644 --- a/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift +++ b/Tests/NextcloudFileProviderKitTests/NKFileExtensionTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudKit import XCTest diff --git a/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift b/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift index ade4b76c..515bfbfe 100644 --- a/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift +++ b/Tests/NextcloudFileProviderKitTests/NextcloudFileProviderKitTestCase.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import XCTest diff --git a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift index ba394beb..5c4b9368 100644 --- a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift +++ b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverEtagOptimizationTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudCapabilitiesKit @testable import NextcloudFileProviderKit @@ -22,7 +22,7 @@ final class RemoteChangeObserverEtagOptimizationTests: NextcloudFileProviderKitT override func setUp() { Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name dbManager = FilesDatabaseManager(account: Self.account, databaseDirectory: makeDatabaseDirectory(), fileProviderDomainIdentifier: NSFileProviderDomainIdentifier("test"), log: FileProviderLogMock()) - mockRemoteInterface = MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + mockRemoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) } func testUnchangedDirectoryShouldNotBeEnumerated() async throws { @@ -125,31 +125,31 @@ final class RemoteChangeObserverEtagOptimizationTests: NextcloudFileProviderKitT print("\n=== Running multiple working set checks ===") // First working set check - var workingSetCheckCompleted = expectation(description: "First working set check completed.") + let firstWorkingSetCheckCompleted = expectation(description: "First working set check completed.") remoteChangeObserver.startWorkingSetCheck { - workingSetCheckCompleted.fulfill() + firstWorkingSetCheckCompleted.fulfill() } - await fulfillment(of: [workingSetCheckCompleted]) + await fulfillment(of: [firstWorkingSetCheckCompleted]) // Second working set check (simulating rapid notify_file messages) - workingSetCheckCompleted = expectation(description: "Second working set check completed.") + let secondWorkingSetCheckCompleted = expectation(description: "Second working set check completed.") remoteChangeObserver.startWorkingSetCheck { - workingSetCheckCompleted.fulfill() + secondWorkingSetCheckCompleted.fulfill() } - await fulfillment(of: [workingSetCheckCompleted]) + await fulfillment(of: [secondWorkingSetCheckCompleted]) // Third working set check - workingSetCheckCompleted = expectation(description: "Third working set check completed.") + let thirdWorkingSetCheckCompleted = expectation(description: "Third working set check completed.") remoteChangeObserver.startWorkingSetCheck { - workingSetCheckCompleted.fulfill() + thirdWorkingSetCheckCompleted.fulfill() } - await fulfillment(of: [workingSetCheckCompleted]) + await fulfillment(of: [thirdWorkingSetCheckCompleted]) // Wait for all operations to complete @@ -162,9 +162,9 @@ final class RemoteChangeObserverEtagOptimizationTests: NextcloudFileProviderKitT XCTAssertGreaterThan(enumerateCallCount, 0, "At least one enumerate call should be made") // Count how many times the Customers folder was enumerated - let customersEnumerateCount = enumeratedPaths.filter { + let customersEnumerateCount = enumeratedPaths.count(where: { $0.contains("Customers") - }.count + }) print("Customers folder enumerated \(customersEnumerateCount) times") diff --git a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift index 1a92a8b5..af2ddb78 100644 --- a/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift +++ b/Tests/NextcloudFileProviderKitTests/RemoteChangeObserverTests.swift @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider import Foundation import NextcloudCapabilitiesKit @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import RealmSwift +import Testing import TestInterface import XCTest @@ -33,9 +34,13 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { ) var remoteChangeObserver: RemoteChangeObserver? - override func setUp() { + override func setUp() async throws { Realm.Configuration.defaultConfiguration.inMemoryIdentifier = name - Task { try await Self.notifyPushServer.run() } + let server = Self.notifyPushServer + + Task { + try await server.run() + } } override func tearDown() async throws { @@ -46,22 +51,22 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { /// 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: 5.0) + 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() + let remoteInterface = MockRemoteInterface(account: Self.account) remoteInterface.capabilities = mockCapabilities - var authenticated = false + let authenticated = expectation(description: "authenticated") + authenticated.assertForOverFulfill = false - NotificationCenter.default.addObserver( - forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil - ) { _ in - authenticated = true + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authenticated.fulfill() } remoteChangeObserver = RemoteChangeObserver( @@ -73,30 +78,23 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { log: FileProviderLogMock() ) - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if authenticated { - break - } - } - XCTAssertTrue(authenticated) + await fulfillment(of: [authenticated]) } func testRetryAuthentication() async throws { Self.notifyPushServer.delay = 1_000_000 - var authenticated = false + let authenticated = expectation(description: "authenticated") + authenticated.assertForOverFulfill = false - NotificationCenter.default.addObserver( - forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil - ) { _ in - authenticated = true + 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() + 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, @@ -105,6 +103,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { dbManager: Self.dbManager, log: FileProviderLogMock() ) + let remoteChangeObserver = remoteChangeObserver! for _ in 0 ... Self.timeout { @@ -113,23 +112,20 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { break } } - XCTAssertTrue(remoteChangeObserver.webSocketAuthenticationFailCount > 0) - remoteChangeObserver.account = Self.account - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if authenticated { - break - } - } - XCTAssertTrue(authenticated) + 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() + let remoteInterface = MockRemoteInterface(account: Self.account) remoteInterface.capabilities = mockCapabilities let remoteChangeObserver = RemoteChangeObserver( account: incorrectAccount, @@ -148,11 +144,13 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { break } } - XCTAssertEqual( - remoteChangeObserver.webSocketAuthenticationFailCount, - remoteChangeObserver.webSocketAuthenticationFailLimit - ) - XCTAssertFalse(remoteChangeObserver.webSocketTaskActive) + + let count = remoteChangeObserver.webSocketAuthenticationFailCount + let limit = remoteChangeObserver.webSocketAuthenticationFailLimit + let active = remoteChangeObserver.webSocketTaskActive + + XCTAssertEqual(count, limit) + XCTAssertFalse(active) } func testChangeRecognised() async throws { @@ -161,29 +159,25 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { debugPrint(db) let testStartDate = Date() - let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + 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) + 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) + 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) + 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 @@ -191,8 +185,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { Self.dbManager.addItemMetadata(fileInAToDelete) // A materialized folder that will be deleted entirely. - var folderBToDelete = - SendableItemMetadata(ocId: "folderB", fileName: "FolderB", account: Self.account) + 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 @@ -248,9 +241,8 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { let authExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified") - let notificationInterface = MockChangeNotificationInterface() - notificationInterface.changeHandler = { + let notificationInterface = MockChangeNotificationInterface { changeNotifiedExpectation.fulfill() } @@ -304,20 +296,20 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { } func testIgnoreNonFileNotifications() async throws { - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) remoteInterface.capabilities = mockCapabilities - var authenticated = false - var notified = false + let authenticated = expectation(description: "authenticated") + authenticated.assertForOverFulfill = false - NotificationCenter.default.addObserver( - forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil - ) { _ in - authenticated = true + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authenticated.fulfill() + } + + let notificationInterface = MockChangeNotificationInterface { + XCTFail("This notification should not happen!") } - let notificationInterface = MockChangeNotificationInterface() - notificationInterface.changeHandler = { notified = true } remoteChangeObserver = RemoteChangeObserver( account: Self.account, remoteInterface: remoteInterface, @@ -327,24 +319,11 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { log: FileProviderLogMock() ) - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if authenticated { - break - } - } - XCTAssertTrue(authenticated) + await fulfillment(of: [authenticated]) Self.notifyPushServer.send(message: "random") Self.notifyPushServer.send(message: "notify_activity") Self.notifyPushServer.send(message: "notify_notification") - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if notified { - break - } - } - XCTAssertFalse(notified) } func testPolling() async throws { @@ -352,7 +331,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { let db = Self.dbManager.ncDatabase() debugPrint(db) - let remoteInterface = MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) // No capabilities -> will force polling. remoteInterface.capabilities = "" @@ -367,8 +346,10 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { remoteInterface.rootItem?.children = [serverItem] let changeNotifiedExpectation = XCTestExpectation(description: "Change Notified via Polling") - let notificationInterface = MockChangeNotificationInterface() - notificationInterface.changeHandler = { changeNotifiedExpectation.fulfill() } + + let notificationInterface = MockChangeNotificationInterface { + changeNotifiedExpectation.fulfill() + } remoteChangeObserver = RemoteChangeObserver( account: Self.account, @@ -376,28 +357,33 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { changeNotificationInterface: notificationInterface, domain: nil, dbManager: Self.dbManager, + pollInterval: 0.5, log: FileProviderLogMock() ) - // Set a very short poll interval for the test. - remoteChangeObserver?.pollInterval = 0.5 // 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") - XCTAssertTrue(remoteChangeObserver?.pollingActive ?? false, "Polling should be active.") + + let pollingActive = remoteChangeObserver?.pollingActive ?? false + XCTAssertTrue(pollingActive, "Polling should be active.") } func testRetryOnRemoteClose() async throws { - let remoteInterface = MockRemoteInterface() + let remoteInterface = MockRemoteInterface(account: Self.account) remoteInterface.capabilities = mockCapabilities - var authenticated = false + 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 - authenticated = true + Task { + await fulfillments.next() + } } remoteChangeObserver = RemoteChangeObserver( @@ -409,37 +395,23 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { log: FileProviderLogMock() ) - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if authenticated { - break - } - } - XCTAssertTrue(authenticated) - authenticated = false + await fulfillment(of: [authenticated]) Self.notifyPushServer.resetCredentialsState() Self.notifyPushServer.closeConnections() - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if authenticated { - break - } - } - XCTAssertTrue(authenticated) + await fulfillment(of: [reauthenticated]) } func testPinging() async throws { - let remoteInterface = MockRemoteInterface() - remoteInterface.capabilities = mockCapabilities + let authentication = expectation(description: "authentication") + authentication.assertForOverFulfill = false - var authenticated = false + let remoteInterface = MockRemoteInterface(account: Self.account) + remoteInterface.capabilities = mockCapabilities - NotificationCenter.default.addObserver( - forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil - ) { _ in - authenticated = true + NotificationCenter.default.addObserver(forName: NotifyPushAuthenticatedNotificationName, object: nil, queue: nil) { _ in + authentication.fulfill() } remoteChangeObserver = RemoteChangeObserver( @@ -452,27 +424,26 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { ) let pingIntervalNsecs = 500_000_000 - remoteChangeObserver?.webSocketPingIntervalNanoseconds = UInt64(pingIntervalNsecs) + remoteChangeObserver?.setWebSocketPingInterval(to: UInt64(pingIntervalNsecs)) + await wait(for: authentication, description: "authentication") - for _ in 0 ... Self.timeout { - try await Task.sleep(nanoseconds: 1_000_000) - if authenticated { - break - } - } - XCTAssertTrue(authenticated) - - let intendedPings = 3 - // Add a bit of buffer to the wait time - let intendedPingsWait = (intendedPings + 1) * pingIntervalNsecs + 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) - var pings = 0 Self.notifyPushServer.pingHandler = { - pings += 1 + Task { + await pings.next() + } } - try await Task.sleep(nanoseconds: UInt64(intendedPingsWait)) - XCTAssertEqual(pings, intendedPings) + 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 { @@ -481,7 +452,7 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { debugPrint(db) let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) remoteInterface.capabilities = mockCapabilities // Setup a change scenario @@ -502,7 +473,17 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { ) remoteInterface.rootItem?.children = [serverItem] - let notificationInterface = MockChangeNotificationInterface() + 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, @@ -514,43 +495,35 @@ final class RemoteChangeObserverTests: NextcloudFileProviderKitTestCase { self.remoteChangeObserver = remoteChangeObserver // --- Phase 1: Test connection and change notification --- - let authExpectation = - XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) + let authExpectation = XCTNSNotificationExpectation(name: NotifyPushAuthenticatedNotificationName) remoteChangeObserver.networkReachabilityObserver(.reachableEthernetOrWiFi) await wait(for: authExpectation, description: "initial authentication") - let changeExpectation1 = XCTestExpectation(description: "First change notification") - notificationInterface.changeHandler = { changeExpectation1.fulfill() } Self.notifyPushServer.send(message: "notify_file") - await wait(for: changeExpectation1, description: "first change") + 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) - XCTAssertFalse( - remoteChangeObserver.webSocketTaskActive, - "Websocket should be inactive after connection loss." - ) + 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) + 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") - XCTAssertTrue( - remoteChangeObserver.webSocketTaskActive, - "Websocket should be active again after reconnection." - ) + let webSocketTaskActiveAfterReconnect = remoteChangeObserver.webSocketTaskActive + + XCTAssertTrue(webSocketTaskActiveAfterReconnect, "Websocket should be active again after reconnection.") - let changeExpectation2 = XCTestExpectation(description: "Second change notification") - notificationInterface.changeHandler = { changeExpectation2.fulfill() } Self.notifyPushServer.send(message: "notify_file") - await wait(for: changeExpectation2, description: "second change") + await wait(for: change2, description: "second change") } } diff --git a/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift b/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift index 8c5429de..d73ddef5 100644 --- a/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift +++ b/Tests/NextcloudFileProviderKitTests/RemoteInterfaceTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Alamofire import Foundation @@ -23,8 +23,7 @@ struct RemoteInterfaceExtensionTests { @Test func currentCapabilitiesReturnsFreshCache() async { await RetrievedCapabilitiesActor.shared.reset() - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { _, _, _ in + let remoteInterface = TestableRemoteInterface { _, _, _ in Issue.record("fetchCapabilities should NOT be called when cache is fresh.") return (testAccount.ncKitAccount, nil, nil, .invalidResponseError) } @@ -51,23 +50,23 @@ struct RemoteInterfaceExtensionTests { await RetrievedCapabilitiesActor.shared.reset() let (fetchedCaps, fetchedData) = capabilitiesFromMockJSON() - var fetcherCalled = false - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in - fetcherCalled = true - #expect(acc.ncKitAccount == testAccount.ncKitAccount) - return (acc.ncKitAccount, fetchedCaps, fetchedData, .success) - } - let result = await remoteInterface.currentCapabilities(account: testAccount) + await confirmation("fetcherCalled") { fetcherCalled in + let remoteInterface = TestableRemoteInterface { acc, _, _ in + fetcherCalled() + #expect(acc.ncKitAccount == testAccount.ncKitAccount) + return (acc.ncKitAccount, fetchedCaps, fetchedData, .success) + } - #expect(fetcherCalled, "fetchCapabilities should be called when cache is empty.") - #expect(result.error == .success) - #expect(result.capabilities == fetchedCaps) - #expect(result.data == fetchedData) + let result = await remoteInterface.currentCapabilities(account: testAccount) - let actorCache = await RetrievedCapabilitiesActor.shared.data - #expect(actorCache[testAccount.ncKitAccount]?.capabilities == fetchedCaps) + #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 { @@ -101,75 +100,59 @@ struct RemoteInterfaceExtensionTests { ) let (newCaps, newData) = capabilitiesFromMockJSON() // Fresh data to be fetched - var fetcherCalled = false - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in - fetcherCalled = true - return (acc.ncKitAccount, newCaps, newData, .success) - } - let result = await remoteInterface.currentCapabilities(account: testAccount) + await confirmation("fetcherCalled") { fetcherCalled in + let remoteInterface = TestableRemoteInterface { acc, _, _ in + fetcherCalled() + return (acc.ncKitAccount, newCaps, newData, .success) + } - #expect(fetcherCalled, "fetchCapabilities should be called for stale cache.") - #expect(result.error == .success) - #expect(result.capabilities == newCaps, "Should return newly fetched capabilities.") - #expect(result.data == newData) + let result = await remoteInterface.currentCapabilities(account: testAccount) - let actorCache = await RetrievedCapabilitiesActor.shared.data - #expect(actorCache[testAccount.ncKitAccount]?.capabilities == newCaps) - #expect((actorCache[testAccount.ncKitAccount]?.retrievedAt ?? .distantPast) > staleDate) + #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() - var fetcherCalledCount = 0 - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in - fetcherCalledCount += 1 - // This fetcher should not be called if cache is fresh after await. + 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 - ) - - var currentCapabilitiesReturned = false - let currentCapabilitiesTask = Task { - // 2. This call to currentCapabilities should await the ongoing fetch. - let result = await remoteInterface.currentCapabilities(account: testAccount) - currentCapabilitiesReturned = true - // Assertions on the result will be done after the task. - #expect(result.capabilities == cachedCaps) - #expect(result.error == .success) - } - - // 3. Give currentCapabilitiesTask a moment to hit the await. - try await Task.sleep(for: .milliseconds(100)) - #expect(currentCapabilitiesReturned == false, "currentCapabilities should be awaiting.") + 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) + } - // 4. 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 - ) + // 3. Now, the "external" fetch completes and populates the cache. + await RetrievedCapabilitiesActor.shared.setCapabilities( + forAccount: testAccount.ncKitAccount, + capabilities: cachedCaps, + retrievedAt: Date() // Fresh date + ) - // 5. currentCapabilitiesTask should now complete. - await currentCapabilitiesTask.value - #expect(currentCapabilitiesReturned == true) + await RetrievedCapabilitiesActor.shared.setOngoingFetch(forAccount: testAccount.ncKitAccount, ongoing: false) - // Check if fetchCapabilities was called. - // If the logic is: await -> check cache -> fetch if needed. - // And we made cache fresh before await unblocked, it should NOT call fetch. - #expect(fetcherCalledCount == 0, "fetchCapabilities should not have been called if cache was fresh after await.") + await currentCapabilitiesTask.value + } } @Test func supportsTrashTrue() async throws { @@ -179,10 +162,10 @@ struct RemoteInterfaceExtensionTests { let (capsWithTrash, dataWithTrash) = capabilitiesFromMockJSON() #expect(capsWithTrash.files?.undelete == true) - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + let remoteInterface = TestableRemoteInterface { acc, _, _ in (acc.ncKitAccount, capsWithTrash, dataWithTrash, .success) } + await RetrievedCapabilitiesActor.shared.setCapabilities( forAccount: testAccount.ncKitAccount, capabilities: capsWithTrash, // any capability @@ -216,8 +199,7 @@ struct RemoteInterfaceExtensionTests { let (capsNoTrash, dataNoTrash) = capabilitiesFromMockJSON(jsonString: jsonNoUndelete) #expect(capsNoTrash.files?.undelete == false) - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + let remoteInterface = TestableRemoteInterface { acc, _, _ in await RetrievedCapabilitiesActor.shared.setCapabilities( forAccount: acc.ncKitAccount, capabilities: capsNoTrash, retrievedAt: Date() ) @@ -235,10 +217,10 @@ struct RemoteInterfaceExtensionTests { @Test func supportsTrashNilCapabilities() async throws { await RetrievedCapabilitiesActor.shared.reset() - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + let remoteInterface = TestableRemoteInterface { acc, _, _ in (acc.ncKitAccount, nil, nil, .invalidResponseError) } + await RetrievedCapabilitiesActor.shared.setCapabilities( forAccount: testAccount.ncKitAccount, capabilities: capabilitiesFromMockJSON().0, @@ -273,10 +255,10 @@ struct RemoteInterfaceExtensionTests { let (capsNoFiles, dataNoFiles) = capabilitiesFromMockJSON(jsonString: jsonNoFilesSection) #expect(capsNoFiles.files?.undelete != true) // Check our parsing logic - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + let remoteInterface = TestableRemoteInterface { acc, _, _ in (acc.ncKitAccount, capsNoFiles, dataNoFiles, .success) } + await RetrievedCapabilitiesActor.shared.setCapabilities( // Stale entry forAccount: testAccount.ncKitAccount, capabilities: capsNoFiles, @@ -289,8 +271,8 @@ struct RemoteInterfaceExtensionTests { @Test func supportsTrashHandlesErrorFromCurrentCapabilities() async throws { await RetrievedCapabilitiesActor.shared.reset() - var remoteInterface = TestableRemoteInterface() - remoteInterface.fetchCapabilitiesHandler = { acc, _, _ in + + let remoteInterface = TestableRemoteInterface { acc, _, _ in (acc.ncKitAccount, nil, nil, .invalidResponseError) } // Ensure fetch is triggered diff --git a/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift b/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift index 9aeec942..31344d3c 100644 --- a/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift +++ b/Tests/NextcloudFileProviderKitTests/RetrievedCapabilitiesActorTests.swift @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// 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 { @@ -20,7 +21,7 @@ struct RetrievedCapabilitiesActorTests { // We call the public API. await actor.setCapabilities(forAccount: account1, capabilities: caps, retrievedAt: specificDate) - let setCaps = await actor.data[account1] + let setCaps = await actor.getCapabilities(for: account1) #expect(setCaps?.retrievedAt == specificDate) #expect(setCaps?.capabilities != nil) @@ -28,7 +29,7 @@ struct RetrievedCapabilitiesActorTests { @Test func setOngoingFetchTrueCausesSuspension() async throws { let actor = RetrievedCapabilitiesActor() - var awaiterDidProceed = false + let awaiterDidProceed = Expectation("awaiterDidProceed") // 1. Mark fetch as ongoing await actor.setOngoingFetch(forAccount: account1, ongoing: true) @@ -36,61 +37,54 @@ struct RetrievedCapabilitiesActorTests { // 2. Attempt to await in a separate task let awaitingTask = Task { await actor.awaitFetchCompletion(forAccount: account1) - awaiterDidProceed = true // This should only become true after resumption + await awaiterDidProceed.fulfill() } // 3. Give the awaitingTask a moment to potentially run and suspend try await Task.sleep(for: .milliseconds(100)) - #expect(!awaiterDidProceed, "`awaitFetchCompletion` should suspend if fetch is ongoing.") + #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(awaiterDidProceed, "Awaiter should proceed after fetch is no longer ongoing.") + #expect(await awaiterDidProceed.isFulfilled, "Awaiter should proceed after fetch is no longer ongoing.") } @Test func setOngoingFetchFalseResumesAwaiter() async throws { let actor = RetrievedCapabilitiesActor() - var awaiterCompleted = false + 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) - awaiterCompleted = true + await awaiterCompleted.fulfill() } // 2. Ensure it's waiting try await Task.sleep(for: .milliseconds(100)) - #expect(awaiterCompleted == false, "Awaiter should be suspended initially.") + #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(awaiterCompleted, "Awaiter should complete after `setOngoingFetch(false)`.") + #expect(await awaiterCompleted.isFulfilled, "Awaiter should complete after `setOngoingFetch(false)`.") } @Test func awaitFetchCompletionReturnsImmediately() async throws { let actor = RetrievedCapabilitiesActor() - var didAwaiterCompleteImmediately = false - let task = Task { + await confirmation("did awaiter complete immediately") { didAwaiterCompleteImmediately in await actor.awaitFetchCompletion(forAccount: account1) - didAwaiterCompleteImmediately = true + didAwaiterCompleteImmediately() } - await task.value - - #expect( - didAwaiterCompleteImmediately, - "`awaitFetchCompletion` should let the task complete quickly if no fetch is ongoing." - ) } @Test func awaitFetchCompletion_suspendsAndResumes_behavioral() async throws { let actor = RetrievedCapabilitiesActor() - var didAwaiterComplete = false + let didAwaiterComplete = Expectation("didAwaiterComplete") // 1. Mark fetch as ongoing await actor.setOngoingFetch(forAccount: account1, ongoing: true) @@ -98,54 +92,57 @@ struct RetrievedCapabilitiesActorTests { // 2. Start task that awaits let awaitingTask = Task { await actor.awaitFetchCompletion(forAccount: account1) - didAwaiterComplete = true + await didAwaiterComplete.fulfill() } // 3. Check for suspension (indirectly) try await Task.sleep(for: .milliseconds(100)) - #expect(!didAwaiterComplete, "Awaiter should be suspended while fetch is ongoing.") + #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(didAwaiterComplete, "Awaiter should complete after fetch is no longer ongoing.") + #expect(await didAwaiterComplete.isFulfilled, "Awaiter should complete after fetch is no longer ongoing.") } @Test func awaitFetchCompletion_multipleAwaiters_behavioral() async throws { let actor = RetrievedCapabilitiesActor() - var awaiter1Complete = false - var awaiter2Complete = false + let awaiter1Complete = Expectation("awaiter1Complete") + let awaiter2Complete = Expectation("awaiter2Complete") await actor.setOngoingFetch(forAccount: account1, ongoing: true) let task1 = Task { await actor.awaitFetchCompletion(forAccount: account1) - awaiter1Complete = true + await awaiter1Complete.fulfill() } let task2 = Task { await actor.awaitFetchCompletion(forAccount: account1) - awaiter2Complete = true + await awaiter2Complete.fulfill() } try await Task.sleep(for: .milliseconds(100)) - #expect(!awaiter1Complete && !awaiter2Complete, "Both awaiters should be suspended.") + + 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 - #expect( - awaiter1Complete && awaiter2Complete, - "Both awaiters should complete after fetch is no longer ongoing." - ) + + 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() - var acc1AwaiterDone = false - var acc2AwaiterDone = false + let acc1AwaiterDone = Expectation("acc1AwaiterDone") + let acc2AwaiterDone = Expectation("acc2AwaiterDone") // Start fetches for both accounts await actor.setOngoingFetch(forAccount: account1, ongoing: true) @@ -154,27 +151,30 @@ struct RetrievedCapabilitiesActorTests { // Setup awaiters let taskAcc1 = Task { await actor.awaitFetchCompletion(forAccount: account1) - acc1AwaiterDone = true + await acc1AwaiterDone.fulfill() } let taskAcc2 = Task { await actor.awaitFetchCompletion(forAccount: account2) - acc2AwaiterDone = true + await acc2AwaiterDone.fulfill() } try await Task.sleep(for: .milliseconds(100)) // Allow tasks to suspend - #expect(!acc1AwaiterDone && !acc2AwaiterDone, "Both awaiters initially suspended.") + + 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(acc1AwaiterDone, "Awaiter for account1 should complete.") - #expect(!acc2AwaiterDone, "Awaiter for account2 should still be suspended.") + #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(acc2AwaiterDone, "Awaiter for account2 should now complete.") + #expect(await acc2AwaiterDone.isFulfilled, "Awaiter for account2 should now complete.") } } diff --git a/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift b/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift index 2c873ce8..f3a46db0 100644 --- a/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift +++ b/Tests/NextcloudFileProviderKitTests/SafeFilenameUrlTests.swift @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later import Foundation @testable import NextcloudFileProviderKit diff --git a/Tests/NextcloudFileProviderKitTests/UploadTests.swift b/Tests/NextcloudFileProviderKitTests/UploadTests.swift index 2157b24b..4a4d7fcc 100644 --- a/Tests/NextcloudFileProviderKitTests/UploadTests.swift +++ b/Tests/NextcloudFileProviderKitTests/UploadTests.swift @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later -import FileProvider +@preconcurrency import FileProvider @testable import NextcloudFileProviderKit import NextcloudFileProviderKitMocks import RealmSwift @@ -24,7 +24,7 @@ final class UploadTests: NextcloudFileProviderKitTestCase { try data.write(to: fileUrl) let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + 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, @@ -49,7 +49,7 @@ final class UploadTests: NextcloudFileProviderKitTestCase { try data.write(to: fileUrl) let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) let remotePath = Self.account.davFilesUrl + "/file.txt" let chunkSize = 3 var uploadedChunks = [RemoteFileChunk]() @@ -93,7 +93,7 @@ final class UploadTests: NextcloudFileProviderKitTestCase { try data.write(to: fileUrl) let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) let chunkSize = 3 let uploadUuid = UUID().uuidString let previousUploadedChunkNum = 1 @@ -220,7 +220,7 @@ final class UploadTests: NextcloudFileProviderKitTestCase { try data.write(to: fileUrl) let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) remoteInterface.capabilities = capabilities let remotePath = Self.account.davFilesUrl + "/file.txt" @@ -300,7 +300,7 @@ final class UploadTests: NextcloudFileProviderKitTestCase { try data.write(to: fileUrl) let remoteInterface = - MockRemoteInterface(rootItem: MockRemoteItem.rootItem(account: Self.account)) + MockRemoteInterface(account: Self.account, rootItem: MockRemoteItem.rootItem(account: Self.account)) remoteInterface.capabilities = capabilities let remotePath = Self.account.davFilesUrl + "/file.txt" diff --git a/Tests/NextcloudFileProviderKitTests/Utilities/Expectation.swift b/Tests/NextcloudFileProviderKitTests/Utilities/Expectation.swift new file mode 100644 index 00000000..197447fd --- /dev/null +++ b/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/Tests/NextcloudFileProviderKitTests/Utilities/ExpectationFulfillmentCounter.swift b/Tests/NextcloudFileProviderKitTests/Utilities/ExpectationFulfillmentCounter.swift new file mode 100644 index 00000000..cc7ced9e --- /dev/null +++ b/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 + } +}