Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## [0.17.0] - 2026-03-11

### Added
Expand Down
141 changes: 141 additions & 0 deletions TablePro/Core/Plugins/Registry/DownloadCountService.swift
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

// 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
}
9 changes: 9 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@
}
}
}
},
"%@ downloads" : {

},
Comment thread
datlechin marked this conversation as resolved.
Comment thread
datlechin marked this conversation as resolved.
"%@ is already assigned to \"%@\". Reassigning will remove it from that action." : {
"localizations" : {
Expand Down Expand Up @@ -2248,6 +2251,9 @@
}
}
}
},
"Auth Database" : {

},
"Authenticate to execute database operations" : {

Expand Down Expand Up @@ -5656,6 +5662,9 @@
}
}
}
},
"Downloads" : {

},
"Drop" : {
"extractionState" : "stale",
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
34 changes: 33 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,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")
}
Comment thread
datlechin marked this conversation as resolved.

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