Skip to content
Open
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
15 changes: 14 additions & 1 deletion LocalPackage/Sources/DataSource/Dependencies/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
limitations under the License.
*/

import Foundation
import ImageIO
import UniformTypeIdentifiers

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) },
Expand All @@ -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
}
Comment on lines +45 to 54

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LUCA's DependencyClient is not a place to dump convenience functions. The convert function right above it is an exception and should not be used as a reference. As you can see from read and write, its purpose is to use the actual implementation as much as possible, right up to the point where mocking is necessary. If you want to achieve what fetch does, you should implement URLSessionClient and use a combination of read and URLSessionClient.data(URL).

)

public static let testValue = Self(
read: { _ in Data() },
write: { _, _ in },
convert: { _, _ in Data() }
convert: { _, _ in Data() },
fetch: { _ in Data() }
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,22 @@ 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

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
Comment on lines +71 to +73

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You should not invoke this kind of logic without using a Store (Composable class).

}

@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)
}
Comment on lines +91 to 94

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You should not write this kind of logic in the View layer. Also, you must not use DependencyClient functions within the View layer. This is because we do not write tests for the View layer in LUCA. All logic must be written in the Store.

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
4 changes: 3 additions & 1 deletion docs/CustomMetricsSchema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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" },
Expand All @@ -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<Metric\> | 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. |
Expand Down