From 8a3e9722485cc9cfbd70f590a8ec0beb2d213ac8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 13:58:33 +0700 Subject: [PATCH 1/2] feat: show plugin download counts from GitHub Releases API --- CHANGELOG.md | 4 + .../Registry/DownloadCountService.swift | 141 ++++++++++++++++++ TablePro/Resources/Localizable.xcstrings | 9 ++ .../Settings/Plugins/BrowsePluginsView.swift | 4 + .../Plugins/InstalledPluginsView.swift | 12 +- .../Plugins/RegistryPluginDetailView.swift | 18 +++ .../Settings/Plugins/RegistryPluginRow.swift | 34 ++++- 7 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 TablePro/Core/Plugins/Registry/DownloadCountService.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 300f2277a..b242ef81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour + ## [0.17.0] - 2026-03-11 ### Added diff --git a/TablePro/Core/Plugins/Registry/DownloadCountService.swift b/TablePro/Core/Plugins/Registry/DownloadCountService.swift new file mode 100644 index 000000000..3c7fd2a3b --- /dev/null +++ b/TablePro/Core/Plugins/Registry/DownloadCountService.swift @@ -0,0 +1,141 @@ +// +// DownloadCountService.swift +// TablePro +// + +import Foundation +import os + +@MainActor @Observable +final class DownloadCountService { + static let shared = DownloadCountService() + + private var counts: [String: Int] = [:] + private static let logger = Logger(subsystem: "com.TablePro", category: "DownloadCountService") + + private static let cacheKey = "downloadCountsCache" + private static let cacheDateKey = "downloadCountsCacheDate" + private static let cacheTTL: TimeInterval = 3_600 // 1 hour + + // swiftlint:disable:next force_unwrapping + private static let releasesURL = URL(string: "https://api.github.com/repos/datlechin/TablePro/releases?per_page=100")! + + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + self.session = URLSession(configuration: config) + + loadCache() + } + + // MARK: - Public + + func downloadCount(for pluginId: String) -> Int? { + counts[pluginId] + } + + func fetchCounts(for manifest: RegistryManifest?) async { + guard let manifest else { return } + + if isCacheValid() { + Self.logger.debug("Using cached download counts") + return + } + + do { + let releases = try await fetchReleases() + let pluginReleases = releases.filter { $0.tagName.hasPrefix("plugin-") } + let urlToPluginId = buildURLMap(from: manifest) + + var totals: [String: Int] = [:] + for release in pluginReleases { + for asset in release.assets { + if let pluginId = urlToPluginId[asset.browserDownloadUrl] { + totals[pluginId, default: 0] += asset.downloadCount + } + } + } + + counts = totals + saveCache(totals) + Self.logger.info("Fetched download counts for \(totals.count) plugin(s)") + } catch { + Self.logger.error("Failed to fetch download counts: \(error.localizedDescription)") + } + } + + // MARK: - GitHub API + + private func fetchReleases() async throws -> [GitHubRelease] { + var request = URLRequest(url: Self.releasesURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode([GitHubRelease].self, from: data) + } + + // MARK: - URL Mapping + + private func buildURLMap(from manifest: RegistryManifest) -> [String: String] { + var map: [String: String] = [:] + for plugin in manifest.plugins { + if let binaries = plugin.binaries { + for binary in binaries { + map[binary.downloadURL] = plugin.id + } + } + if let url = plugin.downloadURL { + map[url] = plugin.id + } + } + return map + } + + // MARK: - Cache + + private func isCacheValid() -> Bool { + guard let cacheDate = UserDefaults.standard.object(forKey: Self.cacheDateKey) as? Date else { + return false + } + return Date().timeIntervalSince(cacheDate) < Self.cacheTTL && !counts.isEmpty + } + + private func loadCache() { + guard let data = UserDefaults.standard.data(forKey: Self.cacheKey), + let cached = try? JSONDecoder().decode([String: Int].self, from: data) else { + return + } + counts = cached + } + + private func saveCache(_ totals: [String: Int]) { + if let data = try? JSONEncoder().encode(totals) { + UserDefaults.standard.set(data, forKey: Self.cacheKey) + UserDefaults.standard.set(Date(), forKey: Self.cacheDateKey) + } + } +} + +// MARK: - GitHub API Models + +private struct GitHubRelease: Decodable { + let tagName: String + let assets: [GitHubAsset] +} + +private struct GitHubAsset: Decodable { + let name: String + let downloadCount: Int + let browserDownloadUrl: String +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 53fe6e431..0f9d28b0d 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -304,6 +304,9 @@ } } } + }, + "%@ downloads" : { + }, "%@ is already assigned to \"%@\". Reassigning will remove it from that action." : { "localizations" : { @@ -2248,6 +2251,9 @@ } } } + }, + "Auth Database" : { + }, "Authenticate to execute database operations" : { @@ -5656,6 +5662,9 @@ } } } + }, + "Downloads" : { + }, "Drop" : { "extractionState" : "stale", diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index 63ed415a2..ff15cc881 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -9,6 +9,7 @@ struct BrowsePluginsView: View { private let registryClient = RegistryClient.shared private let pluginManager = PluginManager.shared private let installTracker = PluginInstallTracker.shared + private let downloadCountService = DownloadCountService.shared @State private var searchText = "" @State private var selectedCategory: RegistryCategory? @@ -31,6 +32,7 @@ struct BrowsePluginsView: View { if registryClient.fetchState == .idle { await registryClient.fetchManifest() } + await downloadCountService.fetchCounts(for: registryClient.manifest) } .alert("Installation Failed", isPresented: $showErrorAlert) { Button("OK") {} @@ -117,6 +119,7 @@ struct BrowsePluginsView: View { plugin: plugin, isInstalled: isPluginInstalled(plugin.id), installProgress: installTracker.state(for: plugin.id), + downloadCount: downloadCountService.downloadCount(for: plugin.id), onInstall: { installPlugin(plugin) }, onToggleDetail: { withAnimation(.easeInOut(duration: 0.2)) { @@ -130,6 +133,7 @@ struct BrowsePluginsView: View { plugin: plugin, isInstalled: isPluginInstalled(plugin.id), installProgress: installTracker.state(for: plugin.id), + downloadCount: downloadCountService.downloadCount(for: plugin.id), onInstall: { installPlugin(plugin) } ) } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 888d084a9..3cd9cea0c 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -93,7 +93,7 @@ struct InstalledPluginsView: View { @ViewBuilder private func pluginRow(_ plugin: PluginEntry) -> some View { HStack { - Image(systemName: plugin.iconName) + pluginIcon(plugin.iconName) .frame(width: 20) .foregroundStyle(plugin.isEnabled ? .primary : .tertiary) @@ -137,6 +137,16 @@ struct InstalledPluginsView: View { } } + @ViewBuilder + private func pluginIcon(_ name: String) -> some View { + if NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil { + Image(systemName: name) + } else { + Image(name) + .renderingMode(.template) + } + } + // MARK: - Detail Section private var selectedPlugin: PluginEntry? { diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift index 76969397c..16e110412 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -9,6 +9,7 @@ struct RegistryPluginDetailView: View { let plugin: RegistryPlugin let isInstalled: Bool let installProgress: InstallProgress? + let downloadCount: Int? let onInstall: () -> Void var body: some View { @@ -23,6 +24,13 @@ struct RegistryPluginDetailView: View { if let minVersion = plugin.minAppVersion { detailItem(label: "Requires", value: "v\(minVersion)+") } + + if let downloadCount { + detailItem( + label: String(localized: "Downloads"), + value: formattedDownloadCount(downloadCount) + ) + } } HStack(spacing: 16) { @@ -63,6 +71,16 @@ struct RegistryPluginDetailView: View { .padding(.vertical, 8) } + private static let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private func formattedDownloadCount(_ count: Int) -> String { + Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" + } + @ViewBuilder private func detailItem(label: String, value: String) -> some View { VStack(alignment: .leading, spacing: 2) { diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift index 94b5048eb..74735217a 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift @@ -9,12 +9,13 @@ struct RegistryPluginRow: View { let plugin: RegistryPlugin let isInstalled: Bool let installProgress: InstallProgress? + let downloadCount: Int? let onInstall: () -> Void let onToggleDetail: () -> Void var body: some View { HStack(spacing: 10) { - Image(systemName: plugin.iconName ?? "puzzlepiece") + pluginIcon(plugin.iconName ?? "puzzlepiece") .frame(width: 24, height: 24) .foregroundStyle(.secondary) @@ -42,6 +43,16 @@ struct RegistryPluginRow: View { Text(plugin.author.name) .font(.caption) .foregroundStyle(.secondary) + + if let downloadCount { + Text("\u{2022}") + .font(.caption2) + .foregroundStyle(.quaternary) + + Text(formattedCount(downloadCount)) + .font(.caption) + .foregroundStyle(.secondary) + } } } @@ -56,6 +67,27 @@ struct RegistryPluginRow: View { } } + @ViewBuilder + private func pluginIcon(_ name: String) -> some View { + if NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil { + Image(systemName: name) + } else { + Image(name) + .renderingMode(.template) + } + } + + private static let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + private func formattedCount(_ count: Int) -> String { + let formatted = Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" + return String(localized: "\(formatted) downloads") + } + @ViewBuilder private var actionButton: some View { if isInstalled { From af8e48bfbd99ec587f28052e516148ff3a3ca203 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 11 Mar 2026 14:05:40 +0700 Subject: [PATCH 2/2] fix: address PR review comments for download count feature --- CHANGELOG.md | 4 ++ .../Registry/DownloadCountService.swift | 6 ++- TablePro/Resources/Localizable.xcstrings | 46 ++++++++++++++++++- .../Settings/Plugins/RegistryPluginRow.swift | 4 +- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b242ef81b..9dd5da9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plugin download count display in Browse Plugins — fetched from GitHub Releases API and cached for 1 hour +### Fixed + +- Plugin icon rendering now supports custom asset images (e.g., duckdb-icon) alongside SF Symbols in Installed and Browse tabs + ## [0.17.0] - 2026-03-11 ### Added diff --git a/TablePro/Core/Plugins/Registry/DownloadCountService.swift b/TablePro/Core/Plugins/Registry/DownloadCountService.swift index 3c7fd2a3b..da5637941 100644 --- a/TablePro/Core/Plugins/Registry/DownloadCountService.swift +++ b/TablePro/Core/Plugins/Registry/DownloadCountService.swift @@ -108,12 +108,14 @@ final class DownloadCountService { guard let cacheDate = UserDefaults.standard.object(forKey: Self.cacheDateKey) as? Date else { return false } - return Date().timeIntervalSince(cacheDate) < Self.cacheTTL && !counts.isEmpty + return Date().timeIntervalSince(cacheDate) < Self.cacheTTL } private func loadCache() { - guard let data = UserDefaults.standard.data(forKey: Self.cacheKey), + guard isCacheValid(), + let data = UserDefaults.standard.data(forKey: Self.cacheKey), let cached = try? JSONDecoder().decode([String: Int].self, from: data) else { + counts = [:] return } counts = cached diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 0f9d28b0d..0fca0ae95 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -305,8 +305,37 @@ } } }, + "%@ download" : { + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ lượt tải" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 次下载" + } + } + } + }, "%@ downloads" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ lượt tải" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 次下载" + } + } + } }, "%@ is already assigned to \"%@\". Reassigning will remove it from that action." : { "localizations" : { @@ -5664,7 +5693,20 @@ } }, "Downloads" : { - + "localizations" : { + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượt tải" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载次数" + } + } + } }, "Drop" : { "extractionState" : "stale", diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift index 74735217a..e103ba9ad 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift @@ -85,7 +85,9 @@ struct RegistryPluginRow: View { private func formattedCount(_ count: Int) -> String { let formatted = Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" - return String(localized: "\(formatted) downloads") + return count == 1 + ? String(localized: "\(formatted) download") + : String(localized: "\(formatted) downloads") } @ViewBuilder