-
-
Notifications
You must be signed in to change notification settings - Fork 314
feat(team-library): share connections and saved queries with your team #1832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9d67838
feat(team-library): app-side publish and pull with tier gating
datlechin b56fe29
feat(team-library): show shared queries in the sidebar and publish sa…
datlechin 97c171d
docs(changelog): document the Team Library feature
datlechin 4883c67
fix(team-library): run publish alert tasks on the main actor
datlechin c66570c
feat(team-library): show shared connections in the welcome connection…
datlechin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 100 additions & 0 deletions
100
TablePro/Core/Services/TeamLibrary/LiveTeamLibraryAPIClient.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| // | ||
| // LiveTeamLibraryAPIClient.swift | ||
| // TablePro | ||
| // | ||
| // URLSession client for the team library endpoints. Mirrors LicenseAPIClient's request pattern and | ||
| // reuses LicenseError. The encoder uses no key strategy so CodingKeys drive the snake_case wire | ||
| // format while the embedded ExportableConnection payload keeps its own encoding. | ||
| // | ||
|
|
||
| import Foundation | ||
| import os | ||
|
|
||
| final class LiveTeamLibraryAPIClient: TeamLibraryAPIClient { | ||
| static let shared = LiveTeamLibraryAPIClient() | ||
|
|
||
| private static let logger = Logger(subsystem: "com.TablePro", category: "TeamLibraryAPIClient") | ||
|
|
||
| private let baseURL = URL(string: "https://api.tablepro.app/v1/license/library")! | ||
| private let session: URLSession | ||
| private let encoder = JSONEncoder() | ||
| private let decoder = JSONDecoder() | ||
|
|
||
| init(session: URLSession? = nil) { | ||
| if let session { | ||
| self.session = session | ||
| } else { | ||
| let config = URLSessionConfiguration.default | ||
| config.timeoutIntervalForRequest = 15 | ||
| config.timeoutIntervalForResource = 30 | ||
| config.waitsForConnectivity = true | ||
| self.session = URLSession(configuration: config) | ||
| } | ||
| } | ||
|
|
||
| func pull(licenseKey: String, machineId: String) async throws -> TeamLibraryPullResponse { | ||
| try await send(method: "POST", path: "pull", body: TeamLibraryPullRequest(licenseKey: licenseKey, machineId: machineId)) | ||
| } | ||
|
|
||
| func publish(_ request: TeamLibraryPublishRequest) async throws -> TeamLibraryPublishResponse { | ||
| try await send(method: "POST", path: "publish", body: request) | ||
| } | ||
|
|
||
| func deleteConnection(id: String, licenseKey: String, machineId: String) async throws { | ||
| try await sendVoid(path: "connections/\(id)", licenseKey: licenseKey, machineId: machineId) | ||
| } | ||
|
|
||
| func deleteQuery(clientId: String, licenseKey: String, machineId: String) async throws { | ||
| try await sendVoid(path: "queries/\(clientId)", licenseKey: licenseKey, machineId: machineId) | ||
| } | ||
|
|
||
| func deleteQueryFolder(clientId: String, licenseKey: String, machineId: String) async throws { | ||
| try await sendVoid(path: "query-folders/\(clientId)", licenseKey: licenseKey, machineId: machineId) | ||
| } | ||
|
|
||
| private func send<T: Encodable, R: Decodable>(method: String, path: String, body: T) async throws -> R { | ||
| let data = try await perform(method: method, path: path, body: body) | ||
| do { | ||
| return try decoder.decode(R.self, from: data) | ||
| } catch { | ||
| throw LicenseError.decodingError(error) | ||
| } | ||
| } | ||
|
|
||
| private func sendVoid(path: String, licenseKey: String, machineId: String) async throws { | ||
| _ = try await perform(method: "DELETE", path: path, body: TeamLibraryPullRequest(licenseKey: licenseKey, machineId: machineId)) | ||
| } | ||
|
|
||
| private func perform<T: Encodable>(method: String, path: String, body: T) async throws -> Data { | ||
| var request = URLRequest(url: baseURL.appendingPathComponent(path)) | ||
| request.httpMethod = method | ||
| request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
| request.setValue("application/json", forHTTPHeaderField: "Accept") | ||
| request.httpBody = try encoder.encode(body) | ||
|
|
||
| let data: Data | ||
| let response: URLResponse | ||
| do { | ||
| (data, response) = try await session.data(for: request) | ||
| } catch { | ||
| throw LicenseError.networkError(error) | ||
| } | ||
|
|
||
| guard let http = response as? HTTPURLResponse else { | ||
| throw LicenseError.networkError(URLError(.badServerResponse)) | ||
| } | ||
|
|
||
| guard (200...299).contains(http.statusCode) else { | ||
| let message: String | ||
| if let errorResponse = try? decoder.decode(LicenseAPIErrorResponse.self, from: data) { | ||
| message = errorResponse.message | ||
| } else { | ||
| message = HTTPURLResponse.localizedString(forStatusCode: http.statusCode) | ||
| } | ||
| Self.logger.error("Team library request failed \(http.statusCode): \(message)") | ||
| throw LicenseError.serverError(http.statusCode, message) | ||
| } | ||
|
|
||
| return data | ||
| } | ||
| } |
46 changes: 46 additions & 0 deletions
46
TablePro/Core/Services/TeamLibrary/TeamLibraryAPIClient.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| // | ||
| // TeamLibraryAPIClient.swift | ||
| // TablePro | ||
| // | ||
| // HTTP boundary for the backend-hosted team library. A protocol so the sync coordinator can be | ||
| // unit-tested against a mock without touching the network. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| protocol TeamLibraryAPIClient: Sendable { | ||
| func pull(licenseKey: String, machineId: String) async throws -> TeamLibraryPullResponse | ||
| func publish(_ request: TeamLibraryPublishRequest) async throws -> TeamLibraryPublishResponse | ||
| func deleteConnection(id: String, licenseKey: String, machineId: String) async throws | ||
| func deleteQuery(clientId: String, licenseKey: String, machineId: String) async throws | ||
| func deleteQueryFolder(clientId: String, licenseKey: String, machineId: String) async throws | ||
| } | ||
|
|
||
| final class MockTeamLibraryAPIClient: TeamLibraryAPIClient, @unchecked Sendable { | ||
| var pullResponse: TeamLibraryPullResponse = .empty | ||
| var publishResponse = TeamLibraryPublishResponse(publishedAt: "", connectionCount: 0, queryCount: 0) | ||
| var pullError: Error? | ||
|
|
||
| private(set) var pullCallCount = 0 | ||
| private(set) var publishedRequests: [TeamLibraryPublishRequest] = [] | ||
| private(set) var deletedConnectionIds: [String] = [] | ||
|
|
||
| func pull(licenseKey: String, machineId: String) async throws -> TeamLibraryPullResponse { | ||
| pullCallCount += 1 | ||
| if let pullError { throw pullError } | ||
| return pullResponse | ||
| } | ||
|
|
||
| func publish(_ request: TeamLibraryPublishRequest) async throws -> TeamLibraryPublishResponse { | ||
| publishedRequests.append(request) | ||
| return publishResponse | ||
| } | ||
|
|
||
| func deleteConnection(id: String, licenseKey: String, machineId: String) async throws { | ||
| deletedConnectionIds.append(id) | ||
| } | ||
|
|
||
| func deleteQuery(clientId: String, licenseKey: String, machineId: String) async throws {} | ||
|
|
||
| func deleteQueryFolder(clientId: String, licenseKey: String, machineId: String) async throws {} | ||
| } |
32 changes: 32 additions & 0 deletions
32
TablePro/Core/Services/TeamLibrary/TeamLibraryMetadataStorage.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // | ||
| // TeamLibraryMetadataStorage.swift | ||
| // TablePro | ||
| // | ||
| // Remembers when the team library was last pulled so the app refreshes on the license revalidation | ||
| // cadence rather than on every launch. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| enum TeamLibraryMetadataStorage { | ||
| private static let lastPullKey = "com.TablePro.teamLibrary.lastPullAt" | ||
| private static let pullInterval: TimeInterval = 7 * 24 * 60 * 60 | ||
|
|
||
| static var lastPullAt: Date? { | ||
| let timestamp = UserDefaults.standard.double(forKey: lastPullKey) | ||
| return timestamp > 0 ? Date(timeIntervalSince1970: timestamp) : nil | ||
| } | ||
|
|
||
| static var isPullDue: Bool { | ||
| guard let lastPullAt else { return true } | ||
| return Date().timeIntervalSince(lastPullAt) >= pullInterval | ||
| } | ||
|
|
||
| static func recordPull() { | ||
| UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastPullKey) | ||
| } | ||
|
|
||
| static func reset() { | ||
| UserDefaults.standard.removeObject(forKey: lastPullKey) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // | ||
| // TeamLibraryStore.swift | ||
| // TablePro | ||
| // | ||
| // Local cache of the pulled team library. Read-only content, small enough to keep as a single JSON | ||
| // file (matching how other small caches persist), guarded by an actor so writes serialize. | ||
| // | ||
|
|
||
| import Foundation | ||
| import os | ||
|
|
||
| actor TeamLibraryStore { | ||
| static let shared = TeamLibraryStore() | ||
|
|
||
| private static let logger = Logger(subsystem: "com.TablePro", category: "TeamLibraryStore") | ||
|
|
||
| private let fileURL: URL | ||
| private var cached: TeamLibraryPullResponse? | ||
|
|
||
| init(fileURL: URL? = nil) { | ||
| if let fileURL { | ||
| self.fileURL = fileURL | ||
| } else { | ||
| let directory = FileManager.default | ||
| .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | ||
| .appendingPathComponent("TablePro", isDirectory: true) | ||
| try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||
| self.fileURL = directory.appendingPathComponent("team_library.json") | ||
| } | ||
| } | ||
|
|
||
| func load() -> TeamLibraryPullResponse? { | ||
| if let cached { | ||
| return cached | ||
| } | ||
| guard let data = try? Data(contentsOf: fileURL) else { | ||
| return nil | ||
| } | ||
| cached = try? JSONDecoder().decode(TeamLibraryPullResponse.self, from: data) | ||
| return cached | ||
| } | ||
|
|
||
| func replace(_ response: TeamLibraryPullResponse) { | ||
| cached = response | ||
| do { | ||
| try JSONEncoder().encode(response).write(to: fileURL, options: .atomic) | ||
| } catch { | ||
| Self.logger.error("Failed to cache team library: \(error.localizedDescription)") | ||
| } | ||
| } | ||
|
|
||
| func clear() { | ||
| cached = .empty | ||
| try? FileManager.default.removeItem(at: fileURL) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Starting the coordinator only from post-launch activation means a common flow is missed: when the app launches unlicensed or on Starter,
start()returns at its feature gate, and activating a Team license or accepting an invite later does not callstart()orpull(). Since periodic validation only runs on the 7-day cadence, newly joined Team users will not see shared queries until relaunch or revalidation; trigger a pull from the activation/status-change path as well.Useful? React with 👍 / 👎.