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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ 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

### 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
Expand Down
143 changes: 143 additions & 0 deletions TablePro/Core/Plugins/Registry/DownloadCountService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//
// 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
}

private func loadCache() {
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
}

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
}
51 changes: 51 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,38 @@
}
}
},
"%@ 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" : {
"en" : {
Expand Down Expand Up @@ -2248,6 +2280,9 @@
}
}
}
},
"Auth Database" : {

},
"Authenticate to execute database operations" : {

Expand Down Expand Up @@ -5657,6 +5692,22 @@
}
}
},
"Downloads" : {
"localizations" : {
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Lượt tải"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "下载次数"
}
}
}
},
"Drop" : {
"extractionState" : "stale",
"localizations" : {
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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") {}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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) }
)
}
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Views/Settings/Plugins/InstalledPluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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? {
Expand Down
18 changes: 18 additions & 0 deletions TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
36 changes: 35 additions & 1 deletion TablePro/Views/Settings/Plugins/RegistryPluginRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -56,6 +67,29 @@ 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 count == 1
? String(localized: "\(formatted) download")
: String(localized: "\(formatted) downloads")
}

@ViewBuilder
private var actionButton: some View {
if isInstalled {
Expand Down