├── .github ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE.md.license └── workflows │ ├── lint.yml │ ├── reuse.yml │ └── xcode.yml ├── .gitignore ├── .swiftlint.yml ├── AUTHORS.md ├── COPYING.iOS ├── Cartfile ├── Gemfile ├── LICENSE.txt ├── LICENSES ├── CC0-1.0.txt ├── GPL-3.0-or-later.txt └── LicenseRef-NextcloudTrademarks.txt ├── NextcloudKit.png ├── NextcloudKit.pxd ├── NextcloudKit.svg ├── Package.resolved ├── Package.swift ├── README.md ├── REUSE.toml ├── Sourcery ├── EnvVars.stencil └── bin │ └── sourcery ├── Sources └── NextcloudKit │ ├── Extensions │ ├── Data+Extension.swift │ ├── Date+Extension.swift │ ├── Image+Extension.swift │ ├── NSLock+Extension.swift │ └── String+Extension.swift │ ├── Log │ ├── NKLog.swift │ └── NKLogFileManager.swift │ ├── Models │ ├── Assistant │ │ ├── v1 │ │ │ ├── NKTextProcessingTask.swift │ │ │ └── NKTextProcessingTaskType.swift │ │ └── v2 │ │ │ ├── TaskList.swift │ │ │ └── TaskTypes.swift │ ├── EditorDetails │ │ ├── NKEditorDetailsConverter.swift │ │ ├── NKEditorDetailsResponse+NKConversion.swift │ │ └── NKEditorDetailsResponse.swift │ ├── NKActivity.swift │ ├── NKComments.swift │ ├── NKDataFileXML.swift │ ├── NKDownloadLimit.swift │ ├── NKExternalSite.swift │ ├── NKFile.swift │ ├── NKProperties.swift │ ├── NKRecommendedFiles.swift │ ├── NKRichdocumentsTemplate.swift │ ├── NKShareAccounts.swift │ ├── NKSharee.swift │ ├── NKTermsOfService.swift │ ├── NKTrash.swift │ ├── NKUserProfile.swift │ └── NKUserStatus.swift │ ├── NKCommon.swift │ ├── NKError.swift │ ├── NKInterceptor.swift │ ├── NKMonitor.swift │ ├── NKRequestOptions.swift │ ├── NKSession.swift │ ├── NextcloudKit+API.swift │ ├── NextcloudKit+Assistant.swift │ ├── NextcloudKit+AssistantV2.swift │ ├── NextcloudKit+Capabilities.swift │ ├── NextcloudKit+Comments.swift │ ├── NextcloudKit+Dashboard.swift │ ├── NextcloudKit+Download.swift │ ├── NextcloudKit+E2EE.swift │ ├── NextcloudKit+FilesLock.swift │ ├── NextcloudKit+Groupfolders.swift │ ├── NextcloudKit+Hovercard.swift │ ├── NextcloudKit+Livephoto.swift │ ├── NextcloudKit+Logging.swift │ ├── NextcloudKit+Login.swift │ ├── NextcloudKit+NCText.swift │ ├── NextcloudKit+PushNotification.swift │ ├── NextcloudKit+RecommendedFiles.swift │ ├── NextcloudKit+Richdocuments.swift │ ├── NextcloudKit+Search.swift │ ├── NextcloudKit+Share.swift │ ├── NextcloudKit+ShareDownloadLimit.swift │ ├── NextcloudKit+TermsOfService.swift │ ├── NextcloudKit+Upload.swift │ ├── NextcloudKit+UserStatus.swift │ ├── NextcloudKit+WebDAV.swift │ ├── NextcloudKit.h │ ├── NextcloudKit.swift │ ├── NextcloudKitBackground.swift │ ├── NextcloudKitSessionDelegate.swift │ ├── TypeIdentifiers │ ├── NKFilePropertyResolver.swift │ └── NKTypeIdentifiers.swift │ └── Utils │ ├── FileAutoRenamer.swift │ ├── FileNameValidator.swift │ └── SynchronizedNKSessionArray.swift ├── Tests ├── NextcloudKitIntegrationTests │ ├── BaseIntegrationXCTestCase.swift │ ├── Common │ │ ├── BaseXCTestCase.swift │ │ └── TestConstants.swift │ ├── FilesIntegrationTests.swift │ └── ShareIntegrationTests.swift └── NextcloudKitUnitTests │ ├── FileAutoRenamerUnitTests.swift │ ├── FileNameValidatorUnitTests.swift │ ├── LoginUnitTests.swift │ └── Resources │ └── PollMock.json ├── create-docker-test-server.sh └── generate-env-vars.sh /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | 1. 4 | 2. 5 | 3. 6 | 7 | ### Expected behaviour 8 | 9 | Tell us what should happen. 10 | 11 | ### Actual behaviour 12 | 13 | Tell was what instead happens. 14 | 15 | ### Screenshots 16 | 17 | If applicable, add a screenshot showing the issue. 18 | 19 | ### Logs 20 | 21 | ``` 22 | If applicable, you can post the iOS app or server logs (removing any sensitive information). 23 | ``` 24 | 25 | ### Reasoning or why should it be changed/implemented? 26 | 27 | ### Environment data 28 | 29 | **iOS version:** e.g. iOS 14.4.1 30 | 31 | **Nextcloud iOS app version:** see More > Settings 32 | 33 | **Server operating system:** 34 | 35 | **Web server:** Apache, nginx 36 | 37 | **Database:** 38 | 39 | **PHP version:** 40 | 41 | **Nextcloud version:** see Nextcloud admin page 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | SPDX-License-Identifier: GPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | # 4 | # Lints the project using SwiftLint 5 | 6 | name: SwiftLint 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | - develop 13 | pull_request: 14 | types: [synchronize, opened, reopened, ready_for_review] 15 | branches: 16 | - main 17 | - develop 18 | 19 | jobs: 20 | Lint: 21 | runs-on: ubuntu-latest 22 | if: github.event.pull_request.draft == false 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - name: GitHub Action for SwiftLint 28 | uses: norio-nomura/action-swiftlint@3.2.1 29 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | 6 | # SPDX-FileCopyrightText: 2022 Free Software Foundation Europe e.V. 7 | # 8 | # SPDX-License-Identifier: CC0-1.0 9 | 10 | name: REUSE Compliance Check 11 | 12 | on: [pull_request] 13 | 14 | jobs: 15 | reuse-compliance-check: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | persist-credentials: false 22 | 23 | - name: REUSE Compliance Check 24 | uses: fsfe/reuse-action@bb774aa972c2a89ff34781233d275075cbddf542 # v5.0.0 25 | -------------------------------------------------------------------------------- /.github/workflows/xcode.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | name: Build and test 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | - develop 10 | pull_request: 11 | types: [synchronize, opened, reopened, ready_for_review] 12 | branches: 13 | - main 14 | - develop 15 | 16 | env: 17 | DESTINATION_IOS: platform=iOS Simulator,name=iPhone 16,OS=18.1 18 | DESTINATION_MACOS: platform=macOS,arch=x86_64 19 | SCHEME: NextcloudKit 20 | SERVER_BRANCH: stable28 21 | PHP_VERSION: 8.2 22 | 23 | jobs: 24 | build-and-test: 25 | name: Build and Test 26 | runs-on: macos-15 27 | if: github.event.pull_request.draft == false 28 | steps: 29 | - name: Set env var 30 | run: echo "DEVELOPER_DIR=$(xcode-select --print-path)" >> $GITHUB_ENV 31 | - uses: actions/checkout@v4 32 | 33 | - name: Set up php ${{ env.PHP_VERSION }} 34 | uses: shivammathur/setup-php@8872c784b04a1420e81191df5d64fbd59d3d3033 # v2.30.0 35 | with: 36 | php-version: ${{ env.PHP_VERSION }} 37 | # https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation 38 | extensions: apcu, bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql 39 | coverage: none 40 | ini-file: development 41 | # Temporary workaround for missing pcntl_* in PHP 8.3: ini-values: apc.enable_cli=on 42 | ini-values: apc.enable_cli=on, disable_functions= 43 | 44 | - name: Checkout server 45 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 46 | with: 47 | submodules: true 48 | repository: nextcloud/server 49 | path: server 50 | ref: ${{ env.SERVER_BRANCH }} 51 | 52 | - name: Set up Nextcloud 53 | run: | 54 | mkdir server/data 55 | ./server/occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin 56 | ./server/occ config:system:set hashing_default_password --value=true --type=boolean 57 | ./server/occ config:system:set auth.bruteforce.protection.enabled --value false --type bool 58 | ./server/occ config:system:set ratelimit.protection.enabled --value false --type bool 59 | ./server/occ config:system:set memcache.local --value="\\OC\\Memcache\\APCu" 60 | ./server/occ config:system:set memcache.distributed --value="\\OC\\Memcache\\APCu" 61 | ./server/occ background:cron 62 | PHP_CLI_SERVER_WORKERS=5 php -S localhost:8080 -t server/ & 63 | # - name: Setup Bundler and Install Gems 64 | # run: | 65 | # gem install bundler 66 | # bundle install 67 | # bundle update 68 | # - name: Install docker 69 | # run: | 70 | # # Workaround for https://github.com/actions/runner-images/issues/8104 71 | # brew remove --ignore-dependencies qemu 72 | # curl -o ./qemu.rb https://raw.githubusercontent.com/Homebrew/homebrew-core/dc0669eca9479e9eeb495397ba3a7480aaa45c2e/Formula/qemu.rb 73 | # brew install ./qemu.rb 74 | # 75 | # brew install docker 76 | # colima start 77 | # - name: Create docker test server and export enviroment variables 78 | # run: | 79 | # source ./create-docker-test-server.sh 80 | # if [ ! -f ".env-vars" ]; then 81 | # touch .env-vars 82 | # echo "export TEST_SERVER_URL=$TEST_SERVER_URL" >> .env-vars 83 | # echo "export TEST_USER=$TEST_USER" >> .env-vars 84 | # echo "export TEST_APP_PASSWORD=$TEST_APP_PASSWORD" >> .env-vars 85 | # fi 86 | # - name: Generate EnvVars file 87 | # run: | 88 | # ./generate-env-vars.sh 89 | - name: Build & Test NextcloudKit 90 | run: | 91 | set -o pipefail && xcodebuild test -scheme "$SCHEME" \ 92 | -destination "$DESTINATION_IOS" \ 93 | -test-iterations 3 \ 94 | -retry-tests-on-failure \ 95 | | xcpretty 96 | 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # file 2 | # 3 | # SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | # 6 | ######################################################################### 7 | # # 8 | # Title - .gitignore file # 9 | # For - iOS - Xcode # 10 | # # 11 | ######################################################################### 12 | 13 | ## NextcloudKit 14 | *.xcodeproj 15 | !NextcloudKit.xcodeproj 16 | 17 | ## Xcode 18 | .DS_Store 19 | */build/* 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | xcuserdata 29 | profile 30 | *.moved-aside 31 | *.xcodeproj/ 32 | DerivedData 33 | .idea/ 34 | *.hmap 35 | *.xccheckout 36 | 37 | ## Package 38 | Carthage/ 39 | 40 | ### SwiftPackageManager ### 41 | .swiftpm 42 | Package.resolved 43 | 44 | /.env-vars 45 | *.generated.swift 46 | /.build 47 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | opt_in_rules: # some rules are turned off by default, so you need to opt-in 2 | - empty_collection_literal 3 | - empty_count 4 | - empty_string 5 | - explicit_init 6 | - unneeded_parentheses_in_closure_argument 7 | - operator_usage_whitespace 8 | 9 | empty_count: 10 | severity: warning 11 | 12 | line_length: 13 | warning: 400 14 | error: 400 15 | 16 | function_body_length: 17 | warning: 200 18 | 19 | type_body_length: 20 | warning: 2000 21 | error: 2000 22 | 23 | file_length: 24 | warning: 2000 25 | error: 3000 26 | ignore_comment_only_lines: true 27 | 28 | identifier_name: 29 | min_length: 0 30 | 31 | disabled_rules: 32 | - unused_setter_value 33 | - large_tuple 34 | - function_parameter_count 35 | - multiple_closures_with_trailing_closure 36 | - for_where 37 | - cyclomatic_complexity 38 | - nesting 39 | - shorthand_operator 40 | - redundant_string_enum_value 41 | - syntactic_sugar 42 | - compiler_protocol_init 43 | 44 | excluded: 45 | - Carthage 46 | - Pods 47 | - Package.swift 48 | - Tests 49 | - DerivedData 50 | - .build 51 | 52 | reporter: "xcode" 53 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | 5 | # Authors 6 | 7 | - Claudio Cambra 8 | - Ivan Sein 9 | - Marcel Müller 10 | - Marino Faggiana 11 | - Milen Pivchev 12 | -------------------------------------------------------------------------------- /COPYING.iOS: -------------------------------------------------------------------------------- 1 | The NextcloudKit developers are aware that the terms of service that 2 | apply to apps distributed via Apple's App Store services may conflict 3 | with rights granted under the NextcloudKit license, the GNU General 4 | Public License, version 3 or (at your option) any later version. The 5 | copyright holders of the NextcloudKit do not wish this conflict 6 | to prevent the otherwise-compliant distribution of derived apps via 7 | the App Store. Therefore, we have committed not to pursue any license 8 | violation that results solely from the conflict between the GNU GPLv3 9 | or any later version and the Apple App Store terms of service. In 10 | other words, as long as you comply with the GPL in all other respects, 11 | including its requirements to provide users with source code and the 12 | text of the license, we will not object to your distribution of the 13 | NextcloudKit through the App Store. 14 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "Alamofire/Alamofire" "5.2.2" 2 | github "https://github.com/yahoojapan/SwiftyXMLParser" "5.2.1" 3 | github "SwiftyJSON/SwiftyJSON" "5.0.0" 4 | 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'slather' 3 | gem 'xcpretty' -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /LICENSES/LicenseRef-NextcloudTrademarks.txt: -------------------------------------------------------------------------------- 1 | The Nextcloud marks 2 | Nextcloud and the Nextcloud logo is a registered trademark of Nextcloud GmbH in Germany and/or other countries. 3 | These guidelines cover the following marks pertaining both to the product names and the logo: “Nextcloud” 4 | and the blue/white cloud logo with or without the word Nextcloud; the service “Nextcloud Enterprise”; 5 | and our products: “Nextcloud Files”; “Nextcloud Groupware” and “Nextcloud Talk”. 6 | This set of marks is collectively referred to as the “Nextcloud marks.” 7 | 8 | Use of Nextcloud logos and other marks is only permitted under the guidelines provided by the Nextcloud GmbH. 9 | A copy can be found at https://nextcloud.com/trademarks/ 10 | -------------------------------------------------------------------------------- /NextcloudKit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/NextcloudKit/13a1e67f10fee09cf8ab475d916973878d880c96/NextcloudKit.png -------------------------------------------------------------------------------- /NextcloudKit.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/NextcloudKit/13a1e67f10fee09cf8ab475d916973878d880c96/NextcloudKit.pxd -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "alamofire", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Alamofire/Alamofire", 7 | "state" : { 8 | "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", 9 | "version" : "5.10.2" 10 | } 11 | }, 12 | { 13 | "identity" : "mocker", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/WeTransfer/Mocker.git", 16 | "state" : { 17 | "revision" : "95fa785c751f6bc40c49e112d433c3acf8417a97", 18 | "version" : "3.0.2" 19 | } 20 | }, 21 | { 22 | "identity" : "swiftyjson", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/SwiftyJSON/SwiftyJSON", 25 | "state" : { 26 | "revision" : "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828", 27 | "version" : "5.0.2" 28 | } 29 | }, 30 | { 31 | "identity" : "swiftyxmlparser", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/yahoojapan/SwiftyXMLParser", 34 | "state" : { 35 | "revision" : "d7a1d23f04c86c1cd2e8f19247dd15d74e0ea8be", 36 | "version" : "5.6.0" 37 | } 38 | } 39 | ], 40 | "version" : 2 41 | } 42 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | // 4 | // SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors 5 | // SPDX-License-Identifier: GPL-3.0-or-later 6 | // 7 | 8 | import PackageDescription 9 | 10 | let package = Package( 11 | name: "NextcloudKit", 12 | platforms: [ 13 | .iOS(.v14), 14 | .macOS(.v11), 15 | .tvOS(.v14), 16 | .watchOS(.v7), 17 | .visionOS(.v1) 18 | ], 19 | products: [ 20 | .library( 21 | name: "NextcloudKit", 22 | targets: ["NextcloudKit"]), 23 | ], 24 | dependencies: [ 25 | .package(url: "https://github.com/WeTransfer/Mocker.git", .upToNextMajor(from: "3.0.2")), 26 | .package(url: "https://github.com/Alamofire/Alamofire", .upToNextMajor(from: "5.10.2")), 27 | .package(url: "https://github.com/SwiftyJSON/SwiftyJSON", .upToNextMajor(from: "5.0.2")), 28 | .package(url: "https://github.com/yahoojapan/SwiftyXMLParser", .upToNextMajor(from: "5.6.0")), 29 | ], 30 | targets: [ 31 | .target( 32 | name: "NextcloudKit", 33 | dependencies: ["Alamofire", "SwiftyJSON", "SwiftyXMLParser"]), 34 | .testTarget( 35 | name: "NextcloudKitUnitTests", 36 | dependencies: ["NextcloudKit", "Mocker"], 37 | resources: [ 38 | .process("Resources") 39 | ]), 40 | .testTarget( 41 | name: "NextcloudKitIntegrationTests", 42 | dependencies: ["NextcloudKit", "Mocker"]) 43 | ] 44 | 45 | /* Test simulate 6 46 | targets: [ 47 | .target( 48 | name: "NextcloudKit", 49 | dependencies: ["Alamofire", "SwiftyJSON", "SwiftyXMLParser"], 50 | swiftSettings: [ 51 | .enableUpcomingFeature("StrictConcurrency"), // simulate Swift 6 52 | .enableExperimentalFeature("StrictConcurrency"), 53 | .unsafeFlags(["-Xfrontend", "-warn-concurrency"]), 54 | .unsafeFlags(["-Xfrontend", "-enable-actor-data-race-checks"]) 55 | ] 56 | ), 57 | .testTarget( 58 | name: "NextcloudKitUnitTests", 59 | dependencies: ["NextcloudKit", "Mocker"], 60 | resources: [ 61 | .process("Resources") 62 | ]), 63 | .testTarget( 64 | name: "NextcloudKitIntegrationTests", 65 | dependencies: ["NextcloudKit", "Mocker"]) 66 | ] 67 | */ 68 | ) 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | Logo of NextcloudKit 8 |

NextcloudKit

9 | REUSE status 10 |
11 | 12 | ## Installation 13 | 14 | ### Carthage 15 | 16 | [Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with binary frameworks. To integrate **NextcloudKit** into your Xcode project using Carthage, specify it in your `Cartfile`: 17 | 18 | ``` 19 | github "nextcloud/NextcloudKit" "main" 20 | ``` 21 | 22 | Run `carthage update` to build the framework and drag the built `NextcloudKit.framework` into your Xcode project. 23 | 24 | ### Swift Package Manager 25 | 26 | [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. Once you have your Swift package set up, adding NextcloudKit as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`. 27 | 28 | ```swift 29 | dependencies: [ 30 | .package(url: "https://github.com/Nextcloud/NextcloudKit.git", .upToNextMajor(from: "2.0.0")) 31 | ] 32 | ``` 33 | 34 | ### Manual 35 | 36 | To add **NextcloudKit** to your app without Carthage, clone this repo and place it somewhere in your project folder. 37 | Then, add `NextcloudKit.xcodeproj` to your project, select your app target and add the NextcloudKit framework as an embedded binary under `General` and as a target dependency under `Build Phases`. 38 | 39 | ## Testing 40 | 41 | ### Unit Tests 42 | 43 | Since most functions in NextcloudKit involve a server call, you can mock the Alamofire session request. For that we use [Mocker](https://github.com/WeTransfer/Mocker). 44 | 45 | ### Integration Tests 46 | To run integration tests, you need a docker instance of a Nextcloud test server. [This](https://github.com/szaimen/nextcloud-easy-test) is a good start. 47 | 48 | 1. In `TestConstants.swift` you must specify your instance credentials. The app token is automatically generated. 49 | 50 | ```swift 51 | public class TestConstants { 52 | static let timeoutLong: Double = 400 53 | static let server = "http://localhost:8080" 54 | static let username = "admin" 55 | static let password = "admin" 56 | static let account = "\(username) \(server)" 57 | } 58 | ``` 59 | 60 | 2. Run the integration tests. 61 | 62 | ## Contribution Guidelines & License 63 | 64 | [GPLv3](LICENSE.txt) with [Apple app store exception](COPYING.iOS). 65 | 66 | Nextcloud doesn't require a CLA (Contributor License Agreement). The copyright belongs to all the individual contributors. Therefore we recommend that every contributor adds following line to the header of a file, if they changed it substantially: 67 | 68 | ``` 69 | @copyright Copyright (c) , () 70 | ``` 71 | 72 | Please read the [Code of Conduct](https://nextcloud.com/code-of-conduct/). This document offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other. 73 | 74 | More information how to contribute: [https://nextcloud.com/contribute/](https://nextcloud.com/contribute/) 75 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | version = 1 4 | SPDX-PackageName = "NextcloudKit" 5 | SPDX-PackageSupplier = "2024 Nextcloud GmbH and Nextcloud contributors" 6 | SPDX-PackageDownloadLocation = "https://github.com/nextcloud/NextcloudKit" 7 | 8 | [[annotations]] 9 | path = ["NextcloudKit.png", "NextcloudKit.pxd", "NextcloudKit.svg"] 10 | precedence = "aggregate" 11 | SPDX-FileCopyrightText = "2024 Nextcloud GmbH" 12 | SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" 13 | 14 | [[annotations]] 15 | path = ["Cartfile", "Gemfile", "Package.resolved"] 16 | precedence = "aggregate" 17 | SPDX-FileCopyrightText = "2022 Nextcloud GmbH and Nextcloud contributors" 18 | SPDX-License-Identifier = "GPL-3.0-or-later" 19 | 20 | [[annotations]] 21 | path = [".swiftlint.yml", "Sourcery/bin/sourcery", "Tests/NextcloudKitUnitTests/Resources/PollMock.json"] 22 | precedence = "aggregate" 23 | SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors" 24 | SPDX-License-Identifier = "GPL-3.0-or-later" 25 | -------------------------------------------------------------------------------- /Sourcery/EnvVars.stencil: -------------------------------------------------------------------------------- 1 | // 2 | // EnvVars.stencil.swift 3 | // NextcloudIntegrationTests 4 | // 5 | // SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 6 | // SPDX-License-Identifier: GPL-3.0-or-later 7 | // 8 | 9 | import Foundation 10 | 11 | /** 12 | This is generated from the .env-vars file in the root directory. If there is an environment variable here that is needed and not filled, please look into this file. 13 | */ 14 | public struct EnvVars { 15 | static let testUser = "{{ argument.TEST_USER }}" 16 | static let testAppPassword = "{{ argument.TEST_APP_PASSWORD }}" 17 | static let testServerUrl = "{{ argument.TEST_SERVER_URL }}" 18 | } 19 | -------------------------------------------------------------------------------- /Sourcery/bin/sourcery: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/NextcloudKit/13a1e67f10fee09cf8ab475d916973878d880c96/Sourcery/bin/sourcery -------------------------------------------------------------------------------- /Sources/NextcloudKit/Extensions/Data+Extension.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | extension Data { 8 | func printJson() { 9 | do { 10 | let json = try JSONSerialization.jsonObject(with: self, options: []) 11 | let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) 12 | guard let jsonString = String(data: data, encoding: .utf8) else { 13 | print("Invalid data") 14 | return 15 | } 16 | print(jsonString) 17 | } catch { 18 | print("Error: \(error.localizedDescription)") 19 | } 20 | } 21 | 22 | func jsonToString() -> String { 23 | do { 24 | let json = try JSONSerialization.jsonObject(with: self, options: []) 25 | let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) 26 | guard let jsonString = String(data: data, encoding: .utf8) else { 27 | print("Invalid data") 28 | return "" 29 | } 30 | return jsonString 31 | } catch { 32 | print("Error: \(error.localizedDescription)") 33 | } 34 | return "" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Extensions/Date+Extension.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | extension Date { 8 | func formatted(using format: String) -> String { 9 | NKLogFileManager.shared.convertDate(self, format: format) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Extensions/Image+Extension.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2020 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2021 Henrik Storch 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | #if os(macOS) 7 | import Foundation 8 | import AppKit 9 | 10 | public typealias UIImage = NSImage 11 | 12 | public extension NSImage { 13 | var cgImage: CGImage? { 14 | var proposedRect = CGRect(origin: .zero, size: size) 15 | 16 | return cgImage(forProposedRect: &proposedRect, 17 | context: nil, 18 | hints: nil) 19 | } 20 | 21 | func jpegData(compressionQuality: Double) -> Data? { 22 | if let bits = self.representations.first as? NSBitmapImageRep { 23 | return bits.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality]) 24 | } 25 | 26 | return nil 27 | } 28 | 29 | func pngData() -> Data? { 30 | if let bits = self.representations.first as? NSBitmapImageRep { 31 | return bits.representation(using: .png, properties: [:]) 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func resizeImage(size: CGSize, isAspectRation: Bool = true) -> NSImage? { 38 | if let bitmapRep = NSBitmapImageRep( 39 | bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), 40 | bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, 41 | colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0 42 | ) { 43 | bitmapRep.size = size 44 | NSGraphicsContext.saveGraphicsState() 45 | NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmapRep) 46 | draw(in: NSRect(x: 0, y: 0, width: size.width, height: size.height), from: .zero, operation: .copy, fraction: 1.0) 47 | NSGraphicsContext.restoreGraphicsState() 48 | 49 | let resizedImage = NSImage(size: size) 50 | resizedImage.addRepresentation(bitmapRep) 51 | return resizedImage 52 | } 53 | 54 | return nil 55 | } 56 | } 57 | #else 58 | import UIKit 59 | 60 | extension UIImage { 61 | internal func resizeImage(size: CGSize, isAspectRation: Bool = true) -> UIImage? { 62 | let originRatio = self.size.width / self.size.height 63 | let newRatio = size.width / size.height 64 | var newSize = size 65 | 66 | if isAspectRation { 67 | if originRatio < newRatio { 68 | newSize.height = size.height 69 | newSize.width = size.height * originRatio 70 | } else { 71 | newSize.width = size.width 72 | newSize.height = size.width / originRatio 73 | } 74 | } 75 | 76 | UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) 77 | self.draw(in: CGRect(origin: .zero, size: newSize)) 78 | defer { UIGraphicsEndImageContext() } 79 | return UIGraphicsGetImageFromCurrentImageContext() 80 | } 81 | } 82 | #endif 83 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Extensions/NSLock+Extension.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Claudio Cambra 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | extension NSLock { 8 | @discardableResult 9 | func perform(_ block: () throws -> T) rethrows -> T { 10 | lock() 11 | defer { unlock() } 12 | return try block() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Extensions/String+Extension.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2023 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | 8 | extension String { 9 | public var urlEncoded: String? { 10 | // + for historical reason, most web servers treat + as a replacement of whitespace 11 | // ?, & mark query pararmeter which should not be part of a url string, but added seperately 12 | let urlAllowedCharSet = CharacterSet.urlQueryAllowed.subtracting(["+", "?", "&"]) 13 | return addingPercentEncoding(withAllowedCharacters: urlAllowedCharSet) 14 | } 15 | 16 | public var encodedToUrl: URLConvertible? { 17 | return urlEncoded?.asUrl 18 | } 19 | 20 | public var asUrl: URLConvertible? { 21 | return try? asURL() 22 | } 23 | 24 | public var withRemovedFileExtension: String { 25 | return String(NSString(string: self).deletingPathExtension) 26 | } 27 | 28 | public var fileExtension: String { 29 | return String(NSString(string: self).pathExtension) 30 | } 31 | 32 | func parsedDate(using format: String) -> Date? { 33 | NKLogFileManager.shared.convertDate(self, format: format) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Log/NKLog.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | // Public logging helpers for apps using the NextcloudKit library. 8 | // These functions internally use `NKLogFileManager.shared`. 9 | 10 | @inlinable 11 | public func nkLog(debug message: String) { 12 | NKLogFileManager.shared.writeLog(debug: message) 13 | } 14 | 15 | @inlinable 16 | public func nkLog(info message: String) { 17 | NKLogFileManager.shared.writeLog(info: message) 18 | } 19 | 20 | @inlinable 21 | public func nkLog(warning message: String) { 22 | NKLogFileManager.shared.writeLog(warning: message) 23 | } 24 | 25 | @inlinable 26 | public func nkLog(error message: String) { 27 | NKLogFileManager.shared.writeLog(error: message) 28 | } 29 | 30 | @inlinable 31 | public func nkLog(success message: String) { 32 | NKLogFileManager.shared.writeLog(success: message) 33 | } 34 | 35 | @inlinable 36 | public func nkLog(network message: String) { 37 | NKLogFileManager.shared.writeLog(network: message) 38 | } 39 | 40 | @inlinable 41 | public func nkLog(start message: String) { 42 | NKLogFileManager.shared.writeLog(start: message) 43 | } 44 | 45 | @inlinable 46 | public func nkLog(stop message: String) { 47 | NKLogFileManager.shared.writeLog(stop: message) 48 | } 49 | 50 | /// Logs a custom tagged message. 51 | /// - Parameters: 52 | /// - tag: A custom uppercase tag, e.g. \"PUSH\", \"SYNC\", \"AUTH\". 53 | /// - emoji: the type tag .info, .debug, .warning, .error, .success .. 54 | /// - message: The message to log. 55 | @inlinable 56 | public func nkLog(tag: String, emoji: NKLogTagEmoji = .debug, message: String) { 57 | NKLogFileManager.shared.writeLog(tag: tag, emoji: emoji, message: message) 58 | } 59 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/Assistant/v1/NKTextProcessingTask.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import SwiftyJSON 6 | 7 | public class NKTextProcessingTask { 8 | public var id: Int? 9 | public var type: String? 10 | public var status: Int? 11 | public var userId: String? 12 | public var appId: String? 13 | public var input: String? 14 | public var output: String? 15 | public var identifier: String? 16 | public var completionExpectedAt: Double? 17 | 18 | public init(id: Int? = nil, type: String? = nil, status: Int? = nil, userId: String? = nil, appId: String? = nil, input: String? = nil, output: String? = nil, identifier: String? = nil, completionExpectedAt: Double? = nil) { 19 | self.id = id 20 | self.type = type 21 | self.status = status 22 | self.userId = userId 23 | self.appId = appId 24 | self.input = input 25 | self.output = output 26 | self.identifier = identifier 27 | self.completionExpectedAt = completionExpectedAt 28 | } 29 | 30 | public init?(json: JSON) { 31 | self.id = json["id"].int 32 | self.type = json["type"].string 33 | self.status = json["status"].int 34 | self.userId = json["userId"].string 35 | self.appId = json["appId"].string 36 | self.input = json["input"].string 37 | self.output = json["output"].string 38 | self.identifier = json["identifier"].string 39 | self.completionExpectedAt = json["completionExpectedAt"].double 40 | } 41 | 42 | static func deserialize(multipleObjects data: JSON) -> [NKTextProcessingTask]? { 43 | guard let allResults = data.array else { return nil } 44 | return allResults.compactMap(NKTextProcessingTask.init) 45 | } 46 | 47 | static func deserialize(singleObject data: JSON) -> NKTextProcessingTask? { 48 | NKTextProcessingTask(json: data) 49 | } 50 | 51 | public static func toV2(tasks: [NKTextProcessingTask]) -> TaskList { 52 | let tasks = tasks.map { task in 53 | AssistantTask( 54 | id: Int64(task.id ?? 0), 55 | type: task.type, 56 | status: String(task.status ?? 0), 57 | userId: task.userId, 58 | appId: task.appId, 59 | input: TaskInput(input: task.input), 60 | output: TaskOutput(output: task.output), 61 | completionExpectedAt: Int(task.completionExpectedAt ?? 0), 62 | progress: nil, 63 | lastUpdated: nil, 64 | scheduledAt: nil, 65 | endedAt: nil 66 | ) 67 | } 68 | 69 | return TaskList(tasks: tasks) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/Assistant/v1/NKTextProcessingTaskType.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import SwiftyJSON 6 | 7 | public class NKTextProcessingTaskType { 8 | public var id: String? 9 | public var name: String? 10 | public var description: String? 11 | 12 | public init(id: String? = nil, name: String? = nil, description: String? = nil) { 13 | self.id = id 14 | self.name = name 15 | self.description = description 16 | } 17 | 18 | public init?(json: JSON) { 19 | self.id = json["id"].string 20 | self.name = json["name"].string 21 | self.description = json["description"].string 22 | } 23 | 24 | static func deserialize(multipleObjects data: JSON) -> [NKTextProcessingTaskType]? { 25 | guard let allResults = data.array else { return nil } 26 | return allResults.compactMap(NKTextProcessingTaskType.init) 27 | } 28 | 29 | public static func toV2(type: [NKTextProcessingTaskType]) -> TaskTypes { 30 | let types = type.map { type in 31 | TaskTypeData(id: type.id, name: type.name, description: type.description, inputShape: nil, outputShape: nil) 32 | } 33 | 34 | return TaskTypes(types: types) 35 | } 36 | } 37 | 38 | 39 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/Assistant/v2/TaskList.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import SwiftyJSON 6 | 7 | public struct TaskList: Codable { 8 | public var tasks: [AssistantTask] 9 | 10 | static func deserialize(from data: JSON) -> TaskList? { 11 | let tasks = data.arrayValue.map { taskJson in 12 | AssistantTask( 13 | id: taskJson["id"].int64Value, 14 | type: taskJson["type"].string, 15 | status: taskJson["status"].string, 16 | userId: taskJson["userId"].string, 17 | appId: taskJson["appId"].string, 18 | input: TaskInput(input: taskJson["input"]["input"].string), 19 | output: TaskOutput(output: taskJson["output"]["output"].string), 20 | completionExpectedAt: taskJson["completionExpectedAt"].int, 21 | progress: taskJson["progress"].int, 22 | lastUpdated: taskJson["lastUpdated"].int, 23 | scheduledAt: taskJson["scheduledAt"].int, 24 | endedAt: taskJson["endedAt"].int 25 | ) 26 | } 27 | 28 | return TaskList(tasks: tasks) 29 | } 30 | } 31 | 32 | public struct AssistantTask: Codable { 33 | public let id: Int64 34 | public let type: String? 35 | public let status: String? 36 | public let userId: String? 37 | public let appId: String? 38 | public let input: TaskInput? 39 | public let output: TaskOutput? 40 | public let completionExpectedAt: Int? 41 | public var progress: Int? 42 | public let lastUpdated: Int? 43 | public let scheduledAt: Int? 44 | public let endedAt: Int? 45 | 46 | public init(id: Int64, type: String?, status: String?, userId: String?, appId: String?, input: TaskInput?, output: TaskOutput?, completionExpectedAt: Int?, progress: Int? = nil, lastUpdated: Int?, scheduledAt: Int?, endedAt: Int?) { 47 | self.id = id 48 | self.type = type 49 | self.status = status 50 | self.userId = userId 51 | self.appId = appId 52 | self.input = input 53 | self.output = output 54 | self.completionExpectedAt = completionExpectedAt 55 | self.progress = progress 56 | self.lastUpdated = lastUpdated 57 | self.scheduledAt = scheduledAt 58 | self.endedAt = endedAt 59 | } 60 | 61 | static func deserialize(from data: JSON) -> AssistantTask? { 62 | let task = AssistantTask( 63 | id: data["id"].int64Value, 64 | type: data["type"].string, 65 | status: data["status"].string, 66 | userId: data["userId"].string, 67 | appId: data["appId"].string, 68 | input: TaskInput(input: data["input"]["input"].string), 69 | output: TaskOutput(output: data["output"]["output"].string), 70 | completionExpectedAt: data["completionExpectedAt"].int, 71 | progress: data["progress"].int, 72 | lastUpdated: data["lastUpdated"].int, 73 | scheduledAt: data["scheduledAt"].int, 74 | endedAt: data["endedAt"].int 75 | ) 76 | 77 | return task 78 | } 79 | } 80 | 81 | public struct TaskInput: Codable { 82 | public var input: String? 83 | 84 | public init(input: String? = nil) { 85 | self.input = input 86 | } 87 | } 88 | 89 | public struct TaskOutput: Codable { 90 | public var output: String? 91 | 92 | public init(output: String? = nil) { 93 | self.output = output 94 | } 95 | } 96 | 97 | 98 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/Assistant/v2/TaskTypes.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import SwiftyJSON 6 | 7 | public struct TaskTypes: Codable { 8 | public let types: [TaskTypeData] 9 | 10 | static func deserialize(from data: JSON) -> TaskTypes? { 11 | var taskTypes: [TaskTypeData] = [] 12 | 13 | for (key, subJson) in data { 14 | let taskTypeData = TaskTypeData( 15 | id: key, 16 | name: subJson["name"].string, 17 | description: subJson["description"].string, 18 | inputShape: subJson["inputShape"].dictionary != nil ? TaskInputShape( 19 | input: subJson["inputShape"]["input"].dictionary != nil ? Shape( 20 | name: subJson["inputShape"]["input"]["name"].stringValue, 21 | description: subJson["inputShape"]["input"]["description"].stringValue, 22 | type: subJson["inputShape"]["input"]["type"].stringValue 23 | ) : nil 24 | ) : nil, 25 | outputShape: subJson["outputShape"].dictionary != nil ? TaskOutputShape( 26 | output: subJson["outputShape"]["output"].dictionary != nil ? Shape( 27 | name: subJson["outputShape"]["output"]["name"].stringValue, 28 | description: subJson["outputShape"]["output"]["description"].stringValue, 29 | type: subJson["outputShape"]["output"]["type"].stringValue 30 | ) : nil 31 | ) : nil 32 | ) 33 | 34 | taskTypes.append(taskTypeData) 35 | } 36 | 37 | return TaskTypes(types: taskTypes) 38 | } 39 | } 40 | 41 | public struct TaskTypeData: Codable { 42 | public let id: String? 43 | public let name: String? 44 | public let description: String? 45 | public let inputShape: TaskInputShape? 46 | public let outputShape: TaskOutputShape? 47 | 48 | public init(id: String?, name: String?, description: String?, inputShape: TaskInputShape?, outputShape: TaskOutputShape?) { 49 | self.id = id 50 | self.name = name 51 | self.description = description 52 | self.inputShape = inputShape 53 | self.outputShape = outputShape 54 | } 55 | } 56 | 57 | public struct TaskInputShape: Codable { 58 | public let input: Shape? 59 | 60 | public init(input: Shape?) { 61 | self.input = input 62 | } 63 | } 64 | 65 | public struct TaskOutputShape: Codable { 66 | public let output: Shape? 67 | 68 | public init(output: Shape?) { 69 | self.output = output 70 | } 71 | } 72 | 73 | public struct Shape: Codable { 74 | public let name: String 75 | public let description: String 76 | public let type: String 77 | 78 | public init(name: String, description: String, type: String) { 79 | self.name = name 80 | self.description = description 81 | self.type = type 82 | } 83 | } 84 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/EditorDetails/NKEditorDetailsConverter.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | public enum NKEditorDetailsConverter { 8 | 9 | /// Parses and converts raw JSON `Data` into `[NKEditorDetailsEditors]` and `[NKEditorDetailsCreators]`. 10 | /// - Parameter data: Raw JSON `Data` from the editors/creators endpoint. 11 | /// - Returns: A tuple with editors and creators. 12 | /// - Throws: Decoding error if parsing fails. 13 | public static func from(data: Data) throws -> (editors: [NKEditorDetailsEditor], creators: [NKEditorDetailsCreator]) { 14 | let decoded = try JSONDecoder().decode(NKEditorDetailsResponse.self, from: data) 15 | let editors = decoded.ocs.data.editorsArray() 16 | let creators = decoded.ocs.data.creatorsArray() 17 | 18 | if NKLogFileManager.shared.logLevel == .verbose { 19 | data.printJson() 20 | } 21 | 22 | return (editors, creators) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/EditorDetails/NKEditorDetailsResponse+NKConversion.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | public extension NKEditorDetailsResponse.OCS.DataClass { 8 | func editorsArray() -> [NKEditorDetailsEditor] { 9 | Array(editors.values) 10 | } 11 | 12 | func creatorsArray() -> [NKEditorDetailsCreator] { 13 | Array(creators.values) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/EditorDetails/NKEditorDetailsResponse.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | public struct NKEditorDetailsResponse: Codable, Sendable { 8 | public let ocs: OCS 9 | 10 | public struct OCS: Codable, Sendable { 11 | public let data: DataClass 12 | 13 | public struct DataClass: Codable, Sendable { 14 | public let editors: [String: NKEditorDetailsEditor] 15 | public let creators: [String: NKEditorDetailsCreator] 16 | } 17 | } 18 | } 19 | 20 | public struct NKEditorTemplateResponse: Codable, Sendable { 21 | public let ocs: OCS 22 | 23 | public struct OCS: Codable, Sendable { 24 | public let data: DataClass 25 | 26 | public struct DataClass: Codable, Sendable { 27 | public let editors: [NKEditorTemplate] 28 | } 29 | } 30 | } 31 | 32 | public struct NKEditorDetailsEditor: Codable, Sendable { 33 | public let identifier: String 34 | public let mimetypes: [String] 35 | public let name: String 36 | public let optionalMimetypes: [String] 37 | public let secure: Bool 38 | 39 | enum CodingKeys: String, CodingKey { 40 | case identifier = "id" 41 | case mimetypes 42 | case name 43 | case optionalMimetypes 44 | case secure 45 | } 46 | } 47 | 48 | public struct NKEditorDetailsCreator: Codable, Sendable { 49 | public let identifier: String 50 | public let templates: Bool 51 | public let mimetype: String 52 | public let name: String 53 | public let editor: String 54 | public let ext: String 55 | 56 | enum CodingKeys: String, CodingKey { 57 | case identifier = "id" 58 | case templates 59 | case mimetype 60 | case name 61 | case editor 62 | case ext = "extension" 63 | } 64 | } 65 | 66 | public struct NKEditorTemplate: Codable, Sendable { 67 | public var ext: String 68 | public var identifier: String 69 | public var name: String 70 | public var preview: String 71 | 72 | enum CodingKeys: String, CodingKey { 73 | case ext = "extension" 74 | case identifier = "id" 75 | case name 76 | case preview 77 | } 78 | 79 | public init(ext: String = "", identifier: String = "", name: String = "", preview: String = "") { 80 | self.ext = ext 81 | self.identifier = identifier 82 | self.name = name 83 | self.preview = preview 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKActivity.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKActivity: NSObject { 9 | public var app = "" 10 | public var date = Date() 11 | public var idActivity: Int = 0 12 | public var icon = "" 13 | public var link = "" 14 | public var message = "" 15 | public var messageRich: Data? 16 | public var objectId: Int = 0 17 | public var objectName = "" 18 | public var objectType = "" 19 | public var previews: Data? 20 | public var subject = "" 21 | public var subjectRich: Data? 22 | public var type = "" 23 | public var user = "" 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKComments.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKComments: NSObject { 9 | public var actorDisplayName = "" 10 | public var actorId = "" 11 | public var actorType = "" 12 | public var creationDateTime = Date() 13 | public var isUnread: Bool = false 14 | public var message = "" 15 | public var messageId = "" 16 | public var objectId = "" 17 | public var objectType = "" 18 | public var path = "" 19 | public var verb = "" 20 | } 21 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKDownloadLimit.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Iva Horn 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | /// 8 | /// Data model for a download limit as returned in the WebDAV response for file properties. 9 | /// 10 | /// Each relates to a share of a file and is optionally provided by the [Files Download Limit](https://github.com/nextcloud/files_downloadlimit) app for Nextcloud server. 11 | /// 12 | public struct NKDownloadLimit: Sendable { 13 | /// 14 | /// The number of downloads which already happened. 15 | /// 16 | public let count: Int 17 | 18 | /// 19 | /// Total number of allowed downloas. 20 | /// 21 | public let limit: Int 22 | 23 | /// 24 | /// The token identifying the related share. 25 | /// 26 | public let token: String 27 | 28 | init(count: Int, limit: Int, token: String) { 29 | self.count = count 30 | self.limit = limit 31 | self.token = token 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKExternalSite.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKExternalSite: NSObject { 9 | public var icon = "" 10 | public var idExternalSite: Int = 0 11 | public var lang = "" 12 | public var name = "" 13 | public var order: Int = 0 14 | public var type = "" 15 | public var url = "" 16 | } 17 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKFile.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public struct NKFile: Sendable { 9 | public var account: String 10 | public var classFile: String 11 | public var commentsUnread: Bool 12 | public var contentType: String 13 | public var checksums: String 14 | public var creationDate: Date? 15 | public var dataFingerprint: String 16 | public var date: Date 17 | public var directory: Bool 18 | public var downloadURL: String 19 | 20 | /// 21 | /// Download limits for shares of this file. 22 | /// 23 | public var downloadLimits: [NKDownloadLimit] 24 | 25 | public var e2eEncrypted: Bool 26 | public var etag: String 27 | public var favorite: Bool 28 | public var fileId: String 29 | public var fileName: String 30 | public var hasPreview: Bool 31 | public var iconName: String 32 | public var mountType: String 33 | public var name: String 34 | public var note: String 35 | public var ocId: String 36 | public var ownerId: String 37 | public var ownerDisplayName: String 38 | public var lock: Bool 39 | public var lockOwner: String 40 | public var lockOwnerEditor: String 41 | public var lockOwnerType: Int 42 | public var lockOwnerDisplayName: String 43 | public var lockTime: Date? 44 | public var lockTimeOut: Date? 45 | public var path: String 46 | public var permissions: String 47 | public var quotaUsedBytes: Int64 48 | public var quotaAvailableBytes: Int64 49 | public var resourceType: String 50 | public var richWorkspace: String? 51 | public var sharePermissionsCollaborationServices: Int 52 | public var sharePermissionsCloudMesh: [String] 53 | public var shareType: [Int] 54 | public var size: Int64 55 | public var serverUrl: String 56 | public var tags: [String] 57 | public var trashbinFileName: String 58 | public var trashbinOriginalLocation: String 59 | public var trashbinDeletionTime: Date 60 | public var uploadDate: Date? 61 | public var urlBase: String 62 | public var user: String 63 | public var userId: String 64 | public var latitude: Double 65 | public var longitude: Double 66 | public var altitude: Double 67 | public var height: Double 68 | public var width: Double 69 | public var hidden: Bool 70 | /// If this is not empty, the media is a live photo. New media gets this straight from server, but old media needs to be detected as live photo (look isFlaggedAsLivePhotoByServer) 71 | public var livePhotoFile: String 72 | /// Indicating if the file is sent as a live photo from the server, or if we should detect it as such and convert it client-side 73 | public var isFlaggedAsLivePhotoByServer: Bool 74 | /// 75 | public var datePhotosOriginal: Date? 76 | /// 77 | public struct ChildElement { 78 | let name: String 79 | let text: String? 80 | } 81 | public var exifPhotos: [[String: String?]] 82 | public var placePhotos: String? 83 | public var typeIdentifier: String 84 | 85 | public init( 86 | account: String = "", 87 | classFile: String = "", 88 | commentsUnread: Bool = false, 89 | contentType: String = "", 90 | checksums: String = "", 91 | creationDate: Date? = nil, 92 | dataFingerprint: String = "", 93 | date: Date = Date(), 94 | directory: Bool = false, 95 | downloadURL: String = "", 96 | downloadLimits: [NKDownloadLimit] = .init(), 97 | e2eEncrypted: Bool = false, 98 | etag: String = "", 99 | favorite: Bool = false, 100 | fileId: String = "", 101 | fileName: String = "", 102 | hasPreview: Bool = false, 103 | iconName: String = "", 104 | mountType: String = "", 105 | name: String = "", 106 | note: String = "", 107 | ocId: String = "", 108 | ownerId: String = "", 109 | ownerDisplayName: String = "", 110 | lock: Bool = false, 111 | lockOwner: String = "", 112 | lockOwnerEditor: String = "", 113 | lockOwnerType: Int = 0, 114 | lockOwnerDisplayName: String = "", 115 | lockTime: Date? = nil, 116 | lockTimeOut: Date? = nil, 117 | path: String = "", 118 | permissions: String = "", 119 | quotaUsedBytes: Int64 = 0, 120 | quotaAvailableBytes: Int64 = 0, 121 | resourceType: String = "", 122 | richWorkspace: String? = nil, 123 | sharePermissionsCollaborationServices: Int = 0, 124 | sharePermissionsCloudMesh: [String] = [], 125 | shareType: [Int] = [], 126 | size: Int64 = 0, 127 | serverUrl: String = "", 128 | tags: [String] = [], 129 | trashbinFileName: String = "", 130 | trashbinOriginalLocation: String = "", 131 | trashbinDeletionTime: Date = Date(), 132 | uploadDate: Date? = nil, 133 | urlBase: String = "", 134 | user: String = "", 135 | userId: String = "", 136 | latitude: Double = 0, 137 | longitude: Double = 0, 138 | altitude: Double = 0, 139 | height: Double = 0, 140 | width: Double = 0, 141 | hidden: Bool = false, 142 | livePhotoFile: String = "", 143 | isFlaggedAsLivePhotoByServer: Bool = false, 144 | datePhotosOriginal: Date? = nil, 145 | exifPhotos: [[String : String?]] = .init(), 146 | placePhotos: String? = nil, 147 | typeIdentifier: String = "") { 148 | 149 | self.account = account 150 | self.classFile = classFile 151 | self.commentsUnread = commentsUnread 152 | self.contentType = contentType 153 | self.checksums = checksums 154 | self.creationDate = creationDate 155 | self.dataFingerprint = dataFingerprint 156 | self.date = date 157 | self.directory = directory 158 | self.downloadURL = downloadURL 159 | self.downloadLimits = downloadLimits 160 | self.e2eEncrypted = e2eEncrypted 161 | self.etag = etag 162 | self.favorite = favorite 163 | self.fileId = fileId 164 | self.fileName = fileName 165 | self.hasPreview = hasPreview 166 | self.iconName = iconName 167 | self.mountType = mountType 168 | self.name = name 169 | self.note = note 170 | self.ocId = ocId 171 | self.ownerId = ownerId 172 | self.ownerDisplayName = ownerDisplayName 173 | self.lock = lock 174 | self.lockOwner = lockOwner 175 | self.lockOwnerEditor = lockOwnerEditor 176 | self.lockOwnerType = lockOwnerType 177 | self.lockOwnerDisplayName = lockOwnerDisplayName 178 | self.lockTime = lockTime 179 | self.lockTimeOut = lockTimeOut 180 | self.path = path 181 | self.permissions = permissions 182 | self.quotaUsedBytes = quotaUsedBytes 183 | self.quotaAvailableBytes = quotaAvailableBytes 184 | self.resourceType = resourceType 185 | self.richWorkspace = richWorkspace 186 | self.sharePermissionsCollaborationServices = sharePermissionsCollaborationServices 187 | self.sharePermissionsCloudMesh = sharePermissionsCloudMesh 188 | self.shareType = shareType 189 | self.size = size 190 | self.serverUrl = serverUrl 191 | self.tags = tags 192 | self.trashbinFileName = trashbinFileName 193 | self.trashbinOriginalLocation = trashbinOriginalLocation 194 | self.trashbinDeletionTime = trashbinDeletionTime 195 | self.uploadDate = uploadDate 196 | self.urlBase = urlBase 197 | self.user = user 198 | self.userId = userId 199 | self.latitude = latitude 200 | self.longitude = longitude 201 | self.altitude = altitude 202 | self.height = height 203 | self.width = width 204 | self.hidden = hidden 205 | self.livePhotoFile = livePhotoFile 206 | self.isFlaggedAsLivePhotoByServer = isFlaggedAsLivePhotoByServer 207 | self.datePhotosOriginal = datePhotosOriginal 208 | self.exifPhotos = exifPhotos 209 | self.placePhotos = placePhotos 210 | self.typeIdentifier = typeIdentifier 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKProperties.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | /// 7 | /// Definition of properties used for decoding in ``NKDataFileXML``. 8 | /// 9 | public enum NKProperties: String, CaseIterable { 10 | /// DAV 11 | case displayname = "" 12 | 13 | /// 14 | /// Download limits for shares of a file as optionally provided by the [Files Download Limit](https://github.com/nextcloud/files_downloadlimit) app for Nextcloud server. 15 | /// 16 | case downloadLimit = "" 17 | 18 | case getlastmodified = "" 19 | case getetag = "" 20 | case getcontenttype = "" 21 | case resourcetype = "" 22 | case quotaavailablebytes = "" 23 | case quotausedbytes = "" 24 | case getcontentlength = "" 25 | /// owncloud.org 26 | case permissions = "" 27 | case id = "" 28 | case fileid = "" 29 | case size = "" 30 | case favorite = "" 31 | case sharetypes = "" 32 | case ownerid = "" 33 | case ownerdisplayname = "" 34 | case commentsunread = "" 35 | case checksums = "" 36 | case downloadURL = "" 37 | case datafingerprint = "" 38 | /// nextcloud.org 39 | case creationtime = "" 40 | case uploadtime = "" 41 | case isencrypted = "" 42 | case haspreview = "" 43 | case mounttype = "" 44 | case richworkspace = "" 45 | case note = "" 46 | case lock = "" 47 | case lockowner = "" 48 | case lockownereditor = "" 49 | case lockownerdisplayname = "" 50 | case lockownertype = "" 51 | case locktime = "" 52 | case locktimeout = "" 53 | case systemtags = "" 54 | case filemetadatasize = "" 55 | case filemetadatagps = "" 56 | case metadataphotosexif = "" 57 | case metadataphotosgps = "" 58 | case metadataphotosoriginaldatetime = "" 59 | case metadataphotoplace = "" 60 | case metadataphotossize = "" 61 | case metadatafileslivephoto = "" 62 | case hidden = "" 63 | /// open-collaboration-services.org 64 | case sharepermissionscollaboration = "" 65 | /// open-cloud-mesh.org 66 | case sharepermissionscloudmesh = "" 67 | 68 | static func properties(createProperties: [NKProperties]?, removeProperties: [NKProperties] = []) -> String { 69 | var properties = allCases.map { $0.rawValue }.joined() 70 | if let createProperties { 71 | properties = "" 72 | properties = createProperties.map { $0.rawValue }.joined(separator: "") 73 | } 74 | for removeProperty in removeProperties { 75 | properties = properties.replacingOccurrences(of: removeProperty.rawValue, with: "") 76 | } 77 | return properties 78 | } 79 | 80 | static func trashProperties() -> String { 81 | let properties: [String] = [displayname.rawValue, getcontenttype.rawValue, resourcetype.rawValue, id.rawValue, fileid.rawValue, size.rawValue, haspreview.rawValue, "", "", ""] 82 | return properties.joined() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKRecommendedFiles.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import SwiftyXMLParser 7 | 8 | public class NKRecommendation: NSObject { 9 | public var id: String 10 | public var timestamp: Date? 11 | public var name: String 12 | public var directory: String 13 | public var extensionType: String 14 | public var mimeType: String 15 | public var hasPreview: Bool 16 | public var reason: String 17 | 18 | public init(id: String, timestamp: Date?, name: String, directory: String, extensionType: String, mimeType: String, hasPreview: Bool, reason: String) { 19 | self.id = id 20 | self.timestamp = timestamp 21 | self.name = name 22 | self.directory = directory 23 | self.extensionType = extensionType 24 | self.mimeType = mimeType 25 | self.hasPreview = hasPreview 26 | self.reason = reason 27 | } 28 | } 29 | 30 | class XMLToRecommendationParser { 31 | func parse(xml: String) -> [NKRecommendation]? { 32 | guard let data = xml.data(using: .utf8) else { return nil } 33 | let xml = XML.parse(data) 34 | 35 | // Parsing "enabled" 36 | guard let enabledString = xml["ocs", "data", "enabled"].text, 37 | Bool(enabledString == "1") 38 | else { 39 | return nil 40 | } 41 | 42 | // Parsing "recommendations" 43 | var recommendations: [NKRecommendation] = [] 44 | let elements = xml["ocs", "data", "recommendations", "element"] 45 | 46 | for element in elements { 47 | let id = element["id"].text ?? "" 48 | var timestamp: Date? 49 | if let timestampDouble = element["timestamp"].double, timestampDouble > 0 { 50 | timestamp = Date(timeIntervalSince1970: timestampDouble) 51 | } 52 | let name = element["name"].text ?? "" 53 | let directory = element["directory"].text ?? "" 54 | let extensionType = element["extension"].text ?? "" 55 | let mimeType = element["mimeType"].text ?? "" 56 | let hasPreview = element["hasPreview"].text == "1" 57 | let reason = element["reason"].text ?? "" 58 | 59 | let recommendation = NKRecommendation( 60 | id: id, 61 | timestamp: timestamp, 62 | name: name, 63 | directory: directory, 64 | extensionType: extensionType, 65 | mimeType: mimeType, 66 | hasPreview: hasPreview, 67 | reason: reason 68 | ) 69 | recommendations.append(recommendation) 70 | } 71 | 72 | return recommendations 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKRichdocumentsTemplate.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKRichdocumentsTemplate: NSObject { 9 | public var delete = "" 10 | public var ext = "" 11 | public var name = "" 12 | public var preview = "" 13 | public var templateId: Int = 0 14 | public var type = "" 15 | } 16 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKShareAccounts.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2023 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | #if os(iOS) 7 | import UIKit 8 | 9 | /// 10 | /// Facility to read and write partial account information shared among apps of the same security group. 11 | /// This is the foundation for the quick account selection feature on login. 12 | /// 13 | public class NKShareAccounts: NSObject { 14 | /// 15 | /// Data transfer object to pass between ``NKShareAccounts`` and calling code. 16 | /// 17 | public class DataAccounts: NSObject, Identifiable { 18 | /// 19 | /// The server address of the account. 20 | /// 21 | public var url: String 22 | 23 | /// 24 | /// The login name for the account. 25 | /// 26 | public var user: String 27 | 28 | /// 29 | /// The display name of the account. 30 | /// 31 | public var name: String? 32 | 33 | /// 34 | /// The ccount profile picture. 35 | /// 36 | public var image: UIImage? 37 | 38 | public init(withUrl url: String, user: String, name: String? = nil, image: UIImage? = nil) { 39 | self.url = url 40 | self.user = user 41 | self.name = name 42 | self.image = image 43 | } 44 | } 45 | 46 | internal struct Account: Codable { 47 | let url: String 48 | let user: String 49 | let name: String? 50 | } 51 | 52 | internal struct Apps: Codable { 53 | let apps: [String: [Account]]? 54 | } 55 | 56 | internal let fileName: String = "accounts.json" 57 | internal let directoryAccounts: String = "Library/Application Support/NextcloudAccounts" 58 | 59 | /// 60 | /// Store shared account information in the app group container. 61 | /// 62 | /// - Parameters: 63 | /// - directory: the group directory of share the accounts (group.com.nextcloud.apps), use the func containerURL(forSecurityApplicationGroupIdentifier groupIdentifier: String) -> URL? // Available for OS X in 10.8.3. 64 | /// - app: the name of app 65 | /// - dataAccounts: the accounts data 66 | public func putShareAccounts(at directory: URL, app: String, dataAccounts: [DataAccounts]) -> Error? { 67 | 68 | var apps: [String: [Account]] = [:] 69 | var accounts: [Account] = [] 70 | let url = directory.appendingPathComponent(directoryAccounts + "/" + fileName) 71 | 72 | do { 73 | try FileManager.default.createDirectory(at: directory.appendingPathComponent(directoryAccounts), withIntermediateDirectories: true) 74 | } catch { } 75 | 76 | // Add data account and image 77 | for dataAccount in dataAccounts { 78 | if let image = dataAccount.image { 79 | do { 80 | let filePathImage = getFileNamePathImage(at: directory, url: dataAccount.url, user: dataAccount.user) 81 | try image.pngData()?.write(to: filePathImage, options: .atomic) 82 | } catch { } 83 | } 84 | let account = Account(url: dataAccount.url, user: dataAccount.user, name: dataAccount.name) 85 | accounts.append(account) 86 | } 87 | apps[app] = accounts 88 | 89 | // Decode 90 | do { 91 | let data = try Data(contentsOf: url) 92 | let json = try JSONDecoder().decode(Apps.self, from: data) 93 | if let appsDecoder = json.apps { 94 | let otherApps = appsDecoder.filter({ $0.key != app }) 95 | apps.merge(otherApps) { current, _ in current} 96 | } 97 | } catch { } 98 | 99 | // Encode 100 | do { 101 | let data = try JSONEncoder().encode(Apps(apps: apps)) 102 | try data.write(to: url) 103 | } catch let error { 104 | return error 105 | } 106 | return nil 107 | } 108 | 109 | /// 110 | /// Read the shared account information from the app group container. 111 | /// 112 | /// - Parameters: 113 | /// - directory: the group directory of share the accounts (group.com.nextcloud.apps), use the func containerURL(forSecurityApplicationGroupIdentifier groupIdentifier: String) -> URL? // Available for OS X in 10.8.3. 114 | /// - application: the UIApplication used for verify if the app(s) is still installed 115 | public func getShareAccount(at directory: URL, application: UIApplication) -> [DataAccounts]? { 116 | 117 | var dataAccounts: [DataAccounts] = [] 118 | let url = directory.appendingPathComponent(directoryAccounts + "/" + fileName) 119 | 120 | do { 121 | let data = try Data(contentsOf: url) 122 | let json = try JSONDecoder().decode(Apps.self, from: data) 123 | if let appsDecoder = json.apps { 124 | for appDecoder in appsDecoder { 125 | let app = appDecoder.key 126 | let accounts = appDecoder.value 127 | if let url = URL(string: app + "://"), application.canOpenURL(url) { 128 | for account in accounts { 129 | if dataAccounts.first(where: { $0.url == account.url && $0.user == account.user }) == nil { 130 | let filePathImage = getFileNamePathImage(at: directory, url: account.url, user: account.user) 131 | let image = UIImage(contentsOfFile: filePathImage.path) 132 | let account = DataAccounts(withUrl: account.url, user: account.user, name: account.name, image: image) 133 | dataAccounts.append(account) 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } catch { } 140 | 141 | return dataAccounts.isEmpty ? nil : dataAccounts 142 | } 143 | 144 | private func getFileNamePathImage(at directory: URL, url: String, user: String) -> URL { 145 | 146 | let userBaseUrl = user + "-" + (URL(string: url)?.host ?? "") 147 | let fileName = userBaseUrl + "-\(user).png" 148 | return directory.appendingPathComponent(directoryAccounts + "/" + fileName) 149 | } 150 | } 151 | #endif 152 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKSharee.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKSharee: NSObject { 9 | public var circleInfo = "" 10 | public var circleOwner = "" 11 | public var label = "" 12 | public var name = "" 13 | public var shareType: Int = 0 14 | public var shareWith = "" 15 | public var uuid = "" 16 | public var userClearAt: Date? 17 | public var userIcon = "" 18 | public var userMessage = "" 19 | public var userStatus = "" 20 | } 21 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKTermsOfService.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | public class NKTermsOfService: NSObject { 8 | public var meta: Meta? 9 | public var data: OCSData? 10 | 11 | public override init() { 12 | super.init() 13 | } 14 | 15 | public func loadFromJSON(_ jsonData: Data) -> Bool { 16 | do { 17 | let decodedResponse = try JSONDecoder().decode(OCSResponse.self, from: jsonData) 18 | self.meta = decodedResponse.ocs.meta 19 | self.data = decodedResponse.ocs.data 20 | return true 21 | } catch { 22 | debugPrint("[DEBUG] decode error:", error) 23 | return false 24 | } 25 | } 26 | 27 | public func getTerms() -> [Term]? { 28 | return data?.terms 29 | } 30 | 31 | public func getLanguages() -> [String: String]? { 32 | return data?.languages 33 | } 34 | 35 | public func hasUserSigned() -> Bool { 36 | return data?.hasSigned ?? false 37 | } 38 | 39 | public func getMeta() -> Meta? { 40 | return meta 41 | } 42 | 43 | // MARK: - Codable 44 | private class OCSResponse: Codable { 45 | let ocs: OCS 46 | } 47 | 48 | private class OCS: Codable { 49 | let meta: Meta 50 | let data: OCSData 51 | } 52 | 53 | public class Meta: Codable { 54 | public let status: String 55 | public let statuscode: Int 56 | public let message: String 57 | } 58 | 59 | public class OCSData: Codable { 60 | public let terms: [Term] 61 | public let languages: [String: String] 62 | public let hasSigned: Bool 63 | } 64 | 65 | public class Term: Codable { 66 | public let id: Int 67 | public let countryCode: String 68 | public let languageCode: String 69 | public let body: String 70 | public let renderedBody: String 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKTrash.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public struct NKTrash: Sendable { 9 | public var ocId = "" 10 | public var contentType = "" 11 | public var typeIdentifier = "" 12 | public var date = Date() 13 | public var directory: Bool = false 14 | public var fileId = "" 15 | public var fileName = "" 16 | public var filePath = "" 17 | public var hasPreview: Bool = false 18 | public var iconName = "" 19 | public var size: Int64 = 0 20 | public var classFile = "" 21 | public var trashbinFileName = "" 22 | public var trashbinOriginalLocation = "" 23 | public var trashbinDeletionTime = Date() 24 | } 25 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKUserProfile.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKUserProfile: NSObject { 9 | public var address = "" 10 | public var backend = "" 11 | public var backendCapabilitiesSetDisplayName: Bool = false 12 | public var backendCapabilitiesSetPassword: Bool = false 13 | public var displayName = "" 14 | public var email = "" 15 | public var enabled: Bool = false 16 | public var groups: [String] = [] 17 | public var language = "" 18 | public var lastLogin: Int64 = 0 19 | public var locale = "" 20 | public var organisation = "" 21 | public var phone = "" 22 | public var quota: Int64 = 0 23 | public var quotaFree: Int64 = 0 24 | public var quotaRelative: Double = 0 25 | public var quotaTotal: Int64 = 0 26 | public var quotaUsed: Int64 = 0 27 | public var storageLocation = "" 28 | public var subadmin: [String] = [] 29 | public var twitter = "" 30 | public var userId = "" 31 | public var website = "" 32 | } 33 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Models/NKUserStatus.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKUserStatus: NSObject { 9 | public var clearAt: Date? 10 | public var clearAtTime: String? 11 | public var clearAtType: String? 12 | public var icon: String? 13 | public var id: String? 14 | public var message: String? 15 | public var predefined: Bool = false 16 | public var status: String? 17 | public var userId: String? 18 | } 19 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NKInterceptor.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | 8 | final class NKInterceptor: RequestInterceptor, Sendable { 9 | let nkCommonInstance: NKCommon 10 | 11 | init(nkCommonInstance: NKCommon) { 12 | self.nkCommonInstance = nkCommonInstance 13 | } 14 | 15 | func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { 16 | // Log request URL in verbose mode 17 | if NKLogFileManager.shared.logLevel == .verbose, 18 | let url = urlRequest.url?.absoluteString { 19 | nkLog(debug: "Interceptor request url: \(url)") 20 | } 21 | 22 | // Skip check if explicitly disabled 23 | if let checkInterceptor = urlRequest.value(forHTTPHeaderField: nkCommonInstance.headerCheckInterceptor), 24 | checkInterceptor == "false" { 25 | return completion(.success(urlRequest)) 26 | } 27 | 28 | // Check for special error states via group defaults 29 | if let account = urlRequest.value(forHTTPHeaderField: nkCommonInstance.headerAccount), 30 | let groupDefaults = UserDefaults(suiteName: nkCommonInstance.groupIdentifier) { 31 | 32 | if let array = groupDefaults.array(forKey: nkCommonInstance.groupDefaultsUnauthorized) as? [String], 33 | array.contains(account) { 34 | nkLog(tag: "AUTH", emoji: .error, message: "Unauthorized for account: \(account)") 35 | let error = AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) 36 | return completion(.failure(error)) 37 | 38 | } else if let array = groupDefaults.array(forKey: nkCommonInstance.groupDefaultsUnavailable) as? [String], 39 | array.contains(account) { 40 | nkLog(tag: "SERVICE", emoji: .error, message: "Unavailable for account: \(account)") 41 | let error = AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 503)) 42 | return completion(.failure(error)) 43 | 44 | } else if let array = groupDefaults.array(forKey: nkCommonInstance.groupDefaultsToS) as? [String], 45 | array.contains(account) { 46 | nkLog(tag: "TOS", emoji: .error, message: "Terms of service error for account: \(account)") 47 | let error = AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 403)) 48 | return completion(.failure(error)) 49 | } 50 | } 51 | 52 | completion(.success(urlRequest)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NKMonitor.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | 8 | final class NKMonitor: EventMonitor, Sendable { 9 | let nkCommonInstance: NKCommon 10 | let queue = DispatchQueue(label: "com.nextcloud.NKMonitor") 11 | 12 | init(nkCommonInstance: NKCommon) { 13 | self.nkCommonInstance = nkCommonInstance 14 | } 15 | 16 | func requestDidResume(_ request: Request) { 17 | DispatchQueue.global(qos: .utility).async { 18 | switch NKLogFileManager.shared.logLevel { 19 | case .normal: 20 | // General-purpose log: full Request description 21 | nkLog(info: "Request started: \(request)") 22 | case .verbose: 23 | // Full dump: headers + body 24 | let headers = request.request?.allHTTPHeaderFields?.description ?? "None" 25 | let body = request.request?.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "None" 26 | 27 | nkLog(debug: "Request started: \(request)") 28 | nkLog(debug: "Headers: \(headers)") 29 | nkLog(debug: "Body: \(body)") 30 | default: 31 | break 32 | } 33 | } 34 | } 35 | 36 | func request(_ request: DataRequest, didParseResponse response: AFDataResponse) { 37 | nkCommonInstance.delegate?.request(request, didParseResponse: response) 38 | 39 | // Check for header and account error code tracking 40 | if let statusCode = response.response?.statusCode, 41 | let headerCheckInterceptor = request.request?.allHTTPHeaderFields?[nkCommonInstance.headerCheckInterceptor], 42 | headerCheckInterceptor.lowercased() == "true", 43 | let account = request.request?.allHTTPHeaderFields?[nkCommonInstance.headerAccount] { 44 | nkCommonInstance.appendServerErrorAccount(account, errorCode: statusCode) 45 | } 46 | 47 | DispatchQueue.global(qos: .utility).async { 48 | switch NKLogFileManager.shared.logLevel { 49 | case .normal: 50 | let resultString = String(describing: response.result) 51 | 52 | if let request = response.request { 53 | nkLog(info: "Network response request: \(request), result: \(resultString)") 54 | } else { 55 | nkLog(info: "Network response result: \(resultString)") 56 | } 57 | 58 | case .compact: 59 | if let method = request.request?.httpMethod, 60 | let url = request.request?.url?.absoluteString, 61 | let code = response.response?.statusCode { 62 | 63 | let responseStatus = (200..<300).contains(code) ? "RESPONSE: SUCCESS" : "RESPONSE: ERROR" 64 | nkLog(network: "\(code) \(method) \(url) \(responseStatus)") 65 | } 66 | 67 | case .verbose: 68 | let debugDesc = String(describing: response) 69 | let headerFields = String(describing: response.response?.allHeaderFields ?? [:]) 70 | let date = Date().formatted(using: "yyyy-MM-dd' 'HH:mm:ss") 71 | 72 | nkLog(debug: "Network response result: \(date) " + debugDesc) 73 | nkLog(debug: "Network response all headers: \(date) " + headerFields) 74 | 75 | default: 76 | break 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NKRequestOptions.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2021 Henrik Sorch 3 | // SPDX-FileCopyrightText: 2021 Marino Faggiana 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | public class NKRequestOptions: NSObject { 9 | public let endpoint: String? 10 | public let version: String? 11 | internal(set) public var customHeader: [String: String]? 12 | public let customUserAgent: String? 13 | internal(set) public var contentType: String? 14 | public let e2eToken: String? 15 | internal(set) public var timeout: TimeInterval 16 | public let taskDescription: String? 17 | public let createProperties: [NKProperties]? 18 | public let removeProperties: [NKProperties] 19 | public let checkInterceptor: Bool 20 | public let paginate: Bool 21 | public let paginateToken: String? 22 | public let paginateOffset: Int? 23 | public let paginateCount: Int? 24 | public let queue: DispatchQueue 25 | 26 | public init(endpoint: String? = nil, 27 | version: String? = nil, 28 | customHeader: [String: String]? = nil, 29 | customUserAgent: String? = nil, 30 | contentType: String? = nil, 31 | e2eToken: String? = nil, 32 | timeout: TimeInterval = 60, 33 | taskDescription: String? = nil, 34 | createProperties: [NKProperties]? = nil, 35 | removeProperties: [NKProperties] = [], 36 | checkInterceptor: Bool = true, 37 | paginate: Bool = false, 38 | paginateToken: String? = nil, 39 | paginateOffset: Int? = nil, 40 | paginateCount: Int? = nil, 41 | queue: DispatchQueue = .main) { 42 | 43 | self.endpoint = endpoint 44 | self.version = version 45 | self.customHeader = customHeader 46 | self.customUserAgent = customUserAgent 47 | self.contentType = contentType 48 | self.e2eToken = e2eToken 49 | self.timeout = timeout 50 | self.taskDescription = taskDescription 51 | self.createProperties = createProperties 52 | self.removeProperties = removeProperties 53 | self.checkInterceptor = checkInterceptor 54 | self.paginate = paginate 55 | self.paginateToken = paginateToken 56 | self.paginateOffset = paginateOffset 57 | self.paginateCount = paginateCount 58 | self.queue = queue 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NKSession.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | @preconcurrency import Alamofire 7 | 8 | public struct NKSession: Sendable { 9 | public var urlBase: String 10 | public var user: String 11 | public var userId: String 12 | public var password: String 13 | public var account: String 14 | public var userAgent: String 15 | public let groupIdentifier: String 16 | public let httpMaximumConnectionsPerHost: Int 17 | public let httpMaximumConnectionsPerHostInDownload: Int 18 | public let httpMaximumConnectionsPerHostInUpload: Int 19 | public let dav: String = "remote.php/dav" 20 | public let sessionData: Alamofire.Session 21 | public let sessionDataNoCache: Alamofire.Session 22 | public let sessionDownloadBackground: URLSession 23 | public let sessionDownloadBackgroundExt: URLSession 24 | public let sessionUploadBackground: URLSession 25 | public let sessionUploadBackgroundWWan: URLSession 26 | public let sessionUploadBackgroundExt: URLSession 27 | 28 | init(nkCommonInstance: NKCommon, 29 | urlBase: String, 30 | user: String, 31 | userId: String, 32 | password: String, 33 | account: String, 34 | userAgent: String, 35 | groupIdentifier: String, 36 | httpMaximumConnectionsPerHost: Int, 37 | httpMaximumConnectionsPerHostInDownload: Int, 38 | httpMaximumConnectionsPerHostInUpload: Int) { 39 | self.urlBase = urlBase 40 | self.user = user 41 | self.userId = userId 42 | self.password = password 43 | self.account = account 44 | self.userAgent = userAgent 45 | self.groupIdentifier = groupIdentifier 46 | self.httpMaximumConnectionsPerHost = httpMaximumConnectionsPerHost 47 | self.httpMaximumConnectionsPerHostInDownload = httpMaximumConnectionsPerHostInDownload 48 | self.httpMaximumConnectionsPerHostInUpload = httpMaximumConnectionsPerHostInUpload 49 | 50 | let backgroundSessionDelegate = NKBackground(nkCommonInstance: nkCommonInstance) 51 | // Strange but works ?!?! 52 | let sharedCookieStorage = user + "@" + urlBase 53 | 54 | // SessionData Alamofire 55 | let configurationSessionData = URLSessionConfiguration.af.default 56 | configurationSessionData.requestCachePolicy = .useProtocolCachePolicy 57 | configurationSessionData.httpMaximumConnectionsPerHost = httpMaximumConnectionsPerHost 58 | 59 | #if os(iOS) || targetEnvironment(macCatalyst) 60 | configurationSessionData.multipathServiceType = .handover 61 | #endif 62 | 63 | configurationSessionData.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 64 | sessionData = Alamofire.Session(configuration: configurationSessionData, 65 | delegate: NextcloudKitSessionDelegate(nkCommonInstance: nkCommonInstance), 66 | rootQueue: nkCommonInstance.rootQueue, 67 | requestQueue: nkCommonInstance.requestQueue, 68 | serializationQueue: nkCommonInstance.serializationQueue, 69 | eventMonitors: [NKMonitor(nkCommonInstance: nkCommonInstance)]) 70 | 71 | // SessionDataNoCache Alamofire 72 | let configurationSessionDataNoCache = URLSessionConfiguration.af.default 73 | configurationSessionDataNoCache.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData 74 | configurationSessionDataNoCache.httpMaximumConnectionsPerHost = httpMaximumConnectionsPerHost 75 | configurationSessionDataNoCache.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 76 | 77 | sessionDataNoCache = Alamofire.Session(configuration: configurationSessionDataNoCache, 78 | delegate: NextcloudKitSessionDelegate(nkCommonInstance: nkCommonInstance), 79 | rootQueue: nkCommonInstance.rootQueue, 80 | requestQueue: nkCommonInstance.requestQueue, 81 | serializationQueue: nkCommonInstance.serializationQueue, 82 | eventMonitors: [NKMonitor(nkCommonInstance: nkCommonInstance)]) 83 | 84 | // Session Download Background 85 | let configurationDownloadBackground = URLSessionConfiguration.background(withIdentifier: NKCommon().getSessionConfigurationIdentifier(NKCommon().identifierSessionDownloadBackground, account: account)) 86 | configurationDownloadBackground.allowsCellularAccess = true 87 | 88 | if #available(macOS 11, *) { 89 | configurationDownloadBackground.sessionSendsLaunchEvents = true 90 | } 91 | 92 | configurationDownloadBackground.isDiscretionary = false 93 | configurationDownloadBackground.httpMaximumConnectionsPerHost = self.httpMaximumConnectionsPerHostInDownload 94 | configurationDownloadBackground.requestCachePolicy = .useProtocolCachePolicy 95 | 96 | #if os(iOS) || targetEnvironment(macCatalyst) 97 | configurationDownloadBackground.multipathServiceType = .handover 98 | #endif 99 | 100 | configurationDownloadBackground.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 101 | sessionDownloadBackground = URLSession(configuration: configurationDownloadBackground, delegate: backgroundSessionDelegate, delegateQueue: OperationQueue.main) 102 | 103 | // Session Download Background Extension 104 | let configurationDownloadBackgroundExt = URLSessionConfiguration.background(withIdentifier: NKCommon().identifierSessionDownloadBackgroundExt + UUID().uuidString) 105 | configurationDownloadBackgroundExt.allowsCellularAccess = true 106 | 107 | if #available(macOS 11, *) { 108 | configurationDownloadBackgroundExt.sessionSendsLaunchEvents = true 109 | } 110 | 111 | configurationDownloadBackgroundExt.isDiscretionary = false 112 | configurationDownloadBackgroundExt.httpMaximumConnectionsPerHost = self.httpMaximumConnectionsPerHostInDownload 113 | configurationDownloadBackgroundExt.requestCachePolicy = .useProtocolCachePolicy 114 | configurationDownloadBackgroundExt.sharedContainerIdentifier = groupIdentifier 115 | 116 | #if os(iOS) || targetEnvironment(macCatalyst) 117 | configurationDownloadBackgroundExt.multipathServiceType = .handover 118 | #endif 119 | 120 | configurationDownloadBackgroundExt.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 121 | sessionDownloadBackgroundExt = URLSession(configuration: configurationDownloadBackgroundExt, delegate: backgroundSessionDelegate, delegateQueue: OperationQueue.main) 122 | 123 | // Session Upload Background 124 | let configurationUploadBackground = URLSessionConfiguration.background(withIdentifier: NKCommon().getSessionConfigurationIdentifier(NKCommon().identifierSessionUploadBackground, account: account)) 125 | configurationUploadBackground.allowsCellularAccess = true 126 | 127 | if #available(macOS 11, *) { 128 | configurationUploadBackground.sessionSendsLaunchEvents = true 129 | } 130 | 131 | configurationUploadBackground.isDiscretionary = false 132 | configurationUploadBackground.httpMaximumConnectionsPerHost = self.httpMaximumConnectionsPerHostInUpload 133 | configurationUploadBackground.requestCachePolicy = .useProtocolCachePolicy 134 | 135 | #if os(iOS) || targetEnvironment(macCatalyst) 136 | configurationUploadBackground.multipathServiceType = .handover 137 | #endif 138 | 139 | configurationUploadBackground.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 140 | sessionUploadBackground = URLSession(configuration: configurationUploadBackground, delegate: backgroundSessionDelegate, delegateQueue: OperationQueue.main) 141 | 142 | // Session Upload Background WWan 143 | let configurationUploadBackgroundWWan = URLSessionConfiguration.background(withIdentifier: NKCommon().getSessionConfigurationIdentifier(NKCommon().identifierSessionUploadBackgroundWWan, account: account)) 144 | configurationUploadBackgroundWWan.allowsCellularAccess = false 145 | 146 | if #available(macOS 11, *) { 147 | configurationUploadBackgroundWWan.sessionSendsLaunchEvents = true 148 | } 149 | 150 | configurationUploadBackgroundWWan.isDiscretionary = false 151 | configurationUploadBackgroundWWan.httpMaximumConnectionsPerHost = self.httpMaximumConnectionsPerHostInUpload 152 | configurationUploadBackgroundWWan.requestCachePolicy = .useProtocolCachePolicy 153 | configurationUploadBackgroundWWan.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 154 | sessionUploadBackgroundWWan = URLSession(configuration: configurationUploadBackgroundWWan, delegate: backgroundSessionDelegate, delegateQueue: OperationQueue.main) 155 | 156 | // Session Upload Background Extension 157 | let configurationUploadBackgroundExt = URLSessionConfiguration.background(withIdentifier: NKCommon().identifierSessionUploadBackgroundExt + UUID().uuidString) 158 | configurationUploadBackgroundExt.allowsCellularAccess = true 159 | 160 | if #available(macOS 11, *) { 161 | configurationUploadBackgroundExt.sessionSendsLaunchEvents = true 162 | } 163 | 164 | configurationUploadBackgroundExt.isDiscretionary = false 165 | configurationUploadBackgroundExt.httpMaximumConnectionsPerHost = self.httpMaximumConnectionsPerHostInUpload 166 | configurationUploadBackgroundExt.requestCachePolicy = .useProtocolCachePolicy 167 | configurationUploadBackgroundExt.sharedContainerIdentifier = groupIdentifier 168 | 169 | #if os(iOS) || targetEnvironment(macCatalyst) 170 | configurationUploadBackgroundExt.multipathServiceType = .handover 171 | #endif 172 | 173 | configurationUploadBackgroundExt.httpCookieStorage = HTTPCookieStorage.sharedCookieStorage(forGroupContainerIdentifier: sharedCookieStorage) 174 | sessionUploadBackgroundExt = URLSession(configuration: configurationUploadBackgroundExt, delegate: backgroundSessionDelegate, delegateQueue: OperationQueue.main) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+Download.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2020 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | import SwiftyJSON 8 | 9 | public extension NextcloudKit { 10 | /// Downloads a remote file and stores it at a local path for the specified Nextcloud account. 11 | /// It provides detailed progress, headers, and metadata such as ETag, last modified date, and content length. 12 | /// 13 | /// Parameters: 14 | /// - serverUrlFileName: A value representing the remote file URL or path (typically String or URL). 15 | /// - fileNameLocalPath: The local filesystem path where the file should be saved. 16 | /// - account: The Nextcloud account performing the download. 17 | /// - options: Optional request options (default is empty). 18 | /// - requestHandler: Closure to access the Alamofire `DownloadRequest` (for customization, inspection, etc.). 19 | /// - taskHandler: Closure to access the underlying `URLSessionTask` (e.g. for progress or cancellation). 20 | /// - progressHandler: Closure that receives periodic progress updates. 21 | /// - completionHandler: Completion closure returning metadata: account, ETag, modification date, content length, headers, AFError, and NKError. 22 | func download(serverUrlFileName: Any, 23 | fileNameLocalPath: String, 24 | account: String, 25 | options: NKRequestOptions = NKRequestOptions(), 26 | requestHandler: @escaping (_ request: DownloadRequest) -> Void = { _ in }, 27 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 28 | progressHandler: @escaping (_ progress: Progress) -> Void = { _ in }, 29 | completionHandler: @escaping (_ account: String, _ etag: String?, _ date: Date?, _ lenght: Int64, _ headers: [AnyHashable: Any]?, _ afError: AFError?, _ nKError: NKError) -> Void) { 30 | var convertible: URLConvertible? 31 | if serverUrlFileName is URL { 32 | convertible = serverUrlFileName as? URLConvertible 33 | } else if serverUrlFileName is String || serverUrlFileName is NSString { 34 | convertible = (serverUrlFileName as? String)?.encodedToUrl 35 | } 36 | guard let url = convertible, 37 | let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 38 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 39 | return options.queue.async { completionHandler(account, nil, nil, 0, nil, nil, .urlError) } 40 | } 41 | var destination: Alamofire.DownloadRequest.Destination? 42 | let fileNamePathLocalDestinationURL = NSURL.fileURL(withPath: fileNameLocalPath) 43 | let destinationFile: DownloadRequest.Destination = { _, _ in 44 | return (fileNamePathLocalDestinationURL, [.removePreviousFile, .createIntermediateDirectories]) 45 | } 46 | destination = destinationFile 47 | 48 | let request = nkSession.sessionData.download(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance), to: destination).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 49 | task.taskDescription = options.taskDescription 50 | options.queue.async { taskHandler(task) } 51 | } .downloadProgress { progress in 52 | options.queue.async { progressHandler(progress) } 53 | } .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 54 | switch response.result { 55 | case .failure(let error): 56 | let resultError = NKError(error: error, afResponse: response, responseData: nil) 57 | options.queue.async { completionHandler(account, nil, nil, 0, response.response?.allHeaderFields, error, resultError) } 58 | case .success: 59 | var date: Date? 60 | var etag: String? 61 | var length: Int64 = 0 62 | 63 | if let result = response.response?.allHeaderFields["Content-Length"] as? String { 64 | length = Int64(result) ?? 0 65 | } 66 | if self.nkCommonInstance.findHeader("oc-etag", allHeaderFields: response.response?.allHeaderFields) != nil { 67 | etag = self.nkCommonInstance.findHeader("oc-etag", allHeaderFields: response.response?.allHeaderFields) 68 | } else if self.nkCommonInstance.findHeader("etag", allHeaderFields: response.response?.allHeaderFields) != nil { 69 | etag = self.nkCommonInstance.findHeader("etag", allHeaderFields: response.response?.allHeaderFields) 70 | } 71 | if etag != nil { 72 | etag = etag?.replacingOccurrences(of: "\"", with: "") 73 | } 74 | if let dateRaw = self.nkCommonInstance.findHeader("Date", allHeaderFields: response.response?.allHeaderFields) { 75 | date = dateRaw.parsedDate(using: "yyyy-MM-dd HH:mm:ss") 76 | } 77 | 78 | options.queue.async { completionHandler(account, etag, date, length, response.response?.allHeaderFields, nil, .success) } 79 | } 80 | } 81 | 82 | options.queue.async { requestHandler(request) } 83 | } 84 | 85 | /// Asynchronously downloads a file to the specified local path, with optional progress and task tracking. 86 | /// - Parameters: 87 | /// - serverUrlFileName: A URL or object convertible to a URL string. 88 | /// - fileNameLocalPath: Destination path for the local file. 89 | /// - account: The Nextcloud account used for the request. 90 | /// - options: Optional request configuration. 91 | /// - requestHandler: Handler for accessing the `DownloadRequest`. 92 | /// - taskHandler: Handler for monitoring the `URLSessionTask`. 93 | /// - progressHandler: Progress tracking callback. 94 | /// - Returns: A tuple with account, etag, date, content length, headers, Alamofire error, and internal NKError. 95 | func downloadAsync(serverUrlFileName: Any, 96 | fileNameLocalPath: String, 97 | account: String, 98 | options: NKRequestOptions = NKRequestOptions(), 99 | requestHandler: @escaping (_ request: DownloadRequest) -> Void = { _ in }, 100 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 101 | progressHandler: @escaping (_ progress: Progress) -> Void = { _ in } 102 | ) async -> ( 103 | account: String, 104 | etag: String?, 105 | date: Date?, 106 | length: Int64, 107 | headers: [AnyHashable: Any]?, 108 | afError: AFError?, 109 | nkError: NKError 110 | ) { 111 | await withCheckedContinuation { continuation in 112 | download(serverUrlFileName: serverUrlFileName, 113 | fileNameLocalPath: fileNameLocalPath, 114 | account: account, 115 | options: options, 116 | requestHandler: requestHandler, 117 | taskHandler: taskHandler, 118 | progressHandler: progressHandler) { account, etag, date, length, headers, afError, nkError in 119 | continuation.resume(returning: ( 120 | account: account, 121 | etag: etag, 122 | date: date, 123 | length: length, 124 | headers: headers, 125 | afError: afError, 126 | nkError: nkError 127 | )) 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+FilesLock.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2022 Henrik Sorch 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | import SwiftyJSON 8 | 9 | public extension NextcloudKit { 10 | /// Sends a WebDAV LOCK or UNLOCK request for a file on the server, 11 | /// depending on the `shouldLock` flag. This is used to prevent or release 12 | /// concurrent edits on a file. 13 | /// 14 | /// Parameters: 15 | /// - serverUrlFileName: Fully qualified and encoded URL of the file to lock/unlock. 16 | /// - shouldLock: Pass `true` to lock the file, `false` to unlock it. 17 | /// - account: The Nextcloud account performing the operation. 18 | /// - options: Optional request options (e.g. headers, queue). 19 | /// - taskHandler: Closure to access the URLSessionTask. 20 | /// - completion: Completion handler returning the account, response, and NKError. 21 | func lockUnlockFile(serverUrlFileName: String, 22 | shouldLock: Bool, 23 | account: String, 24 | options: NKRequestOptions = NKRequestOptions(), 25 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 26 | completion: @escaping (_ account: String, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 27 | guard let url = serverUrlFileName.encodedToUrl 28 | else { 29 | return options.queue.async { completion(account, nil, .urlError) } 30 | } 31 | let method = HTTPMethod(rawValue: shouldLock ? "LOCK" : "UNLOCK") 32 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 33 | var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 34 | return options.queue.async { completion(account, nil, .urlError) } 35 | } 36 | headers.update(name: "X-User-Lock", value: "1") 37 | 38 | nkSession.sessionData.request(url, method: method, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 39 | task.taskDescription = options.taskDescription 40 | taskHandler(task) 41 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 42 | switch response.result { 43 | case .failure(let error): 44 | let error = NKError(error: error, afResponse: response, responseData: response.data) 45 | options.queue.async { completion(account, response, error) } 46 | case .success: 47 | options.queue.async { completion(account, response, .success) } 48 | } 49 | } 50 | } 51 | 52 | /// Asynchronously locks or unlocks a file on the server via WebDAV. 53 | /// - Parameters: 54 | /// - serverUrlFileName: The server-side full URL of the file to lock or unlock. 55 | /// - shouldLock: `true` to lock the file, `false` to unlock it. 56 | /// - account: The Nextcloud account performing the action. 57 | /// - options: Optional request configuration (headers, queue, etc.). 58 | /// - taskHandler: Optional monitoring of the `URLSessionTask`. 59 | /// - Returns: A tuple containing the account, the server response, and any error encountered. 60 | func lockUnlockFileAsync(serverUrlFileName: String, 61 | shouldLock: Bool, 62 | account: String, 63 | options: NKRequestOptions = NKRequestOptions(), 64 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } 65 | ) async -> ( 66 | account: String, 67 | responseData: AFDataResponse?, 68 | error: NKError 69 | ) { 70 | await withCheckedContinuation { continuation in 71 | lockUnlockFile(serverUrlFileName: serverUrlFileName, 72 | shouldLock: shouldLock, 73 | account: account, 74 | options: options, 75 | taskHandler: taskHandler) { account, responseData, error in 76 | continuation.resume(returning: ( 77 | account: account, 78 | responseData: responseData, 79 | error: error 80 | )) 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+Groupfolders.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2023 Henrik Storch 3 | // SPDX-FileCopyrightText: 2023 Marino Faggiana 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | import Alamofire 8 | import SwiftyJSON 9 | 10 | public extension NextcloudKit { 11 | /// Retrieves the list of available group folders for the given Nextcloud account. 12 | /// Group folders are shared spaces available across users and groups, 13 | /// managed via the groupfolders app. 14 | /// 15 | /// Parameters: 16 | /// - account: The Nextcloud account requesting the list of group folders. 17 | /// - options: Optional request options (e.g., API version, custom headers, queue). 18 | /// - taskHandler: Closure to access the underlying URLSessionTask. 19 | /// - completion: Completion handler returning the account, list of group folders, response, and any NKError. 20 | func getGroupfolders(account: String, 21 | options: NKRequestOptions = NKRequestOptions(), 22 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 23 | completion: @escaping (_ account: String, _ results: [NKGroupfolders]?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 24 | let endpoint = "index.php/apps/groupfolders/folders?applicable=1" 25 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 26 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 27 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 28 | return options.queue.async { completion(account, nil, nil, .urlError) } 29 | } 30 | 31 | nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 32 | task.taskDescription = options.taskDescription 33 | taskHandler(task) 34 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 35 | switch response.result { 36 | case .failure(let error): 37 | let error = NKError(error: error, afResponse: response, responseData: response.data) 38 | options.queue.async { completion(account, nil, response, error) } 39 | case .success(let jsonData): 40 | let json = JSON(jsonData) 41 | let data = json["ocs"]["data"] 42 | guard json["ocs"]["meta"]["statuscode"].int == 200 || json["ocs"]["meta"]["statuscode"].int == 100 43 | else { 44 | let error = NKError(rootJson: json, fallbackStatusCode: response.response?.statusCode) 45 | options.queue.async { completion(account, nil, response, error) } 46 | return 47 | } 48 | var results = [NKGroupfolders]() 49 | for (_, subJson) in data { 50 | if let result = NKGroupfolders(json: subJson) { 51 | results.append(result) 52 | } 53 | } 54 | options.queue.async { completion(account, results, response, .success) } 55 | } 56 | } 57 | } 58 | 59 | /// Asynchronously retrieves the list of Groupfolders associated with the given account. 60 | /// - Parameters: 61 | /// - account: The Nextcloud account identifier. 62 | /// - options: Optional request configuration (headers, queue, etc.). 63 | /// - taskHandler: Optional monitoring of the `URLSessionTask`. 64 | /// - Returns: A tuple containing the account, an optional array of `NKGroupfolders`, the response data, and an `NKError`. 65 | func getGroupfoldersAsync(account: String, 66 | options: NKRequestOptions = NKRequestOptions(), 67 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } 68 | ) async -> ( 69 | account: String, 70 | results: [NKGroupfolders]?, 71 | responseData: AFDataResponse?, 72 | error: NKError 73 | ) { 74 | await withCheckedContinuation { continuation in 75 | getGroupfolders(account: account, 76 | options: options, 77 | taskHandler: taskHandler) { account, results, responseData, error in 78 | continuation.resume(returning: ( 79 | account: account, 80 | results: results, 81 | responseData: responseData, 82 | error: error 83 | )) 84 | } 85 | } 86 | } 87 | } 88 | 89 | public class NKGroupfolders: NSObject { 90 | public let id: Int 91 | public let mountPoint: String 92 | public let acl: Bool 93 | public let size: Int 94 | public let quota: Int 95 | public let manage: Data? 96 | public let groups: [String: Any]? 97 | 98 | init?(json: JSON) { 99 | guard let id = json["id"].int, 100 | let mountPoint = json["mount_point"].string, 101 | let acl = json["acl"].bool, 102 | let size = json["size"].int, 103 | let quota = json["quota"].int 104 | else { return nil } 105 | 106 | self.id = id 107 | self.mountPoint = mountPoint 108 | self.acl = acl 109 | self.size = size 110 | self.quota = quota 111 | do { 112 | let data = try json["manage"].rawData() 113 | self.manage = data 114 | } catch { 115 | self.manage = nil 116 | } 117 | self.groups = json["groups"].dictionaryObject 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+Hovercard.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2021 Henrik Sorch 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | import SwiftyJSON 8 | 9 | public extension NextcloudKit { 10 | /// Retrieves the hovercard information for a specific user from the Nextcloud server. 11 | /// - Parameters: 12 | /// - userId: The identifier of the user whose hovercard is being requested. 13 | /// - account: The Nextcloud account used to perform the request. 14 | /// - options: Optional request options for customizing the API call. 15 | /// - taskHandler: Closure for observing the underlying `URLSessionTask`. 16 | /// - completion: Completion handler returning the account, the `NKHovercard` result, raw response data, and any error encountered. 17 | func getHovercard(for userId: String, 18 | account: String, 19 | options: NKRequestOptions = NKRequestOptions(), 20 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 21 | completion: @escaping (_ account: String, _ result: NKHovercard?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 22 | let endpoint = "ocs/v2.php/hovercard/v1/\(userId)" 23 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 24 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 25 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 26 | return options.queue.async { completion(account, nil, nil, .urlError) } 27 | } 28 | 29 | nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 30 | task.taskDescription = options.taskDescription 31 | taskHandler(task) 32 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 33 | switch response.result { 34 | case .failure(let error): 35 | let error = NKError(error: error, afResponse: response, responseData: response.data) 36 | options.queue.async { completion(account, nil, response, error) } 37 | case .success(let jsonData): 38 | let json = JSON(jsonData) 39 | let data = json["ocs"]["data"] 40 | guard json["ocs"]["meta"]["statuscode"].int == 200, 41 | let result = NKHovercard(jsonData: data) 42 | else { 43 | let error = NKError(rootJson: json, fallbackStatusCode: response.response?.statusCode) 44 | options.queue.async { completion(account, nil, response, error) } 45 | return 46 | } 47 | options.queue.async { completion(account, result, response, .success) } 48 | } 49 | } 50 | } 51 | 52 | /// Asynchronously retrieves the hovercard information for a specific user from the Nextcloud server. 53 | /// - Parameters: 54 | /// - userId: The identifier of the user whose hovercard is being requested. 55 | /// - account: The Nextcloud account used to perform the request. 56 | /// - options: Optional request options for customizing the API call. 57 | /// - taskHandler: Closure for observing the underlying `URLSessionTask`. 58 | /// - Returns: A tuple containing the account, the `NKHovercard` result, raw response data, and any error encountered. 59 | func getHovercardAsync(for userId: String, 60 | account: String, 61 | options: NKRequestOptions = NKRequestOptions(), 62 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } 63 | ) async -> ( 64 | account: String, 65 | result: NKHovercard?, 66 | responseData: AFDataResponse?, 67 | error: NKError 68 | ) { 69 | await withCheckedContinuation { continuation in 70 | getHovercard(for: userId, 71 | account: account, 72 | options: options, 73 | taskHandler: taskHandler) { account, result, responseData, error in 74 | continuation.resume(returning: ( 75 | account: account, 76 | result: result, 77 | responseData: responseData, 78 | error: error 79 | )) 80 | } 81 | } 82 | } 83 | } 84 | 85 | public class NKHovercard: NSObject { 86 | public let userId, displayName: String 87 | public let actions: [Action] 88 | 89 | init?(jsonData: JSON) { 90 | guard let userId = jsonData["userId"].string, 91 | let displayName = jsonData["displayName"].string, 92 | let actions = jsonData["actions"].array?.compactMap(Action.init) 93 | else { 94 | return nil 95 | } 96 | self.userId = userId 97 | self.displayName = displayName 98 | self.actions = actions 99 | } 100 | 101 | public class Action: NSObject { 102 | public let title: String 103 | public let icon: String 104 | public let hyperlink: String 105 | public var hyperlinkUrl: URL? { URL(string: hyperlink) } 106 | public let appId: String 107 | 108 | init?(jsonData: JSON) { 109 | guard let title = jsonData["title"].string, 110 | let icon = jsonData["icon"].string, 111 | let hyperlink = jsonData["hyperlink"].string, 112 | let appId = jsonData["appId"].string 113 | else { 114 | return nil 115 | } 116 | self.title = title 117 | self.icon = icon 118 | self.hyperlink = hyperlink 119 | self.appId = appId 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+Livephoto.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2023 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | 8 | public extension NextcloudKit { 9 | /// Associates a Live Photo video file with a photo on the server. 10 | /// 11 | /// Parameters: 12 | /// - serverUrlfileNamePath: The full server path to the original photo. 13 | /// - livePhotoFile: The local path to the Live Photo video file (.mov). 14 | /// - account: The account performing the operation. 15 | /// - options: Optional request configuration (e.g., headers, queue, version). 16 | /// - taskHandler: Callback for tracking the underlying URLSessionTask. 17 | /// - completion: Returns the account, raw response data, and NKError result. 18 | func setLivephoto(serverUrlfileNamePath: String, 19 | livePhotoFile: String, 20 | account: String, 21 | options: NKRequestOptions = NKRequestOptions(), 22 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 23 | completion: @escaping (_ account: String, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 24 | guard let url = serverUrlfileNamePath.encodedToUrl, 25 | let nkSession = nkCommonInstance.nksessions.session(forAccount: account) else { 26 | return options.queue.async { completion(account, nil, .urlError) } 27 | } 28 | let method = HTTPMethod(rawValue: "PROPPATCH") 29 | /// 30 | options.contentType = "application/xml" 31 | /// 32 | guard let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 33 | return options.queue.async { completion(account, nil, .urlError) } 34 | } 35 | var urlRequest: URLRequest 36 | do { 37 | try urlRequest = URLRequest(url: url, method: method, headers: headers) 38 | let parameters = String(format: NKDataFileXML(nkCommonInstance: self.nkCommonInstance).requestBodyLivephoto, livePhotoFile) 39 | urlRequest.httpBody = parameters.data(using: .utf8) 40 | } catch { 41 | return options.queue.async { completion(account, nil, NKError(error: error)) } 42 | } 43 | 44 | nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 45 | task.taskDescription = options.taskDescription 46 | taskHandler(task) 47 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 48 | switch response.result { 49 | case .failure(let error): 50 | let error = NKError(error: error, afResponse: response, responseData: response.data) 51 | options.queue.async { completion(account, response, error) } 52 | case .success: 53 | options.queue.async { completion(account, response, .success) } 54 | } 55 | } 56 | } 57 | 58 | /// Asynchronously attaches a Live Photo video file to an existing image on the server. 59 | /// 60 | /// - Parameters: 61 | /// - serverUrlfileNamePath: The full server-side path of the target image. 62 | /// - livePhotoFile: Local file path of the Live Photo (.mov). 63 | /// - account: The Nextcloud account to use for the request. 64 | /// - options: Optional request context and headers. 65 | /// - taskHandler: Optional callback to observe the URLSessionTask. 66 | /// - Returns: A tuple with the account, response data, and NKError result. 67 | func setLivephotoAsync(serverUrlfileNamePath: String, 68 | livePhotoFile: String, 69 | account: String, 70 | options: NKRequestOptions = NKRequestOptions(), 71 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } 72 | ) async -> ( 73 | account: String, 74 | responseData: AFDataResponse?, 75 | error: NKError 76 | ) { 77 | await withCheckedContinuation { continuation in 78 | setLivephoto(serverUrlfileNamePath: serverUrlfileNamePath, 79 | livePhotoFile: livePhotoFile, 80 | account: account, 81 | options: options, 82 | taskHandler: taskHandler) { account, responseData, error in 83 | continuation.resume(returning: ( 84 | account: account, 85 | responseData: responseData, 86 | error: error 87 | )) 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+Logging.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | public extension NextcloudKit { 6 | /// Shared logger accessible via NextcloudKit.logger 7 | static var logger: NKLogFileManager { 8 | return NKLogFileManager.shared 9 | } 10 | 11 | /// Configure the shared logger from NextcloudKit 12 | static func configureLogger(logLevel: NKLogLevel = .normal) { 13 | NKLogFileManager.configure(logLevel: logLevel) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+RecommendedFiles.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | import SwiftyJSON 8 | 9 | public extension NextcloudKit { 10 | /// Retrieves a list of recommended files from the server. 11 | /// 12 | /// Parameters: 13 | /// - account: The Nextcloud account used to perform the request. 14 | /// - options: Optional configuration for headers, queue, versioning, etc. 15 | /// - request: Optional callback to observe or manipulate the underlying DataRequest. 16 | /// - taskHandler: Callback triggered when the URLSessionTask is created. 17 | /// - completion: Completion handler returning the account, the list of recommendations, 18 | /// the raw response data, and an NKError result. 19 | func getRecommendedFiles(account: String, 20 | options: NKRequestOptions = NKRequestOptions(), 21 | request: @escaping (DataRequest?) -> Void = { _ in }, 22 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 23 | completion: @escaping (_ account: String, _ recommendations: [NKRecommendation]?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 24 | let endpoint = "ocs/v2.php/apps/recommendations/api/v1/recommendations" 25 | /// 26 | options.contentType = "application/xml" 27 | /// 28 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 29 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 30 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 31 | return options.queue.async { completion(account, nil, nil, .urlError) } 32 | } 33 | 34 | let tosRequest = nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 35 | task.taskDescription = options.taskDescription 36 | taskHandler(task) 37 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 38 | switch response.result { 39 | case .success(let data): 40 | if let xmlString = String(data: data, encoding: .utf8) { 41 | let parser = XMLToRecommendationParser() 42 | if let recommendations = parser.parse(xml: xmlString) { 43 | options.queue.async { completion(account, recommendations, response, .success) } 44 | } else { 45 | options.queue.async { completion(account, nil, response, .xmlError) } 46 | } 47 | } else { 48 | options.queue.async { completion(account, nil, response, .xmlError) } 49 | } 50 | case .failure(let error): 51 | let error = NKError(error: error, afResponse: response, responseData: response.data) 52 | options.queue.async { 53 | completion(account, nil, response, error) 54 | } 55 | } 56 | } 57 | options.queue.async { request(tosRequest) } 58 | } 59 | 60 | /// Asynchronously fetches a list of recommended files for the given account. 61 | /// 62 | /// - Parameters: 63 | /// - account: The Nextcloud account requesting the recommendations. 64 | /// - options: Optional configuration for queue, headers, etc. 65 | /// - request: Optional callback to capture the DataRequest object. 66 | /// - taskHandler: Optional handler for the URLSessionTask. 67 | /// - Returns: A tuple containing the account, list of recommended files, raw response data, and NKError result. 68 | func getRecommendedFilesAsync(account: String, 69 | options: NKRequestOptions = NKRequestOptions(), 70 | request: @escaping (DataRequest?) -> Void = { _ in }, 71 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } 72 | ) async -> ( 73 | account: String, 74 | recommendations: [NKRecommendation]?, 75 | responseData: AFDataResponse?, 76 | error: NKError 77 | ) { 78 | await withCheckedContinuation { continuation in 79 | getRecommendedFiles(account: account, 80 | options: options, 81 | request: request, 82 | taskHandler: taskHandler) { account, recommendations, responseData, error in 83 | continuation.resume(returning: ( 84 | account: account, 85 | recommendations: recommendations, 86 | responseData: responseData, 87 | error: error 88 | )) 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+ShareDownloadLimit.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Iva Horn 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Alamofire 6 | import Foundation 7 | import SwiftyJSON 8 | 9 | public extension NextcloudKit { 10 | private func makeEndpoint(with token: String) -> String { 11 | "ocs/v2.php/apps/files_downloadlimit/api/v1/\(token)/limit" 12 | } 13 | 14 | /// Retrieves the current download limit for a shared file based on its public share token. 15 | /// 16 | /// Parameters: 17 | /// - account: The Nextcloud account identifier. 18 | /// - token: The public share token associated with the file or folder. 19 | /// - completion: A closure returning: 20 | /// - NKDownloadLimit?: The current download limit information, or `nil` if not available. 21 | /// - NKError: An object representing success or error during the request. 22 | func getDownloadLimit(account: String, token: String, completion: @escaping (NKDownloadLimit?, NKError) -> Void) { 23 | let endpoint = makeEndpoint(with: token) 24 | let options = NKRequestOptions() 25 | 26 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 27 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 28 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 29 | return options.queue.async { 30 | completion(nil, .urlError) 31 | } 32 | } 33 | 34 | nkSession 35 | .sessionData 36 | .request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) 37 | .validate(statusCode: 200..<300) 38 | .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 39 | switch response.result { 40 | case .failure(let error): 41 | let error = NKError(error: error, afResponse: response, responseData: response.data) 42 | 43 | options.queue.async { 44 | completion(nil, error) 45 | } 46 | case .success(let jsonData): 47 | let json = JSON(jsonData) 48 | 49 | guard json["ocs"]["meta"]["statuscode"].int == 200 else { 50 | let error = NKError(rootJson: json, fallbackStatusCode: response.response?.statusCode) 51 | 52 | options.queue.async { 53 | completion(nil, error) 54 | } 55 | 56 | return 57 | } 58 | 59 | let count = json["ocs"]["data"]["count"] 60 | let limit = json["ocs"]["data"]["limit"] 61 | 62 | guard count.type != .null else { 63 | options.queue.async { 64 | completion(nil, .success) 65 | } 66 | 67 | return 68 | } 69 | 70 | guard limit.type != .null else { 71 | options.queue.async { 72 | completion(nil, .success) 73 | } 74 | 75 | return 76 | } 77 | 78 | let downloadLimit = NKDownloadLimit(count: count.intValue, limit: limit.intValue, token: token) 79 | 80 | options.queue.async { 81 | completion(downloadLimit, .success) 82 | } 83 | } 84 | } 85 | } 86 | 87 | /// Retrieves the current download limit for a shared file using its public token. 88 | /// 89 | /// Parameters: 90 | /// - account: The account associated with the Nextcloud session. 91 | /// - token: The public share token used to identify the shared file. 92 | /// 93 | /// Returns: A tuple containing: 94 | /// - downloadLimit: The current NKDownloadLimit object if available. 95 | /// - error: The NKError representing success or failure of the request. 96 | func getDownloadLimitAsync(account: String, token: String) async -> ( 97 | downloadLimit: NKDownloadLimit?, 98 | error: NKError 99 | ) { 100 | await withCheckedContinuation { continuation in 101 | getDownloadLimit(account: account, token: token) { limit, error in 102 | continuation.resume(returning: ( 103 | downloadLimit: limit, 104 | error: error 105 | )) 106 | } 107 | } 108 | } 109 | 110 | /// Removes the download limit for a shared file using its public share token. 111 | /// 112 | /// Parameters: 113 | /// - account: The Nextcloud account identifier. 114 | /// - token: The public share token associated with the file or folder. 115 | /// - completion: A closure returning: 116 | /// - NKError: An object representing the success or failure of the request. 117 | func removeShareDownloadLimit(account: String, token: String, completion: @escaping (_ error: NKError) -> Void) { 118 | let endpoint = makeEndpoint(with: token) 119 | let options = NKRequestOptions() 120 | 121 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 122 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 123 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 124 | return options.queue.async { 125 | completion(.urlError) 126 | } 127 | } 128 | 129 | nkSession 130 | .sessionData 131 | .request(url, method: .delete, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) 132 | .validate(statusCode: 200..<300) 133 | .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 134 | switch response.result { 135 | case .failure(let error): 136 | let error = NKError(error: error, afResponse: response, responseData: response.data) 137 | 138 | options.queue.async { 139 | completion(error) 140 | } 141 | case .success: 142 | options.queue.async { 143 | completion(.success) 144 | } 145 | } 146 | } 147 | } 148 | 149 | /// Asynchronously removes the download limit for a public shared file or folder. 150 | /// 151 | /// Parameters: 152 | /// - account: The Nextcloud account used for the request. 153 | /// - token: The public token representing the shared resource. 154 | /// 155 | /// Returns: An NKError that indicates the outcome of the operation. 156 | func removeShareDownloadLimitAsync(account: String, token: String) async -> NKError { 157 | await withCheckedContinuation { continuation in 158 | removeShareDownloadLimit(account: account, token: token) { error in 159 | continuation.resume(returning: error) 160 | } 161 | } 162 | } 163 | 164 | /// Sets a download limit for a public shared file or folder. 165 | /// 166 | /// Parameters: 167 | /// - account: The Nextcloud account associated with the request. 168 | /// - token: The public share token identifying the shared resource. 169 | /// - limit: The new download limit to be set. 170 | /// - completion: A closure returning: 171 | /// - error: An NKError representing the success or failure of the operation. 172 | func setShareDownloadLimit(account: String, token: String, limit: Int, completion: @escaping (_ error: NKError) -> Void) { 173 | let endpoint = makeEndpoint(with: token) 174 | let options = NKRequestOptions() 175 | options.contentType = "application/json" 176 | 177 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 178 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 179 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options), 180 | var urlRequest = try? URLRequest(url: url, method: .put, headers: headers) else { 181 | return options.queue.async { 182 | completion(.urlError) 183 | } 184 | } 185 | 186 | urlRequest.httpBody = try? JSONEncoder().encode([ 187 | "limit": limit 188 | ]) 189 | 190 | nkSession 191 | .sessionData 192 | .request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)) 193 | .validate(statusCode: 200..<300) 194 | .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 195 | switch response.result { 196 | case .failure(let error): 197 | let error = NKError(error: error, afResponse: response, responseData: response.data) 198 | 199 | options.queue.async { 200 | completion(error) 201 | } 202 | case .success: 203 | options.queue.async { 204 | completion(.success) 205 | } 206 | } 207 | } 208 | } 209 | 210 | /// Asynchronously sets a download limit for a public shared file or folder. 211 | /// 212 | /// Parameters: 213 | /// - account: The Nextcloud account used for the request. 214 | /// - token: The public share token of the resource. 215 | /// - limit: The maximum number of downloads to allow. 216 | /// 217 | /// Returns: An NKError indicating whether the operation was successful. 218 | func setShareDownloadLimitAsync(account: String, token: String, limit: Int) async -> NKError { 219 | await withCheckedContinuation { continuation in 220 | setShareDownloadLimit(account: account, token: token, limit: limit) { error in 221 | continuation.resume(returning: error) 222 | } 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit+TermsOfService.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import Alamofire 7 | import SwiftyJSON 8 | 9 | public extension NextcloudKit { 10 | /// - Parameters: 11 | /// - account: The account to query. 12 | /// - options: Optional request options (defaults to standard). 13 | /// - Returns: Tuple with NKError and optional NKTermsOfService. 14 | func getTermsOfService(account: String, 15 | options: NKRequestOptions = NKRequestOptions(), 16 | request: @escaping (DataRequest?) -> Void = { _ in }, 17 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 18 | completion: @escaping (_ account: String, _ tos: NKTermsOfService?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 19 | let endpoint = "ocs/v2.php/apps/terms_of_service/terms" 20 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 21 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 22 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 23 | return options.queue.async { completion(account, nil, nil, .urlError) } 24 | } 25 | 26 | let tosRequest = nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 27 | task.taskDescription = options.taskDescription 28 | taskHandler(task) 29 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 30 | switch response.result { 31 | case .success(let jsonData): 32 | let tos = NKTermsOfService() 33 | if tos.loadFromJSON(jsonData), let meta = tos.getMeta() { 34 | if meta.statuscode == 200 { 35 | options.queue.async { completion(account, tos, response, .success) } 36 | } else { 37 | options.queue.async { completion(account, tos, response, NKError(errorCode: meta.statuscode, errorDescription: meta.message, responseData: jsonData)) } 38 | } 39 | } else { 40 | options.queue.async { completion(account, nil, response, .invalidData) } 41 | } 42 | case .failure(let error): 43 | let error = NKError(error: error, afResponse: response, responseData: response.data) 44 | options.queue.async { completion(account, nil, response, error) } 45 | } 46 | } 47 | options.queue.async { request(tosRequest) } 48 | } 49 | 50 | /// Async wrapper for `getTermsOfService(account:options:...)` 51 | /// - Parameters: 52 | /// - account: The account to query. 53 | /// - options: Optional request options (defaults to standard). 54 | /// - Returns: Tuple with NKError and optional NKTermsOfService. 55 | func getTermsOfServiceAsync(account: String, 56 | options: NKRequestOptions = NKRequestOptions(), 57 | request: ((DataRequest?) -> Void)? = nil, 58 | taskHandler: ((URLSessionTask) -> Void)? = nil 59 | ) async -> (error: NKError, tos: NKTermsOfService?) { 60 | await withCheckedContinuation { continuation in 61 | self.getTermsOfService( 62 | account: account, 63 | options: options, 64 | request: request ?? { _ in }, 65 | taskHandler: taskHandler ?? { _ in } 66 | ) { _, tos, _, error in 67 | continuation.resume(returning: (error, tos)) 68 | } 69 | } 70 | } 71 | 72 | /// - Parameters: 73 | /// - termId: The ID of the ToS to sign. 74 | /// - account: The user account. 75 | /// - options: Optional request options. 76 | /// - taskHandler: Optional URLSession task handler. 77 | /// - Returns: NKError and AFDataResponse? 78 | func signTermsOfService(termId: String, 79 | account: String, 80 | options: NKRequestOptions = NKRequestOptions(), 81 | taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, 82 | completion: @escaping (_ account: String, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { 83 | let endpoint = "ocs/v2.php/apps/terms_of_service/sign" 84 | var urlRequest: URLRequest 85 | /// 86 | options.contentType = "application/json" 87 | /// 88 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), 89 | let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint, options: options), 90 | let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { 91 | return options.queue.async { completion(account, nil, .urlError) } 92 | } 93 | 94 | do { 95 | try urlRequest = URLRequest(url: url, method: .post, headers: headers) 96 | let parameters = "{\"termId\":\"" + termId + "\"}" 97 | urlRequest.httpBody = parameters.data(using: .utf8) 98 | } catch { 99 | return options.queue.async { completion(account, nil, NKError(error: error)) } 100 | } 101 | 102 | nkSession.sessionData.request(urlRequest, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in 103 | task.taskDescription = options.taskDescription 104 | taskHandler(task) 105 | }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in 106 | switch response.result { 107 | case .failure(let error): 108 | let error = NKError(error: error, afResponse: response, responseData: response.data) 109 | options.queue.async { completion(account, response, error) } 110 | case .success: 111 | options.queue.async { completion(account, response, .success) } 112 | } 113 | } 114 | } 115 | 116 | /// Async wrapper for `signTermsOfService` 117 | /// - Parameters: 118 | /// - termId: The ID of the ToS to sign. 119 | /// - account: The user account. 120 | /// - options: Optional request options. 121 | /// - taskHandler: Optional URLSession task handler. 122 | /// - Returns: NKError and AFDataResponse? 123 | func signTermsOfServiceAsync(termId: String, 124 | account: String, 125 | options: NKRequestOptions = NKRequestOptions(), 126 | taskHandler: ((URLSessionTask) -> Void)? = nil 127 | ) async -> (error: NKError, response: AFDataResponse?) { 128 | await withCheckedContinuation { continuation in 129 | self.signTermsOfService( 130 | termId: termId, 131 | account: account, 132 | options: options, 133 | taskHandler: taskHandler ?? { _ in } 134 | ) { _, responseData, error in 135 | continuation.resume(returning: (error, responseData)) 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit.h: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2022 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | #import 6 | 7 | //! Project version number for NextcloudKit. 8 | FOUNDATION_EXPORT double NextcloudKitVersionNumber; 9 | 10 | //! Project version string for NextcloudKit. 11 | FOUNDATION_EXPORT const unsigned char NextcloudKitVersionString[]; 12 | 13 | // In this header, you should import all the public headers of your framework using statements like #import 14 | 15 | 16 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKit.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2019 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | #if os(macOS) 6 | import Foundation 7 | #else 8 | import UIKit 9 | #endif 10 | import Alamofire 11 | import SwiftyJSON 12 | 13 | open class NextcloudKit { 14 | #if swift(<6.0) 15 | public static let shared: NextcloudKit = { 16 | let instance = NextcloudKit() 17 | return instance 18 | }() 19 | #endif 20 | #if !os(watchOS) 21 | private let reachabilityManager = Alamofire.NetworkReachabilityManager() 22 | #endif 23 | public var nkCommonInstance = NKCommon() 24 | 25 | internal func log(debug message: String) { 26 | NKLogFileManager.shared.writeLog(debug: message) 27 | } 28 | 29 | internal lazy var unauthorizedSession: Alamofire.Session = { 30 | let configuration = URLSessionConfiguration.af.default 31 | configuration.requestCachePolicy = .reloadIgnoringLocalCacheData 32 | 33 | return Alamofire.Session(configuration: configuration, 34 | delegate: NextcloudKitSessionDelegate(nkCommonInstance: nkCommonInstance), 35 | eventMonitors: [NKMonitor(nkCommonInstance: self.nkCommonInstance)]) 36 | }() 37 | 38 | #if swift(<6.0) 39 | init() { 40 | #if !os(watchOS) 41 | startNetworkReachabilityObserver() 42 | #endif 43 | } 44 | #else 45 | public init() { 46 | #if !os(watchOS) 47 | startNetworkReachabilityObserver() 48 | #endif 49 | } 50 | #endif 51 | 52 | deinit { 53 | #if !os(watchOS) 54 | stopNetworkReachabilityObserver() 55 | #endif 56 | } 57 | 58 | // MARK: - Session setup 59 | 60 | public func setup(groupIdentifier: String? = nil, 61 | delegate: NextcloudKitDelegate? = nil, 62 | memoryCapacity: Int = 30, 63 | diskCapacity: Int = 500, 64 | removeAllCachedResponses: Bool = false) { 65 | self.nkCommonInstance.delegate = delegate 66 | self.nkCommonInstance.groupIdentifier = groupIdentifier 67 | 68 | /// Cache URLSession 69 | /// 70 | let memoryCapacity = memoryCapacity * 1024 * 1024 // default 30 MB in RAM 71 | let diskCapacity = diskCapacity * 1024 * 1024 // default 500 MB on Disk 72 | let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: nil) 73 | URLCache.shared = urlCache 74 | 75 | if removeAllCachedResponses { 76 | URLCache.shared.removeAllCachedResponses() 77 | } 78 | } 79 | 80 | public func appendSession(account: String, 81 | urlBase: String, 82 | user: String, 83 | userId: String, 84 | password: String, 85 | userAgent: String, 86 | httpMaximumConnectionsPerHost: Int = 6, 87 | httpMaximumConnectionsPerHostInDownload: Int = 6, 88 | httpMaximumConnectionsPerHostInUpload: Int = 6, 89 | groupIdentifier: String) { 90 | if nkCommonInstance.nksessions.contains(account: account) { 91 | return updateSession(account: account, urlBase: urlBase, userId: userId, password: password, userAgent: userAgent) 92 | } 93 | 94 | let nkSession = NKSession( 95 | nkCommonInstance: nkCommonInstance, 96 | urlBase: urlBase, 97 | user: user, 98 | userId: userId, 99 | password: password, 100 | account: account, 101 | userAgent: userAgent, 102 | groupIdentifier: groupIdentifier, 103 | httpMaximumConnectionsPerHost: httpMaximumConnectionsPerHost, 104 | httpMaximumConnectionsPerHostInDownload: httpMaximumConnectionsPerHostInDownload, 105 | httpMaximumConnectionsPerHostInUpload: httpMaximumConnectionsPerHostInUpload 106 | ) 107 | 108 | nkCommonInstance.nksessions.append(nkSession) 109 | } 110 | 111 | public func updateSession(account: String, 112 | urlBase: String? = nil, 113 | user: String? = nil, 114 | userId: String? = nil, 115 | password: String? = nil, 116 | userAgent: String? = nil, 117 | replaceWithAccount: String? = nil) { 118 | guard var nkSession = nkCommonInstance.nksessions.session(forAccount: account) else { 119 | return 120 | } 121 | 122 | if let urlBase { 123 | nkSession.urlBase = urlBase 124 | } 125 | if let user { 126 | nkSession.user = user 127 | } 128 | if let userId { 129 | nkSession.userId = userId 130 | } 131 | if let password { 132 | nkSession.password = password 133 | } 134 | if let userAgent { 135 | nkSession.userAgent = userAgent 136 | } 137 | if let replaceWithAccount { 138 | nkSession.account = replaceWithAccount 139 | } 140 | } 141 | 142 | public func deleteCookieStorageForAccount(_ account: String) { 143 | guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account) else { 144 | return 145 | } 146 | 147 | if let cookieStore = nkSession.sessionData.session.configuration.httpCookieStorage { 148 | for cookie in cookieStore.cookies ?? [] { 149 | cookieStore.deleteCookie(cookie) 150 | } 151 | } 152 | } 153 | 154 | // MARK: - Reachability 155 | 156 | #if !os(watchOS) 157 | public func isNetworkReachable() -> Bool { 158 | return reachabilityManager?.isReachable ?? false 159 | } 160 | 161 | private func startNetworkReachabilityObserver() { 162 | reachabilityManager?.startListening(onUpdatePerforming: { status in 163 | switch status { 164 | case .unknown: 165 | self.nkCommonInstance.delegate?.networkReachabilityObserver(.unknown) 166 | case .notReachable: 167 | self.nkCommonInstance.delegate?.networkReachabilityObserver(.notReachable) 168 | case .reachable(.ethernetOrWiFi): 169 | self.nkCommonInstance.delegate?.networkReachabilityObserver(.reachableEthernetOrWiFi) 170 | case .reachable(.cellular): 171 | self.nkCommonInstance.delegate?.networkReachabilityObserver(.reachableCellular) 172 | } 173 | }) 174 | } 175 | 176 | private func stopNetworkReachabilityObserver() { 177 | reachabilityManager?.stopListening() 178 | } 179 | #endif 180 | 181 | /// Evaluates an Alamofire response and returns the appropriate NKError. 182 | /// Treats `inputDataNilOrZeroLength` as `.success`. 183 | func evaluateResponse(_ response: AFDataResponse) -> NKError { 184 | if let afError = response.error?.asAFError { 185 | if afError.isExplicitlyCancelledError { 186 | return .cancelled 187 | } 188 | } 189 | 190 | switch response.result { 191 | case .failure(let error): 192 | if let afError = error.asAFError, 193 | case .responseSerializationFailed(let reason) = afError, 194 | case .inputDataNilOrZeroLength = reason { 195 | return .success 196 | } else { 197 | return NKError(error: error, afResponse: response, responseData: response.data) 198 | } 199 | case .success: 200 | return .success 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/NextcloudKitSessionDelegate.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2020 MarinoFaggiana 3 | // SPDX-FileCopyrightText: 2023 Claudio Cambra 4 | // SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | import Foundation 7 | 8 | #if os(macOS) 9 | import Foundation 10 | #else 11 | import UIKit 12 | #endif 13 | import Alamofire 14 | import SwiftyJSON 15 | 16 | final class NextcloudKitSessionDelegate: SessionDelegate, @unchecked Sendable { 17 | public let nkCommonInstance: NKCommon? 18 | 19 | public init(fileManager: FileManager = .default, nkCommonInstance: NKCommon? = nil) { 20 | self.nkCommonInstance = nkCommonInstance 21 | super.init(fileManager: fileManager) 22 | } 23 | 24 | public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { 25 | if let nkCommon = self.nkCommonInstance, 26 | let delegate = nkCommon.delegate { 27 | delegate.authenticationChallenge(session, didReceive: challenge) { authChallengeDisposition, credential in 28 | completionHandler(authChallengeDisposition, credential) 29 | } 30 | } else { 31 | completionHandler(URLSession.AuthChallengeDisposition.performDefaultHandling, nil) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/TypeIdentifiers/NKFilePropertyResolver.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import UniformTypeIdentifiers 7 | 8 | public class NKFileProperty: NSObject { 9 | public var classFile: NKTypeClassFile = .unknow 10 | public var iconName: NKTypeIconFile = .unknow 11 | public var name: String = "" 12 | public var ext: String = "" 13 | } 14 | 15 | public enum NKTypeClassFile: String { 16 | case audio = "audio" 17 | case compress = "compress" 18 | case directory = "directory" 19 | case document = "document" 20 | case image = "image" 21 | case unknow = "unknow" 22 | case url = "url" 23 | case video = "video" 24 | } 25 | 26 | public enum NKTypeIconFile: String { 27 | case audio = "audio" 28 | case code = "code" 29 | case compress = "compress" 30 | case directory = "directory" 31 | case document = "document" 32 | case image = "image" 33 | case video = "video" 34 | case pdf = "pdf" 35 | case ppt = "ppt" 36 | case txt = "txt" 37 | case unknow = "file" 38 | case url = "url" 39 | case xls = "xls" 40 | } 41 | 42 | /// Class responsible for resolving NKFileProperty information from a given UTI. 43 | public final class NKFilePropertyResolver { 44 | 45 | public init() {} 46 | 47 | public func resolve(inUTI: String, account: String) -> NKFileProperty { 48 | let fileProperty = NKFileProperty() 49 | let typeIdentifier = inUTI as String 50 | let capabilities = NKCapabilities.shared.getCapabilitiesBlocking(for: account) 51 | let utiString = inUTI as String 52 | 53 | // Preferred extension 54 | if let type = UTType(utiString), 55 | let ext = type.preferredFilenameExtension { 56 | fileProperty.ext = ext 57 | } 58 | 59 | // Collabora Nextcloud Text Office 60 | if capabilities.richDocumentsMimetypes.contains(typeIdentifier) { 61 | fileProperty.classFile = .document 62 | fileProperty.iconName = .document 63 | fileProperty.name = "document" 64 | 65 | return fileProperty 66 | } 67 | 68 | // Special-case identifiers 69 | switch typeIdentifier { 70 | case "text/plain", "text/html", "net.daringfireball.markdown", "text/x-markdown": 71 | fileProperty.classFile = .document 72 | fileProperty.iconName = .document 73 | fileProperty.name = "markdown" 74 | return fileProperty 75 | case "com.microsoft.word.doc": 76 | fileProperty.classFile = .document 77 | fileProperty.iconName = .document 78 | fileProperty.name = "document" 79 | return fileProperty 80 | case "com.apple.iwork.keynote.key": 81 | fileProperty.classFile = .document 82 | fileProperty.iconName = .ppt 83 | fileProperty.name = "keynote" 84 | return fileProperty 85 | case "com.microsoft.excel.xls": 86 | fileProperty.classFile = .document 87 | fileProperty.iconName = .xls 88 | fileProperty.name = "sheet" 89 | return fileProperty 90 | case "com.apple.iwork.numbers.numbers": 91 | fileProperty.classFile = .document 92 | fileProperty.iconName = .xls 93 | fileProperty.name = "numbers" 94 | return fileProperty 95 | case "com.microsoft.powerpoint.ppt": 96 | fileProperty.classFile = .document 97 | fileProperty.iconName = .ppt 98 | fileProperty.name = "presentation" 99 | default: 100 | break 101 | } 102 | 103 | // Well-known UTI type classifications 104 | if let type = UTType(utiString) { 105 | if type.conforms(to: .image) { 106 | fileProperty.classFile = .image 107 | fileProperty.iconName = .image 108 | fileProperty.name = "image" 109 | 110 | } else if type.conforms(to: .movie) { 111 | fileProperty.classFile = .video 112 | fileProperty.iconName = .video 113 | fileProperty.name = "movie" 114 | 115 | } else if type.conforms(to: .audio) { 116 | fileProperty.classFile = .audio 117 | fileProperty.iconName = .audio 118 | fileProperty.name = "audio" 119 | 120 | } else if type.conforms(to: .zip) { 121 | fileProperty.classFile = .compress 122 | fileProperty.iconName = .compress 123 | fileProperty.name = "archive" 124 | 125 | } else if type.conforms(to: .html) { 126 | fileProperty.classFile = .document 127 | fileProperty.iconName = .code 128 | fileProperty.name = "code" 129 | 130 | } else if type.conforms(to: .pdf) { 131 | fileProperty.classFile = .document 132 | fileProperty.iconName = .pdf 133 | fileProperty.name = "document" 134 | 135 | } else if type.conforms(to: .rtf) { 136 | fileProperty.classFile = .document 137 | fileProperty.iconName = .txt 138 | fileProperty.name = "document" 139 | 140 | } else if type.conforms(to: .text) { 141 | // Default to .txt if extension is empty 142 | if fileProperty.ext.isEmpty { 143 | fileProperty.ext = "txt" 144 | } 145 | fileProperty.classFile = .document 146 | fileProperty.iconName = .txt 147 | fileProperty.name = "text" 148 | 149 | } else if type.conforms(to: .content) { 150 | fileProperty.classFile = .document 151 | fileProperty.iconName = .document 152 | fileProperty.name = "document" 153 | 154 | } else { 155 | fileProperty.classFile = .unknow 156 | fileProperty.iconName = .unknow 157 | fileProperty.name = "file" 158 | } 159 | } else { 160 | // tipo UTI non valido 161 | fileProperty.classFile = .unknow 162 | fileProperty.iconName = .unknow 163 | fileProperty.name = "file" 164 | } 165 | 166 | return fileProperty 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/TypeIdentifiers/NKTypeIdentifiers.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import UniformTypeIdentifiers 7 | 8 | /// Resolved file type metadata, used for cache and classification 9 | public struct NKTypeIdentifierCache: Sendable { 10 | public let mimeType: String 11 | public let classFile: String 12 | public let iconName: String 13 | public let typeIdentifier: String 14 | public let fileNameWithoutExt: String 15 | public let ext: String 16 | } 17 | 18 | /// Actor responsible for resolving file type metadata (UTI, MIME type, icon, class file, etc.) 19 | public actor NKTypeIdentifiers { 20 | public static let shared = NKTypeIdentifiers() 21 | // Cache: extension → resolved type info 22 | private var filePropertyCache: [String: NKTypeIdentifierCache] = [:] 23 | // Internal resolver 24 | private let resolver = NKFilePropertyResolver() 25 | 26 | private init() {} 27 | 28 | // Resolves type info from file name and optional MIME type 29 | public func getInternalType(fileName: String, mimeType inputMimeType: String, directory: Bool, account: String) -> NKTypeIdentifierCache { 30 | 31 | var ext = (fileName as NSString).pathExtension.lowercased() 32 | var mimeType = inputMimeType 33 | var classFile = "" 34 | var iconName = "" 35 | var typeIdentifier = "" 36 | var fileNameWithoutExt = (fileName as NSString).deletingPathExtension 37 | 38 | // Use full name if no extension 39 | if ext.isEmpty { 40 | fileNameWithoutExt = fileName 41 | } 42 | 43 | // Check cache first 44 | if let cached = filePropertyCache[ext] { 45 | return cached 46 | } 47 | 48 | // Resolve UTType 49 | let type = UTType(filenameExtension: ext) ?? .data 50 | typeIdentifier = type.identifier 51 | 52 | // Resolve MIME type 53 | if mimeType.isEmpty { 54 | mimeType = type.preferredMIMEType ?? "application/octet-stream" 55 | } 56 | 57 | // Handle folder case 58 | if directory { 59 | mimeType = "httpd/unix-directory" 60 | classFile = NKTypeClassFile.directory.rawValue 61 | iconName = NKTypeIconFile.directory.rawValue 62 | typeIdentifier = UTType.folder.identifier 63 | fileNameWithoutExt = fileName 64 | ext = "" 65 | } else { 66 | let props = resolver.resolve(inUTI: typeIdentifier, account: account) 67 | classFile = props.classFile.rawValue 68 | iconName = props.iconName.rawValue 69 | } 70 | 71 | // Construct result 72 | let result = NKTypeIdentifierCache( 73 | mimeType: mimeType, 74 | classFile: classFile, 75 | iconName: iconName, 76 | typeIdentifier: typeIdentifier, 77 | fileNameWithoutExt: fileNameWithoutExt, 78 | ext: ext 79 | ) 80 | 81 | // Cache it 82 | if !ext.isEmpty { 83 | filePropertyCache[ext] = result 84 | } 85 | 86 | return result 87 | } 88 | 89 | // Clears the internal cache (used for testing or reset) 90 | public func clearCache() { 91 | filePropertyCache.removeAll() 92 | } 93 | } 94 | 95 | /// Helper class to access NKTypeIdentifiers from sync contexts (e.g. in legacy code or libraries). 96 | public final class NKTypeIdentifiersHelper { 97 | public static let shared = NKTypeIdentifiersHelper() 98 | 99 | // Internal actor reference (uses NKTypeIdentifiers.shared by default) 100 | private let actor: NKTypeIdentifiers 101 | 102 | private init() { 103 | self.actor = .shared 104 | } 105 | 106 | // Init with optional custom actor (useful for testing) 107 | public init(actor: NKTypeIdentifiers = .shared) { 108 | self.actor = actor 109 | } 110 | 111 | // Synchronously resolves file type info by calling the async actor inside a semaphore block. 112 | public func getInternalTypeSync(fileName: String, mimeType: String, directory: Bool, account: String) -> NKTypeIdentifierCache { 113 | var result: NKTypeIdentifierCache? 114 | let semaphore = DispatchSemaphore(value: 0) 115 | 116 | Task { 117 | result = await actor.getInternalType( 118 | fileName: fileName, 119 | mimeType: mimeType, 120 | directory: directory, 121 | account: account 122 | ) 123 | semaphore.signal() 124 | } 125 | 126 | semaphore.wait() 127 | return result! 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Utils/FileAutoRenamer.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | // 8 | // AutoRenameManager.swift 9 | // Nextcloud 10 | // 11 | // Created by Milen Pivchev on 09.10.24. 12 | // Copyright © 2024 Marino Faggiana. All rights reserved. 13 | // 14 | 15 | public final class FileAutoRenamer: Sendable { 16 | private let forbiddenFileNameCharacters: [String] 17 | private let forbiddenFileNameExtensions: [String] 18 | 19 | private let replacement = "_" 20 | 21 | public init(forbiddenFileNameCharacters: [String] = [], forbiddenFileNameExtensions: [String] = []) { 22 | self.forbiddenFileNameCharacters = forbiddenFileNameCharacters 23 | self.forbiddenFileNameExtensions = forbiddenFileNameExtensions.map { $0.lowercased() } 24 | } 25 | 26 | public func rename(filename: String, isFolderPath: Bool = false) -> String { 27 | var pathSegments = filename.split(separator: "/", omittingEmptySubsequences: false).map { String($0) } 28 | var mutableForbiddenFileNameCharacters = self.forbiddenFileNameCharacters 29 | 30 | if isFolderPath { 31 | mutableForbiddenFileNameCharacters.removeAll { $0 == "/" } 32 | } 33 | 34 | pathSegments = pathSegments.map { segment in 35 | var modifiedSegment = segment 36 | 37 | if mutableForbiddenFileNameCharacters.contains(" ") { 38 | modifiedSegment = modifiedSegment.trimmingCharacters(in: .whitespaces) 39 | } 40 | 41 | mutableForbiddenFileNameCharacters.forEach { forbiddenChar in 42 | if modifiedSegment.contains(forbiddenChar) { 43 | modifiedSegment = modifiedSegment.replacingOccurrences(of: forbiddenChar, with: replacement, options: .caseInsensitive) 44 | } 45 | } 46 | 47 | // Replace forbidden extension, if any (ex. .part -> _part) 48 | forbiddenFileNameExtensions.forEach { forbiddenExtension in 49 | if modifiedSegment.lowercased().hasSuffix(forbiddenExtension) && isFullExtension(forbiddenExtension) { 50 | let changedExtension = forbiddenExtension.replacingOccurrences(of: ".", with: replacement, options: .caseInsensitive) 51 | modifiedSegment = modifiedSegment.replacingOccurrences(of: forbiddenExtension, with: changedExtension, options: .caseInsensitive) 52 | } 53 | } 54 | 55 | // Keep original allowed extension and add it at the end (ex file.test.txt becomes file.test) 56 | let fileExtension = modifiedSegment.fileExtension 57 | modifiedSegment = modifiedSegment.withRemovedFileExtension 58 | 59 | // Replace other forbidden extensions. Original allowed extension is ignored. 60 | forbiddenFileNameExtensions.forEach { forbiddenExtension in 61 | if modifiedSegment.lowercased().hasSuffix(forbiddenExtension) || modifiedSegment.lowercased().hasPrefix(forbiddenExtension) { 62 | modifiedSegment = modifiedSegment.replacingOccurrences(of: forbiddenExtension, with: replacement, options: .caseInsensitive) 63 | } 64 | } 65 | 66 | // If there is an original allowed extension, add it back (ex file_test becomes file_test.txt) 67 | if !fileExtension.isEmpty { 68 | modifiedSegment.append(".\(fileExtension.lowercased())") 69 | } 70 | 71 | if modifiedSegment.hasPrefix(".") { 72 | modifiedSegment.remove(at: modifiedSegment.startIndex) 73 | modifiedSegment = replacement + modifiedSegment 74 | } 75 | 76 | return modifiedSegment 77 | } 78 | 79 | let result = pathSegments.joined(separator: "/") 80 | return removeNonPrintableUnicodeCharacters(convertToUTF8(result)) 81 | } 82 | 83 | private func convertToUTF8(_ filename: String) -> String { 84 | return String(data: filename.data(using: .utf8) ?? Data(), encoding: .utf8) ?? filename 85 | } 86 | 87 | private func isFullExtension(_ string: String) -> Bool { 88 | let pattern = "\\.[a-zA-Z0-9]+$" 89 | let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) 90 | let range = NSRange(location: 0, length: string.utf16.count) 91 | return regex?.firstMatch(in: string, options: [], range: range) != nil 92 | } 93 | 94 | private func removeNonPrintableUnicodeCharacters(_ filename: String) -> String { 95 | do { 96 | let regex = try NSRegularExpression(pattern: "\\p{C}", options: []) 97 | let range = NSRange(location: 0, length: filename.utf16.count) 98 | return regex.stringByReplacingMatches(in: filename, options: [], range: range, withTemplate: "") 99 | } catch { 100 | debugPrint("[DEBUG] Could not remove printable unicode characters.") 101 | return filename 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Utils/FileNameValidator.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | public final class FileNameValidator: Sendable { 8 | private let forbiddenFileNames: [String] 9 | private let forbiddenFileNameBasenames: [String] 10 | private let forbiddenFileNameCharacters: [String] 11 | private let forbiddenFileNameExtensions: [String] 12 | 13 | public func fileWithSpaceError() -> NKError { 14 | NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: NSLocalizedString("_file_name_validator_error_space_", value: "Name must not contain spaces at the beginning or end.", comment: "")) 15 | } 16 | 17 | public func fileReservedNameError(templateString: String) -> NKError { 18 | let errorMessageTemplate = NSLocalizedString("_file_name_validator_error_reserved_name_", value: "\"%@\" is a forbidden name.", comment: "") 19 | let errorMessage = String(format: errorMessageTemplate, templateString) 20 | return NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: errorMessage) 21 | } 22 | 23 | public func fileForbiddenFileExtensionError(templateString: String) -> NKError { 24 | let errorMessageTemplate = NSLocalizedString("_file_name_validator_error_forbidden_file_extension_", value: ".\"%@\" is a forbidden file extension.", comment: "") 25 | let errorMessage = String(format: errorMessageTemplate, templateString) 26 | return NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: errorMessage) 27 | } 28 | 29 | public func fileInvalidCharacterError(templateString: String) -> NKError { 30 | let errorMessageTemplate = NSLocalizedString("_file_name_validator_error_invalid_character_", value: "Name contains an invalid character: \"%@\".", comment: "") 31 | let errorMessage = String(format: errorMessageTemplate, templateString) 32 | return NKError(errorCode: NSURLErrorCannotCreateFile, errorDescription: errorMessage) 33 | } 34 | 35 | public init(forbiddenFileNames: [String], forbiddenFileNameBasenames: [String], forbiddenFileNameCharacters: [String], forbiddenFileNameExtensions: [String]) { 36 | self.forbiddenFileNames = forbiddenFileNames.map { $0.uppercased() } 37 | self.forbiddenFileNameBasenames = forbiddenFileNameBasenames.map { $0.uppercased() } 38 | self.forbiddenFileNameCharacters = forbiddenFileNameCharacters 39 | self.forbiddenFileNameExtensions = forbiddenFileNameExtensions.map { $0.uppercased() } 40 | } 41 | 42 | public func checkFileName(_ filename: String) -> NKError? { 43 | if let regex = try? NSRegularExpression(pattern: "[\(forbiddenFileNameCharacters.joined())]"), let invalidCharacterError = checkInvalidCharacters(string: filename, regex: regex) { 44 | return invalidCharacterError 45 | } 46 | 47 | if forbiddenFileNames.contains(filename.uppercased()) || forbiddenFileNames.contains(filename.withRemovedFileExtension.uppercased()) || 48 | forbiddenFileNameBasenames.contains(filename.uppercased()) || forbiddenFileNameBasenames.contains(filename.withRemovedFileExtension.uppercased()) { 49 | return fileReservedNameError(templateString: filename) 50 | } 51 | 52 | for fileNameExtension in forbiddenFileNameExtensions { 53 | if fileNameExtension == " " { 54 | if filename.uppercased().hasSuffix(fileNameExtension) || filename.uppercased().hasPrefix(fileNameExtension) { 55 | return fileWithSpaceError() 56 | } 57 | } else if filename.uppercased().hasSuffix(fileNameExtension.uppercased()) { 58 | if fileNameExtension == " " { 59 | return fileWithSpaceError() 60 | } 61 | 62 | return fileForbiddenFileExtensionError(templateString: filename.fileExtension) 63 | } 64 | } 65 | 66 | return nil 67 | } 68 | 69 | public func checkFolderPath(_ folderPath: String) -> Bool { 70 | return folderPath.split { $0 == "/" || $0 == "\\" } 71 | .allSatisfy { checkFileName(String($0)) == nil } 72 | } 73 | 74 | public static func isFileHidden(_ name: String) -> Bool { 75 | return !name.isEmpty && name.first == "." 76 | } 77 | 78 | private func checkInvalidCharacters(string: String, regex: NSRegularExpression) -> NKError? { 79 | for char in string { 80 | let charAsString = String(char) 81 | let range = NSRange(location: 0, length: charAsString.utf16.count) 82 | 83 | if regex.firstMatch(in: charAsString, options: [], range: range) != nil { 84 | return fileInvalidCharacterError(templateString: charAsString) 85 | } 86 | } 87 | return nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/NextcloudKit/Utils/SynchronizedNKSessionArray.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2025 Marino Faggiana 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | 7 | /// A thread-safe container for managing an array of `NKSession` instances. 8 | /// 9 | /// Internally uses a concurrent `DispatchQueue` with barrier writes to ensure safe concurrent access and mutation. 10 | /// Conforms to `@unchecked Sendable` for Swift 6 compatibility. 11 | public final class SynchronizedNKSessionArray: @unchecked Sendable { 12 | 13 | // MARK: - Internal Storage 14 | 15 | /// Internal storage for the session array. 16 | private var array: [NKSession] 17 | 18 | /// Dispatch queue used for synchronizing access to the array. 19 | private let queue: DispatchQueue 20 | 21 | // MARK: - Initialization 22 | 23 | /// Initializes a new synchronized array with optional initial content. 24 | /// - Parameter initial: An initial array of `NKSession` to populate the container with. 25 | public init(_ initial: [NKSession] = []) { 26 | self.array = initial 27 | self.queue = DispatchQueue(label: "com.nextcloud.SynchronizedNKSessionArray", attributes: .concurrent) 28 | } 29 | 30 | // MARK: - Read Operations 31 | 32 | /// Returns the number of sessions currently stored. 33 | public var count: Int { 34 | queue.sync { array.count } 35 | } 36 | 37 | /// Returns a Boolean value indicating whether the array is empty. 38 | public var isEmpty: Bool { 39 | queue.sync { array.isEmpty } 40 | } 41 | 42 | /// Returns a snapshot of all stored sessions. 43 | public var all: [NKSession] { 44 | queue.sync { array } 45 | } 46 | 47 | /// Calls the given closure on each session in the array, in order. 48 | /// - Parameter body: A closure that takes a `NKSession` as a parameter. 49 | public func forEach(_ body: (NKSession) -> Void) { 50 | queue.sync { 51 | array.forEach(body) 52 | } 53 | } 54 | 55 | /// Returns the first session matching a given account string. 56 | /// - Parameter account: The account identifier string to match. 57 | /// - Returns: A `NKSession` instance if found, otherwise `nil`. 58 | public func session(forAccount account: String) -> NKSession? { 59 | queue.sync { 60 | for session in array { 61 | if session.account == account { 62 | return session 63 | } 64 | } 65 | return nil 66 | } 67 | } 68 | 69 | /// Checks whether a session for a given account exists. 70 | /// - Parameter account: The account identifier string to check. 71 | /// - Returns: `true` if a matching session exists, `false` otherwise. 72 | public func contains(account: String) -> Bool { 73 | queue.sync { 74 | array.contains(where: { $0.account == account }) 75 | } 76 | } 77 | 78 | // MARK: - Write Operations 79 | 80 | /// Appends a new session to the array. 81 | /// - Parameter element: The `NKSession` to append. 82 | public func append(_ element: NKSession) { 83 | queue.async(flags: .barrier) { 84 | self.array.append(element) 85 | } 86 | } 87 | 88 | /// Removes all sessions associated with the given account. 89 | /// - Parameter account: The account identifier string to remove sessions for. 90 | public func remove(account: String) { 91 | queue.async(flags: .barrier) { 92 | self.array.removeAll { $0.account == account } 93 | } 94 | } 95 | 96 | /// Removes all sessions from the array. 97 | public func removeAll() { 98 | queue.async(flags: .barrier) { 99 | self.array.removeAll() 100 | } 101 | } 102 | 103 | // MARK: - Subscript 104 | 105 | /// Accesses the session at a given index. 106 | /// - Parameter index: The index of the desired session. 107 | /// - Returns: A `NKSession` if the index is valid, otherwise `nil`. 108 | public subscript(index: Int) -> NKSession? { 109 | queue.sync { 110 | guard array.indices.contains(index) else { return nil } 111 | return array[index] 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Tests/NextcloudKitIntegrationTests/BaseIntegrationXCTestCase.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import XCTest 6 | @testable import NextcloudKit 7 | 8 | class BaseIntegrationXCTestCase: BaseXCTestCase { 9 | internal var randomInt: Int { 10 | get { 11 | return Int.random(in: 1000...Int.max) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/NextcloudKitIntegrationTests/Common/BaseXCTestCase.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import XCTest 6 | import Foundation 7 | import UIKit 8 | import Alamofire 9 | import NextcloudKit 10 | 11 | class BaseXCTestCase: XCTestCase { 12 | var appToken = "" 13 | var ncKit: NextcloudKit! 14 | 15 | func setupAppToken() async { 16 | let expectation = expectation(description: "Should get app token") 17 | #if swift(<6.0) 18 | ncKit = NextcloudKit.shared 19 | #else 20 | ncKit = NextcloudKit() 21 | #endif 22 | 23 | ncKit.getAppPassword(url: TestConstants.server, user: TestConstants.username, password: TestConstants.password) { token, _, error in 24 | XCTAssertEqual(error.errorCode, 0) 25 | XCTAssertNotNil(token) 26 | 27 | guard let token else { return XCTFail() } 28 | 29 | self.appToken = token 30 | expectation.fulfill() 31 | } 32 | 33 | await fulfillment(of: [expectation], timeout: TestConstants.timeoutLong) 34 | } 35 | 36 | override func setUp() async throws { 37 | await setupAppToken() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/NextcloudKitIntegrationTests/Common/TestConstants.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Foundation 6 | import UIKit 7 | 8 | public class TestConstants { 9 | static let timeoutLong: Double = 400 10 | static let server = "http://localhost:8080" 11 | static let username = "admin" 12 | static let password = "admin" 13 | static let account = "\(username) \(server)" 14 | } 15 | -------------------------------------------------------------------------------- /Tests/NextcloudKitIntegrationTests/FilesIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import XCTest 6 | @testable import NextcloudKit 7 | 8 | final class FilesIntegrationTests: BaseIntegrationXCTestCase { 9 | // func test_createReadDeleteFolder_withProperParams_shouldCreateReadDeleteFolder() throws { 10 | // let expectation = expectation(description: "Should finish last callback") 11 | // let folderName = "TestFolder\(randomInt)" 12 | // let serverUrl = "\(TestConstants.server)/remote.php/dav/files/\(TestConstants.username)" 13 | // let serverUrlFileName = "\(serverUrl)/\(folderName)" 14 | // 15 | // NextcloudKit.shared.appendSession(account: TestConstants.account, urlBase: TestConstants.server, user: TestConstants.username, userId: TestConstants.username, password: TestConstants.password, userAgent: "", nextcloudVersion: 0, groupIdentifier: "") 16 | // 17 | // // Test creating folder 18 | // NextcloudKit.shared.createFolder(serverUrlFileName: serverUrlFileName, account: TestConstants.account) { account, ocId, date, _, error in 19 | // XCTAssertEqual(TestConstants.account, account) 20 | // 21 | // XCTAssertEqual(NKError.success.errorCode, error.errorCode) 22 | // XCTAssertEqual(NKError.success.errorDescription, error.errorDescription) 23 | // 24 | // Thread.sleep(forTimeInterval: 0.2) 25 | // 26 | // // Test reading folder, should exist 27 | // NextcloudKit.shared.readFileOrFolder(serverUrlFileName: serverUrlFileName, depth: "0", account: account) { account, files, data, error in 28 | // XCTAssertEqual(TestConstants.account, account) 29 | // XCTAssertEqual(NKError.success.errorCode, error.errorCode) 30 | // XCTAssertEqual(NKError.success.errorDescription, error.errorDescription) 31 | // XCTAssertEqual(files?[0].fileName, folderName) 32 | // 33 | // Thread.sleep(forTimeInterval: 0.2) 34 | // 35 | // // Test deleting folder 36 | // NextcloudKit.shared.deleteFileOrFolder(serverUrlFileName: serverUrlFileName, account: account) { account, _, error in 37 | // XCTAssertEqual(TestConstants.account, account) 38 | // XCTAssertEqual(NKError.success.errorCode, error.errorCode) 39 | // XCTAssertEqual(NKError.success.errorDescription, error.errorDescription) 40 | // 41 | // Thread.sleep(forTimeInterval: 0.2) 42 | // 43 | // // Test reading folder, should NOT exist 44 | // NextcloudKit.shared.readFileOrFolder(serverUrlFileName: serverUrlFileName, depth: "0", account: account) { account, files, data, error in 45 | // defer { expectation.fulfill() } 46 | // 47 | // XCTAssertEqual(404, error.errorCode) 48 | // XCTAssertEqual(TestConstants.account, account) 49 | // XCTAssertTrue(files?.isEmpty ?? false) 50 | // } 51 | // } 52 | // } 53 | // } 54 | // 55 | // waitForExpectations(timeout: 100) 56 | // } 57 | } 58 | -------------------------------------------------------------------------------- /Tests/NextcloudKitIntegrationTests/ShareIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import XCTest 6 | import Alamofire 7 | @testable import NextcloudKit 8 | 9 | final class ShareIntegrationTests: BaseIntegrationXCTestCase { 10 | // func test_createShare_withNote_shouldCreateShare() throws { 11 | // let expectation = expectation(description: "Should finish last callback") 12 | // 13 | // let folderName = "Share\(randomInt)" 14 | // let serverUrl = "\(TestConstants.server)/remote.php/dav/files/\(TestConstants.username)" 15 | // let serverUrlFileName = "\(serverUrl)/\(folderName)" 16 | // 17 | // NextcloudKit.shared.appendSession(account: TestConstants.account, urlBase: TestConstants.server, user: TestConstants.username, userId: TestConstants.username, password: TestConstants.password, userAgent: "", nextcloudVersion: 0, groupIdentifier: "") 18 | // 19 | // NextcloudKit.shared.createFolder(serverUrlFileName: serverUrlFileName, account: TestConstants.account) { account, ocId, date, _, error in 20 | // XCTAssertEqual(TestConstants.account, account) 21 | // 22 | // XCTAssertEqual(NKError.success.errorCode, error.errorCode) 23 | // XCTAssertEqual(NKError.success.errorDescription, error.errorDescription) 24 | // 25 | // Thread.sleep(forTimeInterval: 0.2) 26 | // 27 | // let note = "Test note" 28 | // 29 | // NextcloudKit.shared.createShare(path: folderName, shareType: 0, shareWith: "nextcloud", note: note, account: "") { account, share, data, error in 30 | // defer { expectation.fulfill() } 31 | // 32 | // XCTAssertEqual(TestConstants.account, account) 33 | // XCTAssertEqual(NKError.success.errorCode, error.errorCode) 34 | // XCTAssertEqual(NKError.success.errorDescription, error.errorDescription) 35 | // XCTAssertEqual(note, share?.note) 36 | // } 37 | // } 38 | // 39 | // waitForExpectations(timeout: 100) 40 | // } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/NextcloudKitUnitTests/FileAutoRenamerUnitTests.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import Testing 6 | @testable import NextcloudKit 7 | 8 | @Suite(.serialized) struct FileAutoRenamerUnitTests { 9 | let forbiddenFilenameCharacter = ">" 10 | let forbiddenFilenameExtension = "." 11 | 12 | let characterArrays = [ 13 | ["\\\\", "*", ">", "&", "/", "|", ":", "<", "?", " "], 14 | [">", ":", "?", " ", "&", "*", "\\\\", "|", "<", "/"], 15 | ["<", "|", "?", ":", "&", "*", "\\\\", " ", "/", ">"], 16 | ["?", "/", " ", ":", "&", "<", "|", ">", "\\\\", "*"], 17 | ["&", "<", "|", "*", "/", "?", ">", " ", ":", "\\\\"] 18 | ] 19 | 20 | let extensionArrays = [ 21 | [" ", ",", ".", ".filepart", ".part"], 22 | [".filepart", ".part", " ", ".", ","], 23 | [".PART", ".", ",", " ", ".filepart"], 24 | [",", " ", ".FILEPART", ".part", "."], 25 | [".", ".PART", ",", " ", ".FILEPART"] 26 | ] 27 | 28 | let combinedTuples: [([String], [String])] 29 | 30 | init() { 31 | combinedTuples = zip(characterArrays, extensionArrays).map { ($0, $1) } 32 | } 33 | 34 | @Test func testInvalidChar() { 35 | for (characterArray, extensionArray) in combinedTuples { 36 | let fileAutoRenamer = FileAutoRenamer( 37 | forbiddenFileNameCharacters: characterArray, 38 | forbiddenFileNameExtensions: extensionArray 39 | ) 40 | 41 | let filename = "File\(forbiddenFilenameCharacter)File.txt" 42 | let result = fileAutoRenamer.rename(filename: filename) 43 | let expectedFilename = "File_File.txt" 44 | #expect(result == expectedFilename) 45 | } 46 | } 47 | 48 | @Test func testInvalidExtension() { 49 | for (characterArray, extensionArray) in combinedTuples { 50 | let fileAutoRenamer = FileAutoRenamer( 51 | forbiddenFileNameCharacters: characterArray, 52 | forbiddenFileNameExtensions: extensionArray 53 | ) 54 | 55 | let filename = "File\(forbiddenFilenameExtension)" 56 | let result = fileAutoRenamer.rename(filename: filename) 57 | let expectedFilename = "File_" 58 | #expect(result == expectedFilename) 59 | } 60 | } 61 | 62 | @Test func testMultipleInvalidChars() { 63 | for (characterArray, extensionArray) in combinedTuples { 64 | let fileAutoRenamer = FileAutoRenamer( 65 | forbiddenFileNameCharacters: characterArray, 66 | forbiddenFileNameExtensions: extensionArray 67 | ) 68 | 69 | let filename = "File|name?<>.txt" 70 | let result = fileAutoRenamer.rename(filename: filename) 71 | let expectedFilename = "File_name___.txt" 72 | #expect(result == expectedFilename) 73 | } 74 | } 75 | 76 | @Test func testStartEndInvalidExtensions() { 77 | for (characterArray, extensionArray) in combinedTuples { 78 | let fileAutoRenamer = FileAutoRenamer( 79 | forbiddenFileNameCharacters: characterArray, 80 | forbiddenFileNameExtensions: extensionArray 81 | ) 82 | 83 | let filename = " .File.part " 84 | let result = fileAutoRenamer.rename(filename: filename) 85 | let expectedFilename = "_File_part" 86 | #expect(result == expectedFilename) 87 | } 88 | } 89 | 90 | @Test func testStartInvalidExtension() { 91 | for (characterArray, extensionArray) in combinedTuples { 92 | let fileAutoRenamer = FileAutoRenamer( 93 | forbiddenFileNameCharacters: characterArray, 94 | forbiddenFileNameExtensions: extensionArray 95 | ) 96 | 97 | let filename = " .File.part" 98 | let result = fileAutoRenamer.rename(filename: filename) 99 | let expectedFilename = "_File_part" 100 | #expect(result == expectedFilename) 101 | } 102 | } 103 | 104 | @Test func testEndInvalidExtension() { 105 | for (characterArray, extensionArray) in combinedTuples { 106 | let fileAutoRenamer = FileAutoRenamer( 107 | forbiddenFileNameCharacters: characterArray, 108 | forbiddenFileNameExtensions: extensionArray 109 | ) 110 | 111 | let filename = ".File.part " 112 | let result = fileAutoRenamer.rename(filename: filename) 113 | let expectedFilename = "_File_part" 114 | #expect(result == expectedFilename) 115 | } 116 | } 117 | 118 | @Test func testHiddenFile() { 119 | for (characterArray, extensionArray) in combinedTuples { 120 | let fileAutoRenamer = FileAutoRenamer( 121 | forbiddenFileNameCharacters: characterArray, 122 | forbiddenFileNameExtensions: extensionArray 123 | ) 124 | 125 | let filename = ".Filename.txt" 126 | let result = fileAutoRenamer.rename(filename: filename) 127 | let expectedFilename = "_Filename.txt" 128 | #expect(result == expectedFilename) 129 | } 130 | } 131 | 132 | @Test func testUppercaseExtension() { 133 | for (characterArray, extensionArray) in combinedTuples { 134 | let fileAutoRenamer = FileAutoRenamer( 135 | forbiddenFileNameCharacters: characterArray, 136 | forbiddenFileNameExtensions: extensionArray 137 | ) 138 | 139 | let filename = ".Filename.TXT" 140 | let result = fileAutoRenamer.rename(filename: filename) 141 | let expectedFilename = "_Filename.txt" 142 | #expect(result == expectedFilename) 143 | } 144 | } 145 | 146 | @Test func testMiddleNonPrintableChar() { 147 | for (characterArray, extensionArray) in combinedTuples { 148 | let fileAutoRenamer = FileAutoRenamer( 149 | forbiddenFileNameCharacters: characterArray, 150 | forbiddenFileNameExtensions: extensionArray 151 | ) 152 | 153 | let filename = "File\u{0001}name.txt" 154 | let result = fileAutoRenamer.rename(filename: filename) 155 | let expectedFilename = "Filename.txt" 156 | #expect(result == expectedFilename) 157 | } 158 | } 159 | 160 | @Test func testStartNonPrintableChar() { 161 | for (characterArray, extensionArray) in combinedTuples { 162 | let fileAutoRenamer = FileAutoRenamer( 163 | forbiddenFileNameCharacters: characterArray, 164 | forbiddenFileNameExtensions: extensionArray 165 | ) 166 | 167 | let filename = "\u{0001}Filename.txt" 168 | let result = fileAutoRenamer.rename(filename: filename) 169 | let expectedFilename = "Filename.txt" 170 | #expect(result == expectedFilename) 171 | } 172 | } 173 | 174 | @Test func testEndNonPrintableChar() { 175 | for (characterArray, extensionArray) in combinedTuples { 176 | let fileAutoRenamer = FileAutoRenamer( 177 | forbiddenFileNameCharacters: characterArray, 178 | forbiddenFileNameExtensions: extensionArray 179 | ) 180 | 181 | let filename = "Filename.txt\u{0001}" 182 | let result = fileAutoRenamer.rename(filename: filename) 183 | let expectedFilename = "Filename.txt" 184 | #expect(result == expectedFilename) 185 | } 186 | } 187 | 188 | @Test func testExtensionNonPrintableChar() { 189 | for (characterArray, extensionArray) in combinedTuples { 190 | let fileAutoRenamer = FileAutoRenamer( 191 | forbiddenFileNameCharacters: characterArray, 192 | forbiddenFileNameExtensions: extensionArray 193 | ) 194 | 195 | let filename = "Filename.t\u{0001}xt" 196 | let result = fileAutoRenamer.rename(filename: filename) 197 | let expectedFilename = "Filename.txt" 198 | #expect(result == expectedFilename) 199 | } 200 | } 201 | 202 | @Test func testMiddleInvalidFolderChar() { 203 | for (characterArray, extensionArray) in combinedTuples { 204 | let fileAutoRenamer = FileAutoRenamer( 205 | forbiddenFileNameCharacters: characterArray, 206 | forbiddenFileNameExtensions: extensionArray 207 | ) 208 | 209 | let folderPath = "Abc/Def/kg\(forbiddenFilenameCharacter)/lmo/pp" 210 | let result = fileAutoRenamer.rename(filename: folderPath, isFolderPath: true) 211 | let expectedFolderName = "Abc/Def/kg_/lmo/pp" 212 | #expect(result == expectedFolderName) 213 | } 214 | } 215 | 216 | @Test func testEndInvalidFolderChar() { 217 | for (characterArray, extensionArray) in combinedTuples { 218 | let fileAutoRenamer = FileAutoRenamer( 219 | forbiddenFileNameCharacters: characterArray, 220 | forbiddenFileNameExtensions: extensionArray 221 | ) 222 | 223 | let folderPath = "Abc/Def/kg/lmo/pp\(forbiddenFilenameCharacter)" 224 | let result = fileAutoRenamer.rename(filename: folderPath, isFolderPath: true) 225 | let expectedFolderName = "Abc/Def/kg/lmo/pp_" 226 | #expect(result == expectedFolderName) 227 | } 228 | } 229 | 230 | @Test func testStartInvalidFolderChar() { 231 | for (characterArray, extensionArray) in combinedTuples { 232 | let fileAutoRenamer = FileAutoRenamer( 233 | forbiddenFileNameCharacters: characterArray, 234 | forbiddenFileNameExtensions: extensionArray 235 | ) 236 | 237 | let folderPath = "\(forbiddenFilenameCharacter)Abc/Def/kg/lmo/pp" 238 | let result = fileAutoRenamer.rename(filename: folderPath, isFolderPath: true) 239 | let expectedFolderName = "_Abc/Def/kg/lmo/pp" 240 | #expect(result == expectedFolderName) 241 | } 242 | } 243 | 244 | @Test func testMixedInvalidChar() { 245 | for (characterArray, extensionArray) in combinedTuples { 246 | let fileAutoRenamer = FileAutoRenamer( 247 | forbiddenFileNameCharacters: characterArray, 248 | forbiddenFileNameExtensions: extensionArray 249 | ) 250 | 251 | let filename = " File\u{0001}na\(forbiddenFilenameCharacter)me.txt " 252 | let result = fileAutoRenamer.rename(filename: filename) 253 | let expectedFilename = "Filena_me.txt" 254 | #expect(result == expectedFilename) 255 | } 256 | } 257 | 258 | @Test func testStartsWithPathSeparator() { 259 | for (characterArray, extensionArray) in combinedTuples { 260 | let fileAutoRenamer = FileAutoRenamer( 261 | forbiddenFileNameCharacters: characterArray, 262 | forbiddenFileNameExtensions: extensionArray 263 | ) 264 | 265 | let folderPath = "/Abc/Def/kg/lmo/pp\(forbiddenFilenameCharacter)/File.txt/" 266 | let result = fileAutoRenamer.rename(filename: folderPath, isFolderPath: true) 267 | let expectedFolderName = "/Abc/Def/kg/lmo/pp_/File.txt/" 268 | #expect(result == expectedFolderName) 269 | } 270 | } 271 | 272 | @Test func testStartsWithPathSeparatorAndValidFilepath() { 273 | for (characterArray, extensionArray) in combinedTuples { 274 | let fileAutoRenamer = FileAutoRenamer( 275 | forbiddenFileNameCharacters: characterArray, 276 | forbiddenFileNameExtensions: extensionArray 277 | ) 278 | 279 | let folderPath = "/COm02/2569.webp" 280 | let result = fileAutoRenamer.rename(filename: folderPath, isFolderPath: true) 281 | let expectedFolderName = "/COm02/2569.webp" 282 | #expect(result == expectedFolderName) 283 | } 284 | } 285 | } 286 | 287 | -------------------------------------------------------------------------------- /Tests/NextcloudKitUnitTests/FileNameValidatorUnitTests.swift: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: Nextcloud GmbH 2 | // SPDX-FileCopyrightText: 2024 Milen Pivchev 3 | // SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | import XCTest 6 | @testable import NextcloudKit 7 | 8 | class FileNameValidatorUnitTests: XCTestCase { 9 | var fileNameValidator: FileNameValidator! 10 | 11 | override func setUp() { 12 | fileNameValidator = FileNameValidator( 13 | forbiddenFileNames: [".htaccess",".htaccess"], 14 | forbiddenFileNameBasenames: ["con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", "com4", 15 | "com5", "com6", "com7", "com8", "com9", "com¹", "com²", "com³", 16 | "lpt0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", 17 | "lpt8", "lpt9", "lpt¹", "lpt²", "lpt³"], 18 | forbiddenFileNameCharacters: ["<", ">", ":", "\\\\", "/", "|", "?", "*", "&"], 19 | forbiddenFileNameExtensions: [".filepart",".part", ".", ",", " "] 20 | ) 21 | super.setUp() 22 | } 23 | 24 | func testInvalidCharacter() { 25 | let result = fileNameValidator.checkFileName("file