modelstat 0.0.7 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -30
- package/dist/cli.mjs +3504 -464
- package/dist/cli.mjs.map +1 -1
- package/package.json +5 -5
- package/vendor/tray-mac/Package.swift +32 -0
- package/vendor/tray-mac/Resources/Info.plist +42 -0
- package/vendor/tray-mac/Sources/ModelstatTray/main.swift +353 -0
- package/vendor/tray-mac/build-app.sh +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelstat",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"description": "modelstat companion — reads local AI-tool usage and ships tokenised events to modelstat.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
|
+
"vendor/tray-mac",
|
|
13
14
|
"README.md",
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
@@ -17,16 +18,15 @@
|
|
|
17
18
|
"dev:connect": "tsx ./src/cli.ts connect",
|
|
18
19
|
"dev:scan": "tsx ./src/cli.ts scan",
|
|
19
20
|
"dev:watch": "tsx ./src/cli.ts watch",
|
|
20
|
-
"dev:register": "tsx ./src/cli.ts register",
|
|
21
21
|
"dev:discover": "tsx ./src/cli.ts discover",
|
|
22
22
|
"build": "tsup",
|
|
23
|
-
"
|
|
23
|
+
"build:tray": "bash ./scripts/build-tray.sh",
|
|
24
|
+
"prepack": "pnpm run build && pnpm run build:tray",
|
|
24
25
|
"pack:tarball": "pnpm run build && npm pack --pack-destination ../..",
|
|
25
26
|
"install:local": "bash ./scripts/install-local.sh",
|
|
26
27
|
"typecheck": "tsc --noEmit"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"better-sqlite3": "^11.7.0",
|
|
30
30
|
"chokidar": "^4.0.3",
|
|
31
31
|
"conf": "^13.1.0",
|
|
32
32
|
"dotenv": "^16.4.7",
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"undici": "^7.1.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@modelstat/companion-core": "workspace:*",
|
|
37
38
|
"@modelstat/core": "workspace:*",
|
|
38
39
|
"@modelstat/parsers": "workspace:*",
|
|
39
|
-
"@types/better-sqlite3": "^7.6.12",
|
|
40
40
|
"@types/node": "^22.10.5",
|
|
41
41
|
"tsup": "^8.3.5",
|
|
42
42
|
"tsx": "^4.19.2",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// swift-tools-version:5.9
|
|
2
|
+
//
|
|
3
|
+
// ModelstatTray — a tiny menu-bar app for macOS.
|
|
4
|
+
//
|
|
5
|
+
// No third-party deps. AppKit provides NSStatusItem for the menu-bar
|
|
6
|
+
// icon; Foundation is enough for subprocess + JSON. The whole thing
|
|
7
|
+
// is a single main.swift so release builds are fast and the bundle
|
|
8
|
+
// we ship in the DMG is small (~1 MB).
|
|
9
|
+
//
|
|
10
|
+
// Build:
|
|
11
|
+
// swift build -c release (executable at .build/release/modelstat-tray)
|
|
12
|
+
// ./build-app.sh (wraps the binary in ModelstatTray.app)
|
|
13
|
+
//
|
|
14
|
+
// Wired into the macOS install path in apps/agent-dev/src/service.ts —
|
|
15
|
+
// the launchd plist launches THIS instead of the headless daemon; the
|
|
16
|
+
// tray then spawns `modelstat start` as a child so there's still only
|
|
17
|
+
// one process managing the pipeline.
|
|
18
|
+
import PackageDescription
|
|
19
|
+
|
|
20
|
+
let package = Package(
|
|
21
|
+
name: "ModelstatTray",
|
|
22
|
+
platforms: [.macOS(.v12)],
|
|
23
|
+
products: [
|
|
24
|
+
.executable(name: "modelstat-tray", targets: ["ModelstatTray"])
|
|
25
|
+
],
|
|
26
|
+
targets: [
|
|
27
|
+
.executableTarget(
|
|
28
|
+
name: "ModelstatTray",
|
|
29
|
+
path: "Sources/ModelstatTray"
|
|
30
|
+
)
|
|
31
|
+
]
|
|
32
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
6
|
+
<string>en</string>
|
|
7
|
+
<key>CFBundleDisplayName</key>
|
|
8
|
+
<string>Modelstat</string>
|
|
9
|
+
<key>CFBundleExecutable</key>
|
|
10
|
+
<string>modelstat-tray</string>
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>ai.modelstat.tray</string>
|
|
13
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
14
|
+
<string>6.0</string>
|
|
15
|
+
<key>CFBundleName</key>
|
|
16
|
+
<string>Modelstat</string>
|
|
17
|
+
<key>CFBundlePackageType</key>
|
|
18
|
+
<string>APPL</string>
|
|
19
|
+
<key>CFBundleShortVersionString</key>
|
|
20
|
+
<string>0.1.0</string>
|
|
21
|
+
<key>CFBundleVersion</key>
|
|
22
|
+
<string>1</string>
|
|
23
|
+
<key>LSMinimumSystemVersion</key>
|
|
24
|
+
<string>12.0</string>
|
|
25
|
+
<!-- Agent-style app: no Dock icon, no window at launch, just the
|
|
26
|
+
menu-bar item. -->
|
|
27
|
+
<key>LSUIElement</key>
|
|
28
|
+
<true/>
|
|
29
|
+
<!-- Launch at login when the installer drops us into
|
|
30
|
+
~/Library/LaunchAgents (actually driven by the launchd plist). -->
|
|
31
|
+
<key>LSBackgroundOnly</key>
|
|
32
|
+
<false/>
|
|
33
|
+
<key>NSHighResolutionCapable</key>
|
|
34
|
+
<true/>
|
|
35
|
+
<key>NSSupportsAutomaticTermination</key>
|
|
36
|
+
<false/>
|
|
37
|
+
<key>NSSupportsSuddenTermination</key>
|
|
38
|
+
<true/>
|
|
39
|
+
<key>NSHumanReadableCopyright</key>
|
|
40
|
+
<string>© 2026 modelstat</string>
|
|
41
|
+
</dict>
|
|
42
|
+
</plist>
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// ModelstatTray — macOS menu-bar companion for the modelstat agent.
|
|
2
|
+
//
|
|
3
|
+
// What it does:
|
|
4
|
+
// · puts a "◉" status item in the menu bar
|
|
5
|
+
// · runs `modelstat start` as a child process so the pipeline is live
|
|
6
|
+
// · polls `modelstat stats --json` every 5 s to refresh the dropdown
|
|
7
|
+
// · offers: Open dashboard, Copy claim URL, View pipeline, Pause/Resume,
|
|
8
|
+
// Quit
|
|
9
|
+
//
|
|
10
|
+
// Spawning the CLI is deliberate: the CLI already owns discover/scan/
|
|
11
|
+
// watch + IngestClient retry semantics, and the tray never needs to
|
|
12
|
+
// touch ingestion or auth. Keeps this binary a thin shell — one file,
|
|
13
|
+
// ~300 LOC, no dependencies beyond AppKit/Foundation.
|
|
14
|
+
|
|
15
|
+
import AppKit
|
|
16
|
+
import Foundation
|
|
17
|
+
|
|
18
|
+
// ── Resolve the `modelstat` CLI on $PATH, then at the install-path
|
|
19
|
+
// the agent-dev installer writes into (~/.modelstat/bin/modelstat.mjs)
|
|
20
|
+
// so we don't rely on shell profiles loading in launchd's env.
|
|
21
|
+
func locateCli() -> URL? {
|
|
22
|
+
let fm = FileManager.default
|
|
23
|
+
let home = NSHomeDirectory()
|
|
24
|
+
let candidates = [
|
|
25
|
+
"\(home)/.modelstat/bin/modelstat.mjs",
|
|
26
|
+
"/opt/homebrew/bin/modelstat",
|
|
27
|
+
"/usr/local/bin/modelstat",
|
|
28
|
+
"/usr/bin/modelstat",
|
|
29
|
+
]
|
|
30
|
+
for p in candidates {
|
|
31
|
+
if fm.isExecutableFile(atPath: p) { return URL(fileURLWithPath: p) }
|
|
32
|
+
}
|
|
33
|
+
// Last-ditch: `which modelstat` via a login shell so PATH lookups
|
|
34
|
+
// honour the user's zsh/bash config.
|
|
35
|
+
let task = Process()
|
|
36
|
+
task.launchPath = "/bin/zsh"
|
|
37
|
+
task.arguments = ["-l", "-c", "which modelstat"]
|
|
38
|
+
let pipe = Pipe()
|
|
39
|
+
task.standardOutput = pipe
|
|
40
|
+
task.standardError = Pipe()
|
|
41
|
+
do { try task.run(); task.waitUntilExit() } catch { return nil }
|
|
42
|
+
let out = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
43
|
+
let path = out.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
44
|
+
return path.isEmpty ? nil : URL(fileURLWithPath: path)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
struct AgentStats: Decodable {
|
|
48
|
+
let paired: Bool?
|
|
49
|
+
let claimed: Bool?
|
|
50
|
+
let dashboard: String?
|
|
51
|
+
let claim_code: String?
|
|
52
|
+
let status: String?
|
|
53
|
+
let claim_url: String?
|
|
54
|
+
let agent_url: String?
|
|
55
|
+
let device: DeviceInfo?
|
|
56
|
+
let analyzed: AnalyzedInfo?
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
struct DeviceInfo: Decodable {
|
|
60
|
+
let hostname: String?
|
|
61
|
+
let os_family: String?
|
|
62
|
+
let agent_status: String?
|
|
63
|
+
let last_seen_at: String?
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
struct AnalyzedInfo: Decodable {
|
|
67
|
+
let count: Int?
|
|
68
|
+
let totalTokens: String?
|
|
69
|
+
let totalCostUsd: Double?
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@MainActor
|
|
73
|
+
final class TrayController: NSObject {
|
|
74
|
+
private let statusItem: NSStatusItem
|
|
75
|
+
private let menu = NSMenu()
|
|
76
|
+
private let cli: URL?
|
|
77
|
+
private var daemon: Process?
|
|
78
|
+
private var paused = false
|
|
79
|
+
private var latest: AgentStats?
|
|
80
|
+
private var pollTimer: Timer?
|
|
81
|
+
|
|
82
|
+
// Menu items we update on every poll
|
|
83
|
+
private let statusMI = NSMenuItem(title: "Starting…", action: nil, keyEquivalent: "")
|
|
84
|
+
private let deviceMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
|
|
85
|
+
private let analyzedMI = NSMenuItem(title: "", action: nil, keyEquivalent: "")
|
|
86
|
+
private let claimMI = NSMenuItem(title: "Open device page", action: #selector(openDashboard), keyEquivalent: "o")
|
|
87
|
+
private let copyClaimMI = NSMenuItem(title: "Copy claim URL", action: #selector(copyClaimUrl), keyEquivalent: "c")
|
|
88
|
+
private let jobsMI = NSMenuItem(title: "View pipeline…", action: #selector(openJobs), keyEquivalent: "j")
|
|
89
|
+
private let pauseMI = NSMenuItem(title: "Pause", action: #selector(togglePaused), keyEquivalent: "p")
|
|
90
|
+
|
|
91
|
+
override init() {
|
|
92
|
+
self.cli = locateCli()
|
|
93
|
+
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
94
|
+
super.init()
|
|
95
|
+
configureStatusItem()
|
|
96
|
+
buildMenu()
|
|
97
|
+
startDaemon()
|
|
98
|
+
refreshStats()
|
|
99
|
+
pollTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|
100
|
+
Task { @MainActor in self?.refreshStats() }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func configureStatusItem() {
|
|
105
|
+
// SF Symbol falls back to a bullet on older macOS versions; the
|
|
106
|
+
// title always works so we stack title + symbol for robustness.
|
|
107
|
+
if #available(macOS 11.0, *),
|
|
108
|
+
let btn = statusItem.button,
|
|
109
|
+
let img = NSImage(systemSymbolName: "circle.hexagongrid.fill", accessibilityDescription: "modelstat")
|
|
110
|
+
{
|
|
111
|
+
img.isTemplate = true
|
|
112
|
+
btn.image = img
|
|
113
|
+
} else {
|
|
114
|
+
statusItem.button?.title = "◉"
|
|
115
|
+
}
|
|
116
|
+
statusItem.button?.toolTip = "modelstat"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func buildMenu() {
|
|
120
|
+
statusMI.isEnabled = false
|
|
121
|
+
deviceMI.isEnabled = false
|
|
122
|
+
analyzedMI.isEnabled = false
|
|
123
|
+
for mi in [statusMI, deviceMI, analyzedMI] { menu.addItem(mi) }
|
|
124
|
+
menu.addItem(NSMenuItem.separator())
|
|
125
|
+
claimMI.target = self
|
|
126
|
+
copyClaimMI.target = self
|
|
127
|
+
jobsMI.target = self
|
|
128
|
+
pauseMI.target = self
|
|
129
|
+
menu.addItem(claimMI)
|
|
130
|
+
menu.addItem(copyClaimMI)
|
|
131
|
+
menu.addItem(jobsMI)
|
|
132
|
+
menu.addItem(pauseMI)
|
|
133
|
+
menu.addItem(NSMenuItem.separator())
|
|
134
|
+
let logsMI = NSMenuItem(title: "Open logs folder", action: #selector(openLogs), keyEquivalent: "l")
|
|
135
|
+
logsMI.target = self
|
|
136
|
+
menu.addItem(logsMI)
|
|
137
|
+
menu.addItem(NSMenuItem.separator())
|
|
138
|
+
let quitMI = NSMenuItem(title: "Quit modelstat", action: #selector(quit), keyEquivalent: "q")
|
|
139
|
+
quitMI.target = self
|
|
140
|
+
menu.addItem(quitMI)
|
|
141
|
+
statusItem.menu = menu
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Daemon lifecycle ─────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
private func startDaemon() {
|
|
147
|
+
guard let cli else {
|
|
148
|
+
statusMI.title = "modelstat CLI not found"
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
let p = Process()
|
|
152
|
+
if cli.pathExtension == "mjs" {
|
|
153
|
+
p.launchPath = "/usr/bin/env"
|
|
154
|
+
p.arguments = ["node", cli.path, "start"]
|
|
155
|
+
} else {
|
|
156
|
+
p.launchPath = cli.path
|
|
157
|
+
p.arguments = ["start"]
|
|
158
|
+
}
|
|
159
|
+
// Bolt stdout/stderr onto the same log the launchd plist uses so
|
|
160
|
+
// `modelstat status` still sees the same tail.
|
|
161
|
+
let logsDir = ("~/.modelstat/logs" as NSString).expandingTildeInPath
|
|
162
|
+
try? FileManager.default.createDirectory(atPath: logsDir, withIntermediateDirectories: true)
|
|
163
|
+
let out = FileHandle(forWritingAtPath: "\(logsDir)/out.log") ?? FileHandle.standardOutput
|
|
164
|
+
let err = FileHandle(forWritingAtPath: "\(logsDir)/err.log") ?? FileHandle.standardError
|
|
165
|
+
p.standardOutput = out
|
|
166
|
+
p.standardError = err
|
|
167
|
+
p.terminationHandler = { [weak self] proc in
|
|
168
|
+
// Daemon exited — if we didn't pause it intentionally, restart
|
|
169
|
+
// after 2s so a crash is self-healing without user clicks.
|
|
170
|
+
Task { @MainActor in
|
|
171
|
+
guard let self else { return }
|
|
172
|
+
guard !self.paused else { return }
|
|
173
|
+
self.daemon = nil
|
|
174
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
175
|
+
self?.startDaemon()
|
|
176
|
+
}
|
|
177
|
+
_ = proc
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
do {
|
|
181
|
+
try p.run()
|
|
182
|
+
daemon = p
|
|
183
|
+
} catch {
|
|
184
|
+
statusMI.title = "modelstat start failed: \(error.localizedDescription)"
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private func stopDaemon() {
|
|
189
|
+
daemon?.terminate()
|
|
190
|
+
daemon = nil
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Polling `modelstat stats --json` ────────────────────────────
|
|
194
|
+
|
|
195
|
+
private func refreshStats() {
|
|
196
|
+
guard let cli else { return }
|
|
197
|
+
let p = Process()
|
|
198
|
+
if cli.pathExtension == "mjs" {
|
|
199
|
+
p.launchPath = "/usr/bin/env"
|
|
200
|
+
p.arguments = ["node", cli.path, "stats", "--json"]
|
|
201
|
+
} else {
|
|
202
|
+
p.launchPath = cli.path
|
|
203
|
+
p.arguments = ["stats", "--json"]
|
|
204
|
+
}
|
|
205
|
+
let pipe = Pipe()
|
|
206
|
+
p.standardOutput = pipe
|
|
207
|
+
p.standardError = Pipe()
|
|
208
|
+
do {
|
|
209
|
+
try p.run()
|
|
210
|
+
} catch {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
// Run on a background queue so we don't block the main loop.
|
|
214
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
215
|
+
p.waitUntilExit()
|
|
216
|
+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
217
|
+
let stats = try? JSONDecoder().decode(AgentStats.self, from: data)
|
|
218
|
+
DispatchQueue.main.async {
|
|
219
|
+
self?.latest = stats
|
|
220
|
+
self?.renderStats()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private func renderStats() {
|
|
226
|
+
guard let s = latest else {
|
|
227
|
+
statusMI.title = "pairing…"
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if s.paired == false {
|
|
231
|
+
statusMI.title = "not paired — run `modelstat connect`"
|
|
232
|
+
deviceMI.title = ""
|
|
233
|
+
analyzedMI.title = ""
|
|
234
|
+
claimMI.title = "Open modelstat.ai"
|
|
235
|
+
copyClaimMI.isHidden = true
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if s.claimed == true {
|
|
239
|
+
statusMI.title = "Claimed ✓"
|
|
240
|
+
deviceMI.title = "Synced to your account"
|
|
241
|
+
analyzedMI.title = ""
|
|
242
|
+
claimMI.title = "Open dashboard"
|
|
243
|
+
copyClaimMI.isHidden = true
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
// Unclaimed: show the rich summary from the stats payload.
|
|
247
|
+
let status = s.device?.agent_status ?? "ready"
|
|
248
|
+
statusMI.title = "Agent: \(status)"
|
|
249
|
+
let host = s.device?.hostname ?? "unknown"
|
|
250
|
+
let os = s.device?.os_family ?? ""
|
|
251
|
+
deviceMI.title = "\(host) · \(os)"
|
|
252
|
+
if let a = s.analyzed {
|
|
253
|
+
let tok = a.totalTokens ?? "0"
|
|
254
|
+
let cnt = a.count ?? 0
|
|
255
|
+
let usd = String(format: "%.2f", a.totalCostUsd ?? 0.0)
|
|
256
|
+
analyzedMI.title = "\(cnt) sessions · \(fmtTokens(tok)) tokens · $\(usd)"
|
|
257
|
+
} else {
|
|
258
|
+
analyzedMI.title = "no activity yet"
|
|
259
|
+
}
|
|
260
|
+
claimMI.title = "Open device page"
|
|
261
|
+
copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func fmtTokens(_ raw: String) -> String {
|
|
265
|
+
guard let n = Double(raw) else { return raw }
|
|
266
|
+
if n >= 1e9 { return String(format: "%.1fB", n / 1e9) }
|
|
267
|
+
if n >= 1e6 { return String(format: "%.1fM", n / 1e6) }
|
|
268
|
+
if n >= 1e3 { return String(format: "%.0fK", n / 1e3) }
|
|
269
|
+
return String(format: "%.0f", n)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Menu actions ────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
@objc private func openDashboard() {
|
|
275
|
+
let url: String
|
|
276
|
+
if latest?.claimed == true {
|
|
277
|
+
url = "https://modelstat.ai/dashboard"
|
|
278
|
+
} else if let claim = latest?.claim_url, !claim.isEmpty {
|
|
279
|
+
url = claim
|
|
280
|
+
} else {
|
|
281
|
+
url = "https://modelstat.ai"
|
|
282
|
+
}
|
|
283
|
+
if let u = URL(string: url) { NSWorkspace.shared.open(u) }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@objc private func openJobs() {
|
|
287
|
+
let url: String
|
|
288
|
+
if latest?.claimed == true {
|
|
289
|
+
url = "https://modelstat.ai/dashboard/jobs"
|
|
290
|
+
} else if let claim = latest?.claim_url, !claim.isEmpty {
|
|
291
|
+
url = claim + "/jobs"
|
|
292
|
+
} else {
|
|
293
|
+
url = "https://modelstat.ai/dashboard/jobs"
|
|
294
|
+
}
|
|
295
|
+
if let u = URL(string: url) { NSWorkspace.shared.open(u) }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@objc private func copyClaimUrl() {
|
|
299
|
+
guard let claim = latest?.claim_url, !claim.isEmpty else { return }
|
|
300
|
+
let pb = NSPasteboard.general
|
|
301
|
+
pb.clearContents()
|
|
302
|
+
pb.setString(claim, forType: .string)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@objc private func togglePaused() {
|
|
306
|
+
paused.toggle()
|
|
307
|
+
if paused {
|
|
308
|
+
stopDaemon()
|
|
309
|
+
pauseMI.title = "Resume"
|
|
310
|
+
statusMI.title = "Paused"
|
|
311
|
+
} else {
|
|
312
|
+
pauseMI.title = "Pause"
|
|
313
|
+
startDaemon()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@objc private func openLogs() {
|
|
318
|
+
let path = ("~/.modelstat/logs" as NSString).expandingTildeInPath
|
|
319
|
+
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
@objc private func quit() {
|
|
323
|
+
stopDaemon()
|
|
324
|
+
pollTimer?.invalidate()
|
|
325
|
+
NSApp.terminate(nil)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── App bootstrap ──────────────────────────────────────────────────
|
|
330
|
+
//
|
|
331
|
+
// LSUIElement=true in Info.plist hides the Dock icon. Without it the
|
|
332
|
+
// agent would bounce in the Dock on every boot, which is not what
|
|
333
|
+
// anyone signed up for. We set it here as a belt — the plist is the
|
|
334
|
+
// braces — so a malformed bundle still behaves.
|
|
335
|
+
//
|
|
336
|
+
// TrayController is @MainActor, so its init must run on the main
|
|
337
|
+
// actor. We wrap the bootstrap in a main-actor function to satisfy
|
|
338
|
+
// Swift 6's strict concurrency without bloating the controller.
|
|
339
|
+
@MainActor
|
|
340
|
+
func bootstrap() {
|
|
341
|
+
let app = NSApplication.shared
|
|
342
|
+
app.setActivationPolicy(.accessory)
|
|
343
|
+
_ = TrayController()
|
|
344
|
+
app.run()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Swift's top-level code runs on the main thread implicitly, but the
|
|
348
|
+
// compiler doesn't infer main-actor isolation there. `DispatchQueue.main.async`
|
|
349
|
+
// schedules the bootstrap on the main queue where the @MainActor hop
|
|
350
|
+
// becomes a no-op; we keep the current thread alive with RunLoop.main
|
|
351
|
+
// until AppKit takes over.
|
|
352
|
+
DispatchQueue.main.async { bootstrap() }
|
|
353
|
+
RunLoop.main.run()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build ModelstatTray.app from the Swift package.
|
|
3
|
+
#
|
|
4
|
+
# Output:
|
|
5
|
+
# .build/release/modelstat-tray (raw executable)
|
|
6
|
+
# build/ModelstatTray.app (bundle ready to drop into /Applications)
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ./build-app.sh (release build, universal when running on arm64)
|
|
10
|
+
# SWIFT_ARCH=x86_64 ./build-app.sh (cross-build for Intel)
|
|
11
|
+
#
|
|
12
|
+
# We deliberately DO NOT codesign here — the installer pipeline takes
|
|
13
|
+
# care of that with the team's Developer ID. This script just has to
|
|
14
|
+
# produce a runnable bundle; `codesign` + `create-dmg` happen in CI.
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
19
|
+
cd "$HERE"
|
|
20
|
+
|
|
21
|
+
APP_NAME="ModelstatTray"
|
|
22
|
+
BUNDLE="build/${APP_NAME}.app"
|
|
23
|
+
|
|
24
|
+
# Clean prior bundle; leave the .build/ cache so incremental swift
|
|
25
|
+
# compiles stay fast.
|
|
26
|
+
rm -rf "$BUNDLE"
|
|
27
|
+
mkdir -p "$BUNDLE/Contents/MacOS" "$BUNDLE/Contents/Resources"
|
|
28
|
+
|
|
29
|
+
echo "▶ swift build -c release"
|
|
30
|
+
swift build -c release
|
|
31
|
+
|
|
32
|
+
cp ".build/release/modelstat-tray" "$BUNDLE/Contents/MacOS/modelstat-tray"
|
|
33
|
+
chmod +x "$BUNDLE/Contents/MacOS/modelstat-tray"
|
|
34
|
+
cp "Resources/Info.plist" "$BUNDLE/Contents/Info.plist"
|
|
35
|
+
|
|
36
|
+
# Embedded PkgInfo file — AppKit used to be picky about this. Costs
|
|
37
|
+
# four bytes to include and silences a startup warning on older macOS.
|
|
38
|
+
printf "APPL????" > "$BUNDLE/Contents/PkgInfo"
|
|
39
|
+
|
|
40
|
+
echo
|
|
41
|
+
echo "✓ Built $BUNDLE"
|
|
42
|
+
echo " run: open '$BUNDLE'"
|
|
43
|
+
echo " install: cp -R '$BUNDLE' /Applications/"
|