From 17a235de9f4116d4cafcc862469a33ee957b5b7a Mon Sep 17 00:00:00 2001 From: nopo Date: Thu, 16 Jul 2026 01:25:36 +0900 Subject: [PATCH] Support custom metric card icons from URLs Signed-off-by: nopo --- .../DataSource/Dependencies/DataClient.swift | 15 ++++++++- .../CustomMetrics/CustomMetricsSnapshot.swift | 3 ++ .../Dashboard/CustomMetricsCardView.swift | 31 +++++++++++++++++-- .../CustomMetricsSnapshotTests.swift | 19 ++++++++++++ docs/CustomMetricsSchema.md | 4 ++- 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/LocalPackage/Sources/DataSource/Dependencies/DataClient.swift b/LocalPackage/Sources/DataSource/Dependencies/DataClient.swift index 64638d4..bf4d10c 100644 --- a/LocalPackage/Sources/DataSource/Dependencies/DataClient.swift +++ b/LocalPackage/Sources/DataSource/Dependencies/DataClient.swift @@ -18,6 +18,7 @@ limitations under the License. */ +import Foundation import ImageIO import UniformTypeIdentifiers @@ -25,6 +26,7 @@ public struct DataClient: DependencyClient { public var read: @Sendable (URL) throws -> Data public var write: @Sendable (Data, URL) throws -> Void public var convert: @Sendable (CGImage, UTType) throws -> Data + public var fetch: @Sendable (URL) async throws -> Data public static let liveValue = Self( read: { try Data(contentsOf: $0) }, @@ -39,12 +41,23 @@ public struct DataClient: DependencyClient { throw CGError.failure.nsError } return data as Data + }, + fetch: { url in + if url.isFileURL { + return try Data(contentsOf: url) + } + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + throw URLError(.badServerResponse) + } + return data } ) public static let testValue = Self( read: { _ in Data() }, write: { _, _ in }, - convert: { _, _ in Data() } + convert: { _, _ in Data() }, + fetch: { _ in Data() } ) } diff --git a/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift b/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift index c6840b3..7ce69d5 100644 --- a/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift +++ b/LocalPackage/Sources/DataSource/Entities/CustomMetrics/CustomMetricsSnapshot.swift @@ -23,6 +23,7 @@ import Foundation public struct CustomMetricsSnapshot: Codable, Sendable, Equatable { public var title: String public var symbol: String? + public var iconURL: URL? public var metricsBarValue: String? public var metrics: [CustomMetric] public var lastUpdatedDate: Date @@ -30,12 +31,14 @@ public struct CustomMetricsSnapshot: Codable, Sendable, Equatable { public init( title: String, symbol: String? = nil, + iconURL: URL? = nil, metricsBarValue: String? = nil, metrics: [CustomMetric] = [], lastUpdatedDate: Date ) { self.title = title self.symbol = symbol + self.iconURL = iconURL self.metricsBarValue = metricsBarValue self.metrics = metrics self.lastUpdatedDate = lastUpdatedDate diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift index 02ec47d..b6120cc 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/CustomMetricsCardView.swift @@ -18,12 +18,16 @@ limitations under the License. */ +import AppKit import DataSource +import Model import SwiftUI struct CustomMetricsCardView: View { var snapshot: CustomMetricsSnapshot var isFailed: Bool + @Environment(\.appDependencies) private var appDependencies + @State private var loadedIcon: NSImage? init(customMetricsBundle: CustomMetricsBundle) { self.snapshot = customMetricsBundle.snapshot @@ -40,9 +44,7 @@ struct CustomMetricsCardView: View { var body: some View { HStack(alignment: .center, spacing: 16) { - Image(systemName: snapshot.displaySymbol) - .resizable() - .scaledToFit() + icon .frame(width: 24, height: 24) VStack(alignment: .leading, spacing: 2) { Text(verbatim: snapshot.title) @@ -66,5 +68,28 @@ struct CustomMetricsCardView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .materialCellStyle() + .task(id: snapshot.iconURL) { + loadedIcon = await loadIcon(from: snapshot.iconURL) + } + } + + @ViewBuilder + private var icon: some View { + if let loadedIcon { + Image(nsImage: loadedIcon) + .resizable() + .scaledToFit() + .accessibilityHidden(true) + } else { + Image(systemName: snapshot.displaySymbol) + .resizable() + .scaledToFit() + .accessibilityHidden(true) + } + } + + private func loadIcon(from url: URL?) async -> NSImage? { + guard let url, let data = try? await appDependencies.dataClient.fetch(url) else { return nil } + return NSImage(data: data) } } diff --git a/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift b/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift index 673d084..e5316e2 100644 --- a/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift +++ b/LocalPackage/Tests/DataSourceTests/EntityTests/CustomMetricsSnapshotTests.swift @@ -72,6 +72,25 @@ struct CustomMetricsSnapshotTests { #expect(snapshot == expected) } + @Test + func decode_snapshot_with_iconURL() throws { + let json = """ + { + "title": "Sessions", + "iconURL": "https://example.com/icon.png", + "metrics": [], + "lastUpdatedDate": "2026-06-05T04:50:40Z" + } + """.data(using: .utf8)! + #expect( + try decoder.decode(CustomMetricsSnapshot.self, from: json) == CustomMetricsSnapshot( + title: "Sessions", + iconURL: URL(string: "https://example.com/icon.png"), + lastUpdatedDate: try #require(ISO8601DateFormatter().date(from: "2026-06-05T04:50:40Z")) + ) + ) + } + @Test func decode_throws_when_title_missing() { let json = """ diff --git a/docs/CustomMetricsSchema.md b/docs/CustomMetricsSchema.md index 666f9f9..0be7b0c 100644 --- a/docs/CustomMetricsSchema.md +++ b/docs/CustomMetricsSchema.md @@ -4,7 +4,7 @@ RunCat Neo can watch any local JSON file you point it at and render it as a card ## Overview -You decide what to track (Claude Code usage, GPU temperature, GitHub contributions, remaining reminders, anything else). You write a small script or program that keeps a JSON file on disk up to date. RunCat watches the file with a `DispatchSource` file-system event source and updates the card the moment the file changes. RunCat itself never polls the file and never makes network calls — whether your data comes from the network is entirely up to your script. +You decide what to track (Claude Code usage, GPU temperature, GitHub contributions, remaining reminders, anything else). You write a small script or program that keeps a JSON file on disk up to date. RunCat watches the file with a `DispatchSource` file-system event source and updates the card the moment the file changes without polling. A remote `iconURL`, when provided, is fetched separately. To add a source: open RunCat's settings, go to **Metrics** → **Custom Metrics**, and click **Add Custom Metrics Source**, then pick the JSON file. The file is bookmarked with a security-scoped bookmark so the access survives sandbox restarts. @@ -18,6 +18,7 @@ A valid file might look like this: { "title": "Claude Code", "symbol": "staroflife", + "iconURL": "https://example.com/icon.png", "metricsBarValue": "5.4%", "metrics": [ { "title": "Model", "formattedValue": "Opus 4.7" }, @@ -37,6 +38,7 @@ The values above are illustrative — `title`, `symbol`, and the metric labels a |-------------------|----------------|----------|-------------| | `title` | string | yes | Card header text. | | `symbol` | string | no | [SF Symbol](https://developer.apple.com/sf-symbols/) identifier shown next to the title. Defaults to `chart.bar.horizontal.page.fill`. | +| `iconURL` | string | no | `https://` or `file://` URL for the card icon. A remote URL causes a network request to its host. Falls back to `symbol` when the image cannot be loaded. | | `metricsBarValue` | string | no | Short text shown in the Metrics Bar (the dedicated menu-bar item) next to the source's symbol. Displayed verbatim; keep it short — the bar caps the label width and truncates longer strings. Each source is hidden in the bar by default: click the Metrics Bar and flip the source's toggle to show it. When the source is shown but this field is omitted, the bar renders `---`. | | `metrics` | array | yes | Rows displayed inside the card. Empty array is allowed. | | `lastUpdatedDate` | string | yes | ISO 8601 timestamp (e.g. `"2026-06-05T04:50:40Z"`) of when the producer wrote this file. Shown as a relative time (`"3 min ago"`) at the bottom of the card; updates automatically. |