Skip to content

Commit e7cc7d2

Browse files
committed
feat: Client Integration Support
Signed-off-by: Iva Horn <iva.horn@nextcloud.com>
1 parent d93d9c9 commit e7cc7d2

7 files changed

Lines changed: 421 additions & 0 deletions

File tree

‎Sources/NextcloudCapabilitiesKit/Capabilities.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public struct Capabilities: Equatable, Sendable {
77
public let major: Int64
88
public let minor: Int64
99
public let patch: Int64
10+
public let clientIntegration: ClientIntegration?
1011
public let core: Core?
1112
public let bruteForce: BruteForce?
1213
public let files: Files?
@@ -51,6 +52,7 @@ public struct Capabilities: Equatable, Sendable {
5152
minor = Int64(minorValue)
5253
patch = Int64(microValue)
5354

55+
clientIntegration = ClientIntegration(capabilities: capabilities)
5456
core = Core(capabilities: capabilities)
5557
bruteForce = BruteForce(capabilities: capabilities)
5658
files = Files(capabilities: capabilities)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: LGPL-3.0-or-later
3+
4+
import Foundation
5+
6+
///
7+
/// The capability of client integration.
8+
///
9+
/// The presence of this capability indicates that clients can offer file context menu actions defined by and run on the server.
10+
///
11+
public struct ClientIntegration: Equatable, Sendable {
12+
///
13+
/// Server apps and what context menu items they offer.
14+
///
15+
public let apps: [ClientIntegrationApp]
16+
17+
///
18+
/// Try to parse the server response for client integration capabilities.
19+
///
20+
/// - Parameters:
21+
/// - capabilities: The `capabilities` dictionary as returned in the JSON from the server.
22+
///
23+
/// - Returns: `nil`, if the parsing failed.
24+
///
25+
init?(capabilities: [String: Any]) {
26+
guard let capabilities = capabilities["client_integration"] as? [String: Any] else {
27+
debugPrint("No client_integration data in received capabilities.")
28+
return nil
29+
}
30+
31+
var apps = [ClientIntegrationApp]()
32+
33+
for key in capabilities.keys.sorted() {
34+
guard let data = capabilities[key] as? [String: Any] else {
35+
continue
36+
}
37+
38+
if let app = ClientIntegrationApp(app: key, data: data) {
39+
apps.append(app)
40+
}
41+
}
42+
43+
self.apps = apps
44+
}
45+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: LGPL-3.0-or-later
3+
4+
import Foundation
5+
6+
///
7+
/// Data model for a server app offering client integration.
8+
///
9+
public struct ClientIntegrationApp: Equatable, Sendable {
10+
///
11+
/// The app identifier.
12+
///
13+
public let identifier: String
14+
15+
///
16+
/// Context menu items offered by this app.
17+
///
18+
public let contextMenuItems: [ClientIntegrationContextMenuItem]
19+
20+
///
21+
/// The server app version.
22+
///
23+
public let version: Double
24+
25+
init?(app: String, data: [String: Any]) {
26+
identifier = app
27+
28+
let versionValue = data["version"]
29+
let parsedVersion: Double? = if let version = versionValue as? Double {
30+
version
31+
} else if let version = versionValue as? Int {
32+
Double(version)
33+
} else if let version = versionValue as? NSNumber {
34+
version.doubleValue
35+
} else {
36+
nil
37+
}
38+
39+
guard let version = parsedVersion else {
40+
return nil
41+
}
42+
43+
self.version = version
44+
45+
guard let contextMenuItems = data["context-menu"] as? [[String: Any]] else {
46+
return nil
47+
}
48+
49+
var items = [ClientIntegrationContextMenuItem]()
50+
51+
for contextMenuItem in contextMenuItems {
52+
guard let item = ClientIntegrationContextMenuItem(data: contextMenuItem) else {
53+
continue
54+
}
55+
56+
items.append(item)
57+
}
58+
59+
self.contextMenuItems = items
60+
}
61+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: LGPL-3.0-or-later
3+
4+
import Foundation
5+
6+
///
7+
/// Data model for a context menu item.
8+
///
9+
public struct ClientIntegrationContextMenuItem: Equatable, Sendable {
10+
///
11+
/// A list of filters for MIME types.
12+
///
13+
public let filters: [String]
14+
15+
///
16+
/// An absolute path for an image to display in the context menu.
17+
///
18+
public let icon: String
19+
20+
///
21+
/// The HTTP verb to use when opening the ``path``.
22+
///
23+
public let method: String
24+
25+
///
26+
/// The user-visible label for the context menu item.
27+
///
28+
public let name: String
29+
30+
///
31+
/// Query parameters for the URL to open.
32+
///
33+
/// Values might contain placeholders which must be substituted by the client.
34+
///
35+
public let parameters: [String: String]
36+
37+
///
38+
/// The server address to open when this item is invoked.
39+
///
40+
/// This is an absolute path on the server and not a fully qualified URL.
41+
///
42+
public let path: String
43+
44+
init?(data: [String: Any]) {
45+
// filters
46+
47+
guard let filters = data["mimetype_filters"] as? String else {
48+
return nil
49+
}
50+
51+
self.filters = filters.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
52+
53+
// icon
54+
55+
guard let icon = data["icon"] as? String else {
56+
return nil
57+
}
58+
59+
self.icon = icon
60+
61+
// method
62+
63+
guard let method = data["method"] as? String else {
64+
return nil
65+
}
66+
67+
self.method = method
68+
69+
// name
70+
71+
guard let name = data["name"] as? String else {
72+
return nil
73+
}
74+
75+
self.name = name
76+
77+
// parameters
78+
79+
if let params = data["params"] as? [String: String] {
80+
parameters = params
81+
} else {
82+
parameters = [:]
83+
}
84+
85+
// path
86+
87+
guard let path = data["url"] as? String else {
88+
return nil
89+
}
90+
91+
self.path = path
92+
}
93+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
// SPDX-License-Identifier: LGPL-3.0-or-later
3+
4+
@testable import NextcloudCapabilitiesKit
5+
import Testing
6+
7+
@Suite("Client Integration App Tests")
8+
struct ClientIntegrationAppTests {
9+
@Test("Decode with double version")
10+
func decodeWithDoubleVersion() throws {
11+
let data: [String: Any] = [
12+
"version": 1.2,
13+
"context-menu": [
14+
[
15+
"icon": "/apps/assistant/img/client_integration/summarize.svg",
16+
"method": "POST",
17+
"mimetype_filters": "text/, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.oasis.opendocument.text, application/pdf",
18+
"name": "Summarize using AI",
19+
"url": "/ocs/v2.php/apps/assistant/api/v1/file-action/{fileId}/core:text2text:summary"
20+
]
21+
]
22+
]
23+
24+
let result = ClientIntegrationApp(app: "example", data: data)
25+
#expect(result != nil)
26+
27+
let app = try #require(result)
28+
29+
// Verify identifier
30+
#expect(app.identifier == "example")
31+
32+
// Verify version
33+
#expect(app.version == 1.2)
34+
}
35+
36+
@Test("Decode with integer version")
37+
func decodeWithIntegerVersion() throws {
38+
let data: [String: Any] = [
39+
"version": 2,
40+
"context-menu": []
41+
]
42+
43+
let result = ClientIntegrationApp(app: "test-app", data: data)
44+
#expect(result != nil)
45+
46+
let app = try #require(result)
47+
48+
// Verify integer version is converted to double
49+
#expect(app.version == 2.0)
50+
#expect(app.identifier == "test-app")
51+
#expect(app.contextMenuItems.isEmpty)
52+
}
53+
54+
// MARK: - Negative Tests
55+
56+
@Test("Decode fails with missing version")
57+
func decodeFailsWithMissingVersion() {
58+
let data: [String: Any] = [
59+
"context-menu": []
60+
]
61+
62+
let result = ClientIntegrationApp(app: "no-version", data: data)
63+
#expect(result == nil)
64+
}
65+
66+
@Test("Decode fails with invalid version type")
67+
func decodeFailsWithInvalidVersionType() {
68+
let data: [String: Any] = [
69+
"version": "1.2.3",
70+
"context-menu": []
71+
]
72+
73+
let result = ClientIntegrationApp(app: "invalid-version", data: data)
74+
#expect(result == nil)
75+
}
76+
77+
@Test("Decode fails with missing context-menu")
78+
func decodeFailsWithMissingContextMenu() {
79+
let data: [String: Any] = [
80+
"version": 1.0
81+
]
82+
83+
let result = ClientIntegrationApp(app: "no-context-menu", data: data)
84+
#expect(result == nil)
85+
}
86+
87+
@Test("Decode fails with invalid context-menu type")
88+
func decodeFailsWithInvalidContextMenuType() {
89+
let data: [String: Any] = [
90+
"version": 1.0,
91+
"context-menu": "not an array"
92+
]
93+
94+
let result = ClientIntegrationApp(app: "invalid-context-menu", data: data)
95+
#expect(result == nil)
96+
}
97+
}

0 commit comments

Comments
 (0)