Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Join a team from the activation window: paste the invite code your team owner sent instead of a license key. Owners manage members and seats from their account on tablepro.app.
- Connections can take their password from 1Password (op://), HashiCorp Vault, or AWS Secrets Manager, resolved at connect time so the secret is never stored in TablePro.
- Team plan: publish connections to a shared folder your team reads from, without sending passwords. Right-click a connection, then Share > Publish to Team Catalog. Teammates add the folder under Settings > Linked Folders.
- Team Library: share connections and saved queries with your team through your account instead of a shared folder. Right-click a connection and choose Share > Publish to Team Library, or publish your saved queries from the Favorites sidebar. Teammates see shared connections in their connection list and shared queries in their sidebar, and you manage the library from your account on tablepro.app. Passwords are never included.
- Saved SQL queries and their folders now sync across your Macs when iCloud Sync is on. Toggle "Saved Queries" under Settings > Sync.
- Recent section at the top of the sidebar tracks the last 10 tables you opened per connection and database, in every sidebar layout, and remembers them between launches. It records a table when you open it, not while you arrow through previews. Click a recent table to reopen it, or right-click to remove one or clear the list. Off by default, turn it on in Settings > General > Sidebar. (#1352)
- Saving now asks you to confirm before it permanently deletes rows, so a bulk delete can't be committed by accident. (#1823)
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
AnalyticsService.shared.startPeriodicHeartbeat()
SyncCoordinator.shared.start()
LinkedFolderWatcher.shared.start()
TeamLibrarySyncCoordinator.shared.start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trigger team-library sync after activation

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 call start() or pull(). 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 👍 / 👎.


Task {
LicenseManager.shared.startPeriodicValidation()
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Events/AppEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ final class AppEvents {

let linkedFoldersDidUpdate = PassthroughSubject<Void, Never>()

let teamLibraryDidUpdate = PassthroughSubject<Void, Never>()

/// Linked SQL folder rescan completed; cached file index changed.
/// Senders are bulk rescans across all enabled folders, so payload is always `nil`.
/// The shape is kept consistent with `sqlFavoritesDidUpdate` so subscribers can
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Services/Licensing/LicenseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ final class LicenseManager {
self.license = updatedLicense
evaluateStatus()

await TeamLibrarySyncCoordinator.shared.pullIfNeeded()

Self.logger.trace("License re-validated successfully")
} catch {
// Network failure — use grace period
Expand Down
100 changes: 100 additions & 0 deletions TablePro/Core/Services/TeamLibrary/LiveTeamLibraryAPIClient.swift
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 TablePro/Core/Services/TeamLibrary/TeamLibraryAPIClient.swift
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 {}
}
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)
}
}
56 changes: 56 additions & 0 deletions TablePro/Core/Services/TeamLibrary/TeamLibraryStore.swift
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)
}
}
Loading
Loading