From 44ad29cd40befef81c9e4797af616722df1364af Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 18 Aug 2025 12:19:52 +0200 Subject: [PATCH 1/9] Add models Signed-off-by: Milen Pivchev --- .../DeclarativeUI/NKDeclarativeUI.swift | 40 +++++++++++++++++++ .../NextcloudKit+Capabilities.swift | 37 ++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift new file mode 100644 index 00000000..85a30246 --- /dev/null +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift @@ -0,0 +1,40 @@ +// +// File.swift +// NextcloudKit +// +// Created by Milen Pivchev on 10.07.25. +// + +import Foundation +// +//public struct NKDeclarativeUI { +// public struct ContextMenu: Codable { +// let title: String +// let url: String +// } +//} + +public struct DeclarativeUI: Codable { + public let contextMenu: [ContextMenuItem] + + enum CodingKeys: String, CodingKey { + case contextMenu = "context-menu" + } +} + +public struct ContextMenuItem: Codable { + public let title: String + public let endpoint: String + + public init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + title = try container.decode(String.self) + endpoint = try container.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(title) + try container.encode(endpoint) + } +} diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index 61d34244..49ccbc66 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -127,6 +127,7 @@ public extension NextcloudKit { let assistant: Assistant? let recommendations: Recommendations? let termsOfService: TermsOfService? + let declarativeUI: DeclarativeUI? enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" @@ -140,6 +141,7 @@ public extension NextcloudKit { case assistant case recommendations case termsOfService = "terms_of_service" + case declarativeUI = "declarativeui" } struct DownloadLimit: Codable { @@ -329,6 +331,32 @@ public extension NextcloudKit { struct Recommendations: Codable { let enabled: Bool? } + +// struct DeclarativeUI: Codable { +// let contextMenu: [[ContextMenuItem]] +// +// enum CodingKeys: String, CodingKey { +// case contextMenu = "context-menu" +// } +// } + +// +// struct DeclarativeUI: Codable { +// let contextMenus: [ContextMenu] +// +// enum CodingKeys: String, CodingKey { +// case contextMenus = "context-menu" +// } +// +// struct ContextMenu: Codable { +// let items +// } +// +// struct ContextMenuItem: Codable { +// let title: String +// let endpoint: String +// } +// } } } } @@ -346,6 +374,8 @@ public extension NextcloudKit { let data = decoded.ocs.data let json = data.capabilities + print(json) + // Initialize capabilities let capabilities = NKCapabilities.Capabilities() @@ -409,6 +439,9 @@ public extension NextcloudKit { capabilities.recommendations = json.recommendations?.enabled ?? false capabilities.termsOfService = json.termsOfService?.enabled ?? false +// capabilities.declarativeUIEnabled = !(json.declarativeUI?.contextMenu.isEmpty ?? false) +// capabilities.declarativeUIContextMenu = json.declarativeUI?.contextMenu ?? [] + capabilities.declarativeUI = json.declarativeUI // Persist capabilities in shared store await NKCapabilities.shared.setCapabilities(for: account, capabilities: capabilities) return capabilities @@ -484,7 +517,9 @@ final public class NKCapabilities: Sendable { public var forbiddenFileNameExtensions: [String] = [] public var recommendations: Bool = false public var termsOfService: Bool = false - +// public var declarativeUIEnabled: Bool = false +// public var declarativeUIContextMenu: [ContextMenuItem] = [] + public var declarativeUI: DeclarativeUI? = nil public var directEditingEditors: [NKEditorDetailsEditor] = [] public var directEditingCreators: [NKEditorDetailsCreator] = [] public var directEditingTemplates: [NKEditorTemplate] = [] From b5d796da9b20a555993c258f3bb516ba3d5c6183 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 23 Sep 2025 14:44:56 +0200 Subject: [PATCH 2/9] WIP Signed-off-by: Milen Pivchev --- .../DeclarativeUI/NKDeclarativeUI.swift | 115 ++++++++++++++++-- .../NextcloudKit+Capabilities.swift | 3 +- .../NextcloudKit+DeclarativeUI.swift | 115 ++++++++++++++++++ 3 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift index 85a30246..3d332067 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift @@ -6,6 +6,7 @@ // import Foundation +import Alamofire // //public struct NKDeclarativeUI { // public struct ContextMenu: Codable { @@ -13,28 +14,116 @@ import Foundation // let url: String // } //} +// +//public struct DeclarativeUI: Codable { +// public let contextMenu: [ContextMenuItem] +// +// enum CodingKeys: String, CodingKey { +// case contextMenu = "context-menu" +// } +//} +// +//public struct ContextMenuItem: Codable { +// public let title: String +// public let endpoint: String +// +// public init(from decoder: Decoder) throws { +// var container = try decoder.unkeyedContainer() +// title = try container.decode(String.self) +// endpoint = try container.decode(String.self) +// } +// +// public func encode(to encoder: Encoder) throws { +// var container = encoder.unkeyedContainer() +// try container.encode(title) +// try container.encode(endpoint) +// } +//} public struct DeclarativeUI: Codable { - public let contextMenu: [ContextMenuItem] + public let apps: [String: AppContext] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicKey.self) + + var dict: [String: AppContext] = [:] + for key in container.allKeys { + let value = try container.decode(AppContext.self, forKey: key) + dict[key.stringValue] = value + } + self.apps = dict + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + for (key, value) in apps { + try container.encode(value, forKey: DynamicKey(stringValue: key)!) + } + } + + public enum Params: String { + case fileId = "fileId" + case filePath = "filePath" + } + } + + struct DynamicKey: CodingKey { + var stringValue: String + var intValue: Int? { nil } + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } + } + +public struct AppContext: Codable { + public let contextMenu: [ContextMenuAction] enum CodingKeys: String, CodingKey { case contextMenu = "context-menu" } } -public struct ContextMenuItem: Codable { - public let title: String - public let endpoint: String +public struct ContextMenuAction: Codable { + public let name: String + public let url: String + public let method: String + public let mimetypeFilters: String? + public let params: [String: String]? + public let icon: String? +// public let filter: String? - public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - title = try container.decode(String.self) - endpoint = try container.decode(String.self) + enum CodingKeys: String, CodingKey { + case name, url, method, icon, params + case mimetypeFilters = "mimetype_filters" } - public func encode(to encoder: Encoder) throws { - var container = encoder.unkeyedContainer() - try container.encode(title) - try container.encode(endpoint) - } +// func asRequest(user: String, password: String, userAgent: String? = nil, +// options: NKRequestOptions = NKRequestOptions(), +// taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, +// completion: @escaping (_ token: String?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) async { +// +// // Convert string method to Alamofire HTTPMethod +// let httpMethod = HTTPMethod(rawValue: method.uppercased()) +// +// // Map params/bodyParams arrays into key/value (example: fileId → dummy value) +// var queryParams: [String: Any]? = nil +// if let params = params { +// queryParams = Dictionary(uniqueKeysWithValues: params.map { ($0, "SOME_VALUE") }) +// } +// +// var body: [String: Any]? = nil +// if let bodyParams = bodyParams { +// body = Dictionary(uniqueKeysWithValues: bodyParams.map { ($0, "SOME_BODY_VALUE") }) +// } +// +// await NextcloudKit.shared.sendRequestAsync(fullUrl: url, +// method: httpMethod, +// user: user, +// password: password, +// userAgent: userAgent, +// params: queryParams, +// bodyParams: body, +// options: options, +// taskHandler: taskHandler) +// } } + diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index 49ccbc66..2fe744a2 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -439,9 +439,8 @@ public extension NextcloudKit { capabilities.recommendations = json.recommendations?.enabled ?? false capabilities.termsOfService = json.termsOfService?.enabled ?? false -// capabilities.declarativeUIEnabled = !(json.declarativeUI?.contextMenu.isEmpty ?? false) -// capabilities.declarativeUIContextMenu = json.declarativeUI?.contextMenu ?? [] capabilities.declarativeUI = json.declarativeUI + // Persist capabilities in shared store await NKCapabilities.shared.setCapabilities(for: account, capabilities: capabilities) return capabilities diff --git a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift b/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift new file mode 100644 index 00000000..32aa1d51 --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2020 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire +import SwiftyJSON + +public extension NextcloudKit { + // MARK: - App Password + + /// Retrieves an app password (token) for the given user credentials and server URL. + /// + /// Parameters: + /// - url: The base server URL (e.g., https://cloud.example.com). + /// - user: The username for authentication. + /// - password: The user's password. + /// - userAgent: Optional user-agent string to include in the request. + /// - options: Optional request configuration (headers, queue, etc.). + /// - taskHandler: Callback for observing the underlying URLSessionTask. + /// - completion: Returns the token string (if any), raw response data, and NKError result. + func sendRequest(account: String, + fileId: String, + filePath: String, + url: String, + method: String, + params: [String: String]? = nil, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (_ token: String?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (completion(nil, nil, .urlError)) + } + + let httpMethod = HTTPMethod(rawValue: method.uppercased()) + + var queryParams: [String: Any] = [:] + typealias pEnum = DeclarativeUI.Params + if let params = params { + params.forEach { (key: String, value: String) in + switch value { + case pEnum.fileId.rawValue: + queryParams[pEnum.fileId.rawValue] = "{\(fileId)}" + case pEnum.filePath.rawValue: + queryParams[pEnum.filePath.rawValue] = filePath + default: + queryParams = [:] + } + } + + guard let url = URL(string: nkSession.urlBase + url) else { + return options.queue.async { completion(nil, nil, .urlError) } + } + +// var headers: HTTPHeaders = [.init(name: "OCS-APIRequest", value: "true")] +// headers.update(.userAgent(nkSession.userAgent)) + + + let encoding: ParameterEncoding = (httpMethod == .get ? URLEncoding.default : JSONEncoding.default) + + unauthorizedSession.request(url, + method: httpMethod, + parameters: queryParams, + encoding: encoding, + headers: headers) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(nil, response, error) } + case .success(let data): + let apppassword = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).convertDataAppPassword(data: data) + options.queue.async { completion(apppassword, response, .success) } + } + } + } + } + + /// Asynchronously fetches an app password for the provided user credentials. + /// + /// - Parameters: + /// - url: The base URL of the Nextcloud server. + /// - user: The user login name. + /// - password: The user’s password. + /// - userAgent: Optional custom user agent for the request. + /// - options: Optional request configuration. + /// - taskHandler: Callback to observe the task, if needed. + /// - Returns: A tuple containing the token, response data, and error result. + func sendRequestAsync(account: String, + fileId: String, + filePath: String, + url: String, + method: String, + params: [String: String]? = nil, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + ) async -> ( + token: String?, + responseData: AFDataResponse?, + error: NKError + ) { + await withCheckedContinuation { continuation in + sendRequest(account: account, fileId: fileId, filePath: filePath, url: url, method: method, params: params) { token, responseData, error in + continuation.resume(returning: (token: token, responseData: responseData, error: error)) + } + } + } +} From a054696e453ea3918c7c0a068e8a35d3c4f872b0 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 23 Sep 2025 16:09:37 +0200 Subject: [PATCH 3/9] WIP Signed-off-by: Milen Pivchev --- Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift b/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift index 32aa1d51..718bd57f 100644 --- a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift +++ b/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift @@ -31,7 +31,7 @@ public extension NextcloudKit { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return (completion(nil, nil, .urlError)) + return completion(nil, nil, .urlError) } let httpMethod = HTTPMethod(rawValue: method.uppercased()) @@ -42,7 +42,7 @@ public extension NextcloudKit { params.forEach { (key: String, value: String) in switch value { case pEnum.fileId.rawValue: - queryParams[pEnum.fileId.rawValue] = "{\(fileId)}" + queryParams[pEnum.fileId.rawValue] = fileId case pEnum.filePath.rawValue: queryParams[pEnum.filePath.rawValue] = filePath default: @@ -103,6 +103,7 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, ) async -> ( token: String?, + responseData: AFDataResponse?, error: NKError ) { From ce3de4f34f295b547aaf145f24d565cde871445c Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 1 Oct 2025 10:44:26 +0200 Subject: [PATCH 4/9] WIP Signed-off-by: Milen Pivchev --- ...wift => NKDeclarativeUICapabilities.swift} | 2 +- .../NKDeclarativeUIResponse.swift | 27 +++ .../NextcloudKit+Capabilities.swift | 4 +- .../NextcloudKit+DeclarativeUI.swift | 178 ++++++++++++------ 4 files changed, 147 insertions(+), 64 deletions(-) rename Sources/NextcloudKit/Models/DeclarativeUI/{NKDeclarativeUI.swift => NKDeclarativeUICapabilities.swift} (98%) create mode 100644 Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUICapabilities.swift similarity index 98% rename from Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift rename to Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUICapabilities.swift index 3d332067..739c46fd 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUI.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUICapabilities.swift @@ -40,7 +40,7 @@ import Alamofire // } //} -public struct DeclarativeUI: Codable { +public struct NKDeclarativeUICapabilities: Codable { public let apps: [String: AppContext] public init(from decoder: Decoder) throws { diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift new file mode 100644 index 00000000..3444585b --- /dev/null +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift @@ -0,0 +1,27 @@ +// +// NKDeclarativeUIResponse.swift +// NextcloudKit +// +// Created by Milen Pivchev on 24.09.25. +// + + +public struct NKDeclarativeUIResponse: Codable { + let version: Double + let root: RootContainer +} + +public struct RootContainer: Codable { + let orientation: String + let rows: [Row] +} + +public struct Row: Codable { + let children: [Child] +} + +public struct Child: Codable { + let element: String + let text: String? + let url: String? +} diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index 2fe744a2..af63fa24 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -127,7 +127,7 @@ public extension NextcloudKit { let assistant: Assistant? let recommendations: Recommendations? let termsOfService: TermsOfService? - let declarativeUI: DeclarativeUI? + let declarativeUI: NKDeclarativeUICapabilities? enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" @@ -518,7 +518,7 @@ final public class NKCapabilities: Sendable { public var termsOfService: Bool = false // public var declarativeUIEnabled: Bool = false // public var declarativeUIContextMenu: [ContextMenuItem] = [] - public var declarativeUI: DeclarativeUI? = nil + public var declarativeUI: NKDeclarativeUICapabilities? = nil public var directEditingEditors: [NKEditorDetailsEditor] = [] public var directEditingCreators: [NKEditorDetailsCreator] = [] public var directEditingTemplates: [NKEditorTemplate] = [] diff --git a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift b/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift index 718bd57f..7a0dd7c6 100644 --- a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift +++ b/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift @@ -19,7 +19,7 @@ public extension NextcloudKit { /// - options: Optional request configuration (headers, queue, etc.). /// - taskHandler: Callback for observing the underlying URLSessionTask. /// - completion: Returns the token string (if any), raw response data, and NKError result. - func sendRequest(account: String, + internal func sendRequest(account: String, fileId: String, filePath: String, url: String, @@ -27,60 +27,65 @@ public extension NextcloudKit { params: [String: String]? = nil, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (_ token: String?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { + completion: @escaping (_ token: String?, _ uiResponse: NKDeclarativeUIResponse?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { - guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), - let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return completion(nil, nil, .urlError) - } - - let httpMethod = HTTPMethod(rawValue: method.uppercased()) - - var queryParams: [String: Any] = [:] - typealias pEnum = DeclarativeUI.Params - if let params = params { - params.forEach { (key: String, value: String) in - switch value { - case pEnum.fileId.rawValue: - queryParams[pEnum.fileId.rawValue] = fileId - case pEnum.filePath.rawValue: - queryParams[pEnum.filePath.rawValue] = filePath - default: - queryParams = [:] - } - } - - guard let url = URL(string: nkSession.urlBase + url) else { - return options.queue.async { completion(nil, nil, .urlError) } - } - -// var headers: HTTPHeaders = [.init(name: "OCS-APIRequest", value: "true")] -// headers.update(.userAgent(nkSession.userAgent)) - - - let encoding: ParameterEncoding = (httpMethod == .get ? URLEncoding.default : JSONEncoding.default) - - unauthorizedSession.request(url, - method: httpMethod, - parameters: queryParams, - encoding: encoding, - headers: headers) - .validate(statusCode: 200..<300) - .onURLSessionTaskCreation { task in - task.taskDescription = options.taskDescription - taskHandler(task) - } - .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): - let error = NKError(error: error, afResponse: response, responseData: response.data) - options.queue.async { completion(nil, response, error) } - case .success(let data): - let apppassword = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).convertDataAppPassword(data: data) - options.queue.async { completion(apppassword, response, .success) } - } - } - } +// guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), +// let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { +// return completion(nil, nil, .urlError) +// } +// +// let httpMethod = HTTPMethod(rawValue: method.uppercased()) +// +// // var queryParams: [String: Any] = [:] +// typealias pEnum = NKDeclarativeUICapabilities.Params +// // Build query params if provided +// var queryParams: [String: Any]? = nil +// if let params = params { +// var qp: [String: Any] = [:] +// for (key, value) in params { +// switch key { +// case NKDeclarativeUICapabilities.Params.fileId.rawValue: +// qp[key] = fileId +// case NKDeclarativeUICapabilities.Params.filePath.rawValue: +// qp[key] = filePath +// default: +// qp[key] = value +// } +// } +// queryParams = qp +// } +// +// +// guard let url = URL(string: nkSession.urlBase + url) else { +// return options.queue.async { completion(nil, nil, .urlError) } +// } +// +// // var headers: HTTPHeaders = [.init(name: "OCS-APIRequest", value: "true")] +// // headers.update(.userAgent(nkSession.userAgent)) +// +// +//// let encoding: ParameterEncoding = (httpMethod == .get ? URLEncoding.default : JSONEncoding.default) +// +// unauthorizedSession.request(url, +// method: httpMethod, +// parameters: queryParams, +// encoding: URLEncoding.queryString, +// headers: headers) +// .validate(statusCode: 200..<300) +// .onURLSessionTaskCreation { task in +// task.taskDescription = options.taskDescription +// taskHandler(task) +// } +// .responseData(queue: self.nkCommonInstance.backgroundQueue) { response in +// switch response.result { +// case .failure(let error): +// let error = NKError(error: error, afResponse: response, responseData: response.data) +// options.queue.async { completion(nil, response, error) } +// case .success(let data): +// let apppassword = NKDataFileXML(nkCommonInstance: self.nkCommonInstance).convertDataAppPassword(data: data) +// options.queue.async { completion(apppassword, response, .success) } +// } +// } } /// Asynchronously fetches an app password for the provided user credentials. @@ -100,17 +105,68 @@ public extension NextcloudKit { method: String, params: [String: String]? = nil, options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> ( - token: String?, - - responseData: AFDataResponse?, + account: String, + uiResponse: NKDeclarativeUIResponse?, error: NKError ) { - await withCheckedContinuation { continuation in - sendRequest(account: account, fileId: fileId, filePath: filePath, url: url, method: method, params: params) { token, responseData, error in - continuation.resume(returning: (token: token, responseData: responseData, error: error)) + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return (account, nil, NKError.urlError) + } + + let httpMethod = HTTPMethod(rawValue: method.uppercased()) + + var queryParams: [String: Any]? = nil + if let params = params { + var qp: [String: Any] = [:] + for (key, value) in params { + switch key { + case NKDeclarativeUICapabilities.Params.fileId.rawValue: + qp[key] = fileId + case NKDeclarativeUICapabilities.Params.filePath.rawValue: + qp[key] = filePath + default: + qp[key] = value + } + } + queryParams = qp + } + + guard let fullURL = URL(string: nkSession.urlBase + url) else { + return (account, nil, NKError.urlError) + } + + let taskDescription = options.taskDescription + + let request = unauthorizedSession.request(fullURL, + method: httpMethod, + parameters: queryParams, + encoding: URLEncoding.queryString, + headers: headers) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { [taskDescription] task in + task.taskDescription = taskDescription + taskHandler(task) + } + + let response = await request.serializingData().response + + switch response.result { + case .failure(let afError): + let nkErr = NKError(error: afError, afResponse: response, responseData: response.data) + return (account, nil, nkErr) + case .success(let data): + do { + let decoder = JSONDecoder() + let ui = try decoder.decode(NKDeclarativeUIResponse.self, from: data) + return (account, ui, .success) + } catch { + nkLog(debug: "Declarative UI response decoding failed: \(error)") + return (account, nil, .invalidData) } } } } + From ccdad721632b90f98565ad0565013c332f859204 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 2 Dec 2025 17:00:01 +0100 Subject: [PATCH 5/9] Refactor Signed-off-by: Milen Pivchev --- ...lities.swift => NKClientIntegration.swift} | 46 +++---------------- ...ft => NKClientIntegrationUIResponse.swift} | 12 ++--- .../NextcloudKit+Capabilities.swift | 8 ++-- ...t => NextcloudKit+ClientIntegration.swift} | 10 ++-- 4 files changed, 19 insertions(+), 57 deletions(-) rename Sources/NextcloudKit/Models/DeclarativeUI/{NKDeclarativeUICapabilities.swift => NKClientIntegration.swift} (75%) rename Sources/NextcloudKit/Models/DeclarativeUI/{NKDeclarativeUIResponse.swift => NKClientIntegrationUIResponse.swift} (61%) rename Sources/NextcloudKit/{NextcloudKit+DeclarativeUI.swift => NextcloudKit+ClientIntegration.swift} (94%) diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUICapabilities.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift similarity index 75% rename from Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUICapabilities.swift rename to Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift index 739c46fd..222cd862 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUICapabilities.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift @@ -1,46 +1,11 @@ -// -// File.swift -// NextcloudKit -// -// Created by Milen Pivchev on 10.07.25. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later import Foundation import Alamofire -// -//public struct NKDeclarativeUI { -// public struct ContextMenu: Codable { -// let title: String -// let url: String -// } -//} -// -//public struct DeclarativeUI: Codable { -// public let contextMenu: [ContextMenuItem] -// -// enum CodingKeys: String, CodingKey { -// case contextMenu = "context-menu" -// } -//} -// -//public struct ContextMenuItem: Codable { -// public let title: String -// public let endpoint: String -// -// public init(from decoder: Decoder) throws { -// var container = try decoder.unkeyedContainer() -// title = try container.decode(String.self) -// endpoint = try container.decode(String.self) -// } -// -// public func encode(to encoder: Encoder) throws { -// var container = encoder.unkeyedContainer() -// try container.encode(title) -// try container.encode(endpoint) -// } -//} -public struct NKDeclarativeUICapabilities: Codable { +public struct NKClientIntegration: Codable { public let apps: [String: AppContext] public init(from decoder: Decoder) throws { @@ -75,9 +40,11 @@ public struct NKDeclarativeUICapabilities: Codable { } public struct AppContext: Codable { + public let version: Double public let contextMenu: [ContextMenuAction] enum CodingKeys: String, CodingKey { + case version case contextMenu = "context-menu" } } @@ -89,7 +56,6 @@ public struct ContextMenuAction: Codable { public let mimetypeFilters: String? public let params: [String: String]? public let icon: String? -// public let filter: String? enum CodingKeys: String, CodingKey { case name, url, method, icon, params diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift similarity index 61% rename from Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift rename to Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift index 3444585b..0dd29a2b 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKDeclarativeUIResponse.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift @@ -1,12 +1,8 @@ -// -// NKDeclarativeUIResponse.swift -// NextcloudKit -// -// Created by Milen Pivchev on 24.09.25. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later - -public struct NKDeclarativeUIResponse: Codable { +public struct NKClientIntegrationUIResponse: Codable { let version: Double let root: RootContainer } diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index 329b5155..a6c6ad70 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -132,7 +132,7 @@ public extension NextcloudKit { let assistant: Assistant? let recommendations: Recommendations? let termsOfService: TermsOfService? - let declarativeUI: NKDeclarativeUICapabilities? + let clientIntegration: NKClientIntegration? enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" @@ -146,7 +146,7 @@ public extension NextcloudKit { case assistant case recommendations case termsOfService = "terms_of_service" - case declarativeUI = "declarativeui" + case clientIntegration = "client_integration" } struct DownloadLimit: Codable { @@ -464,7 +464,7 @@ public extension NextcloudKit { capabilities.recommendations = json.recommendations?.enabled ?? false capabilities.termsOfService = json.termsOfService?.enabled ?? false - capabilities.declarativeUI = json.declarativeUI + capabilities.clientIntegration = json.clientIntegration // Persist capabilities in shared store await NKCapabilities.shared.setCapabilities(for: account, capabilities: capabilities) @@ -562,7 +562,7 @@ final public class NKCapabilities: Sendable { public var termsOfService: Bool = false // public var declarativeUIEnabled: Bool = false // public var declarativeUIContextMenu: [ContextMenuItem] = [] - public var declarativeUI: NKDeclarativeUICapabilities? = nil + public var clientIntegration: NKClientIntegration? = nil public var directEditingEditors: [NKEditorDetailsEditor] = [] public var directEditingCreators: [NKEditorDetailsCreator] = [] public var directEditingTemplates: [NKEditorTemplate] = [] diff --git a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift b/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift similarity index 94% rename from Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift rename to Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift index 7a0dd7c6..37d1c31e 100644 --- a/Sources/NextcloudKit/NextcloudKit+DeclarativeUI.swift +++ b/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift @@ -27,7 +27,7 @@ public extension NextcloudKit { params: [String: String]? = nil, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, - completion: @escaping (_ token: String?, _ uiResponse: NKDeclarativeUIResponse?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { + completion: @escaping (_ token: String?, _ uiResponse: NKClientIntegrationUIResponse?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { // guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), // let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { @@ -108,7 +108,7 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> ( account: String, - uiResponse: NKDeclarativeUIResponse?, + uiResponse: NKClientIntegrationUIResponse?, error: NKError ) { guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), @@ -123,9 +123,9 @@ public extension NextcloudKit { var qp: [String: Any] = [:] for (key, value) in params { switch key { - case NKDeclarativeUICapabilities.Params.fileId.rawValue: + case NKClientIntegration.Params.fileId.rawValue: qp[key] = fileId - case NKDeclarativeUICapabilities.Params.filePath.rawValue: + case NKClientIntegration.Params.filePath.rawValue: qp[key] = filePath default: qp[key] = value @@ -160,7 +160,7 @@ public extension NextcloudKit { case .success(let data): do { let decoder = JSONDecoder() - let ui = try decoder.decode(NKDeclarativeUIResponse.self, from: data) + let ui = try decoder.decode(NKClientIntegrationUIResponse.self, from: data) return (account, ui, .success) } catch { nkLog(debug: "Declarative UI response decoding failed: \(error)") From 0e40cf7e8b038571dcbb9857bfa44d9baa2504e0 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 3 Dec 2025 16:26:33 +0100 Subject: [PATCH 6/9] WIP Signed-off-by: Milen Pivchev --- .../NKClientIntegrationUIResponse.swift | 32 ++++++++++++++----- .../NextcloudKit+ClientIntegration.swift | 8 +++-- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift index 0dd29a2b..0ee74543 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift @@ -3,21 +3,37 @@ // SPDX-License-Identifier: GPL-3.0-or-later public struct NKClientIntegrationUIResponse: Codable { - let version: Double - let root: RootContainer + public let ocs: OCSContainer +} + +public struct OCSContainer: Codable { + public let meta: Meta + public let data: ResponseData +} + +public struct Meta: Codable { + public let status: String + public let statuscode: Int + public let message: String +} + +public struct ResponseData: Codable { + // TODO: add after +// public let version: String + public let tooltip: String? } public struct RootContainer: Codable { - let orientation: String - let rows: [Row] + public let orientation: String + public let rows: [Row] } public struct Row: Codable { - let children: [Child] + public let children: [Child] } public struct Child: Codable { - let element: String - let text: String? - let url: String? + public let element: String + public let text: String? + public let url: String? } diff --git a/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift b/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift index 37d1c31e..81bb5f02 100644 --- a/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift +++ b/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift @@ -134,13 +134,17 @@ public extension NextcloudKit { queryParams = qp } - guard let fullURL = URL(string: nkSession.urlBase + url) else { + let fullURLString = nkSession.urlBase + url + let finalURLString = fullURLString.replacingOccurrences(of: "{\(NKClientIntegration.Params.fileId.rawValue)}", with: fileId) + .replacingOccurrences(of: "{\(NKClientIntegration.Params.filePath.rawValue)}", with: filePath) + + guard let finalURL = URL(string: finalURLString) else { return (account, nil, NKError.urlError) } let taskDescription = options.taskDescription - let request = unauthorizedSession.request(fullURL, + let request = unauthorizedSession.request(finalURL, method: httpMethod, parameters: queryParams, encoding: URLEncoding.queryString, From 259d19250d5716518da419930a3d2545f175b6c4 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Mon, 8 Dec 2025 14:14:54 +0100 Subject: [PATCH 7/9] WIP Signed-off-by: Milen Pivchev --- Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift b/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift index 81bb5f02..8001338b 100644 --- a/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift +++ b/Sources/NextcloudKit/NextcloudKit+ClientIntegration.swift @@ -167,7 +167,7 @@ public extension NextcloudKit { let ui = try decoder.decode(NKClientIntegrationUIResponse.self, from: data) return (account, ui, .success) } catch { - nkLog(debug: "Declarative UI response decoding failed: \(error)") + nkLog(debug: "Client Integration response decoding failed: \(error)") return (account, nil, .invalidData) } } From afbbfdf31dbae5c5e0acc56ab74b2c7028387305 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 10 Dec 2025 16:12:36 +0100 Subject: [PATCH 8/9] WIP Signed-off-by: Milen Pivchev --- .../DeclarativeUI/NKClientIntegration.swift | 30 ------------------- .../NKClientIntegrationUIResponse.swift | 8 ++--- 2 files changed, 4 insertions(+), 34 deletions(-) diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift index 222cd862..758ed0b1 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift @@ -61,35 +61,5 @@ public struct ContextMenuAction: Codable { case name, url, method, icon, params case mimetypeFilters = "mimetype_filters" } - -// func asRequest(user: String, password: String, userAgent: String? = nil, -// options: NKRequestOptions = NKRequestOptions(), -// taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, -// completion: @escaping (_ token: String?, _ responseData: AFDataResponse?, _ error: NKError) -> Void) async { -// -// // Convert string method to Alamofire HTTPMethod -// let httpMethod = HTTPMethod(rawValue: method.uppercased()) -// -// // Map params/bodyParams arrays into key/value (example: fileId → dummy value) -// var queryParams: [String: Any]? = nil -// if let params = params { -// queryParams = Dictionary(uniqueKeysWithValues: params.map { ($0, "SOME_VALUE") }) -// } -// -// var body: [String: Any]? = nil -// if let bodyParams = bodyParams { -// body = Dictionary(uniqueKeysWithValues: bodyParams.map { ($0, "SOME_BODY_VALUE") }) -// } -// -// await NextcloudKit.shared.sendRequestAsync(fullUrl: url, -// method: httpMethod, -// user: user, -// password: password, -// userAgent: userAgent, -// params: queryParams, -// bodyParams: body, -// options: options, -// taskHandler: taskHandler) -// } } diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift index 0ee74543..ac01ea42 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift @@ -18,9 +18,9 @@ public struct Meta: Codable { } public struct ResponseData: Codable { - // TODO: add after -// public let version: String + public let version: Double public let tooltip: String? + public let root: RootContainer? } public struct RootContainer: Codable { @@ -34,6 +34,6 @@ public struct Row: Codable { public struct Child: Codable { public let element: String - public let text: String? - public let url: String? + public let text: String + public let url: String } From f76bd0497f3d6ad5d32f4ebee019d82e4cc6123f Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Fri, 16 Jan 2026 13:49:23 +0100 Subject: [PATCH 9/9] Remove version fields for now Signed-off-by: Milen Pivchev --- .../Models/DeclarativeUI/NKClientIntegration.swift | 4 ++-- .../Models/DeclarativeUI/NKClientIntegrationUIResponse.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift index 758ed0b1..d5698cf9 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegration.swift @@ -40,11 +40,11 @@ public struct NKClientIntegration: Codable { } public struct AppContext: Codable { - public let version: Double +// public let version: Double public let contextMenu: [ContextMenuAction] enum CodingKeys: String, CodingKey { - case version +// case version case contextMenu = "context-menu" } } diff --git a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift index ac01ea42..3b903b4a 100644 --- a/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift +++ b/Sources/NextcloudKit/Models/DeclarativeUI/NKClientIntegrationUIResponse.swift @@ -18,7 +18,7 @@ public struct Meta: Codable { } public struct ResponseData: Codable { - public let version: Double +// public let version: String public let tooltip: String? public let root: RootContainer? }