diff --git a/CHANGELOG.md b/CHANGELOG.md
index f02707b..651d3cf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,34 @@ The top entry is the current source version. Binary release metadata appears at
`/api/v1/latest` only after a signed and notarized DMG has actually been
published.
+## 0.2.5 — 2026-08-14
+
+- The widget updates itself. Clicking the update banner now downloads the
+ release, proves it three ways — the registry's published SHA-256, Apple's
+ notarization assessment, and the running app's own designated code-signing
+ requirement — swaps the bundle in place, and lets launchd relaunch the new
+ version seconds later. Any failed check, or an unwritable /Applications,
+ falls back to the old verified-DMG-in-Downloads flow. Quitting or updating
+ now also shuts down the Node bridge instead of stranding it.
+- The registry can publish a release without carrying the bytes:
+ `TOKEN_METER_LATEST_*` environment variables describe the version, digest,
+ and size, and `/download/token-widget.dmg` redirects to the immutable
+ GitHub release asset. `/api/v1/latest` goes live for the first time.
+- The DMG opens like a real installer: styled Finder window with background
+ art, hidden chrome, and the app and Applications laid out for the drag.
+- Share actions on the dashboard and public profiles are now icon buttons
+ (X, LinkedIn, copy link, PNG card) with the leaderboard CTA on its own
+ row; fixed a CSS bug that kept state-hidden share buttons visible.
+- Widget settings polish: "Share with friends" (formerly "Refer friends")
+ now copies the tokenwidget.app home page instead of the GitHub repo, and
+ the identity block leads with your @handle.
+- New homepage: hero, a scroll-tracked feature tour (live telemetry, the
+ runaway alarm, identity & community, privacy), and a privacy-first footer.
+ The support line now names everything tracked: Claude Code, Codex, Cline.
+
+Versions 0.2.3 and 0.2.4 were internal builds used to test the self-update
+pipeline end to end; they were never published.
+
## 0.2.2 — 2026-08-14
- Share buttons on public profiles and the local dashboard: post to X or
diff --git a/assets/dmg/background.png b/assets/dmg/background.png
new file mode 100644
index 0000000..187d9ca
Binary files /dev/null and b/assets/dmg/background.png differ
diff --git a/assets/dmg/background@2x.png b/assets/dmg/background@2x.png
new file mode 100644
index 0000000..9eea520
Binary files /dev/null and b/assets/dmg/background@2x.png differ
diff --git a/docs/install-claude-desktop.md b/docs/install-claude-desktop.md
index 7debf4e..c67d51a 100644
--- a/docs/install-claude-desktop.md
+++ b/docs/install-claude-desktop.md
@@ -181,6 +181,21 @@ The integration deliberately shows nothing rather than guessing another Session.
### Update
+The packaged app updates itself. The widget checks the registry at startup and
+hourly; when a newer release exists, a banner offers it. Clicking the banner
+downloads the DMG, checks it against the digest published at `/api/v1/latest`,
+runs Gatekeeper's assessment, and confirms the replacement bundle satisfies this
+build's own designated code-signing requirement — same team, same identifier.
+Only then is the bundle swapped in place, and the widget exits so its LaunchAgent
+(`KeepAlive`) restarts it on the new version a few seconds later. No download
+page, no drag, no reopening.
+
+If any check fails, or `/Applications` is not writable by this user, the
+verified DMG lands in `~/Downloads` and opens on the drag-to-install window
+instead — the previous behaviour, so an update is never a dead end.
+
+Updating a source install still goes through the installer:
+
```bash
git pull --ff-only
./scripts/install-claude-meter-macos.sh
diff --git a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift
index 3531d7a..27e5900 100644
--- a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift
+++ b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift
@@ -1,6 +1,8 @@
import AppKit
import ApplicationServices
+import CryptoKit
import Foundation
+import Security
import WebKit
private let claudeBundleID = "com.anthropic.claudefordesktop"
@@ -866,6 +868,8 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes
bootout.arguments = ["bootout", "gui/\(getuid())/com.sergiochan.token-meter.claude-desktop"]
try? bootout.run()
bootout.waitUntilExit()
+ // Same reason as the updater: the bridge outlives a bare exit.
+ snapshotBridge.stop()
exit(0)
case "set-sharing":
let enabled = body["enabled"] as? Bool ?? false
@@ -884,48 +888,195 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes
let version = payload["version"] as? String,
version.range(of: #"^[0-9]+\.[0-9]+\.[0-9]+$"#, options: .regularExpression) != nil
else { return }
- self.downloadAndOpenUpdate(url: url, version: version)
+ let digest = payload["sha256"] as? String
+ self.downloadAndInstallUpdate(url: url, version: version, sha256: digest)
}
default:
break
}
}
- // Downloads the release DMG into ~/Downloads and opens it once Gatekeeper's
- // assessment passes, landing the user directly on the drag-to-install window.
- private func downloadAndOpenUpdate(url: URL, version: String) {
- let task = URLSession.shared.downloadTask(with: url) { temporary, _, error in
- guard error == nil, let temporary else { return }
- let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
- let destination = downloads.appendingPathComponent("TokenWidget-\(version).dmg")
- do {
- try? FileManager.default.removeItem(at: destination)
- try FileManager.default.moveItem(at: temporary, to: destination)
- } catch {
+ // Pushes install progress to the banner. States come from string literals
+ // in this file only, so they are safe to interpolate into the page.
+ private func postUpdateState(_ state: String) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self, self.pageReady else { return }
+ self.webView.evaluateJavaScript(
+ "window.__tokenMeter?.setUpdateState({state:'\(state)'})"
+ )
+ }
+ }
+
+ @discardableResult
+ private func runTool(_ path: String, _ arguments: [String]) -> Int32 {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: path)
+ process.arguments = arguments
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ do { try process.run() } catch { return -1 }
+ process.waitUntilExit()
+ return process.terminationStatus
+ }
+
+ // A replacement may only carry the signature this build already carries.
+ // Comparing against our own designated requirement pins the team and the
+ // bundle identifier without hard-coding either, and an ad hoc development
+ // build (whose requirement is its own hash) can never satisfy it.
+ private func signedLikeUs(_ bundle: URL) -> Bool {
+ var selfCode: SecCode?
+ guard SecCodeCopySelf(SecCSFlags(), &selfCode) == errSecSuccess,
+ let selfCode else { return false }
+ var selfStatic: SecStaticCode?
+ guard SecCodeCopyStaticCode(selfCode, SecCSFlags(), &selfStatic) == errSecSuccess,
+ let selfStatic else { return false }
+ var requirement: SecRequirement?
+ guard SecCodeCopyDesignatedRequirement(selfStatic, SecCSFlags(), &requirement) == errSecSuccess,
+ let requirement else { return false }
+ var candidate: SecStaticCode?
+ guard SecStaticCodeCreateWithPath(bundle as CFURL, SecCSFlags(), &candidate) == errSecSuccess,
+ let candidate else { return false }
+ return SecStaticCodeCheckValidity(candidate, SecCSFlags(), requirement) == errSecSuccess
+ }
+
+ private func bundleVersion(at bundle: URL) -> String? {
+ let plist = bundle.appendingPathComponent("Contents/Info.plist")
+ guard let data = try? Data(contentsOf: plist),
+ let info = try? PropertyListSerialization.propertyList(from: data, format: nil)
+ as? [String: Any] else { return nil }
+ return info["CFBundleShortVersionString"] as? String
+ }
+
+ // Last resort when the bundle cannot be replaced in place — an /Applications
+ // the user cannot write to, mostly. Restores the old behaviour: a verified
+ // disk image in ~/Downloads, opened on the drag-to-install window.
+ private func fallBackToManualInstall(image: URL, version: String) {
+ let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
+ let destination = downloads.appendingPathComponent("TokenWidget-\(version).dmg")
+ try? FileManager.default.removeItem(at: destination)
+ guard (try? FileManager.default.moveItem(at: image, to: destination)) != nil,
+ runTool("/usr/sbin/spctl", ["--assess", "--type", "install", destination.path]) == 0
+ else {
+ try? FileManager.default.removeItem(at: destination)
+ postUpdateState("failed")
+ return
+ }
+ postUpdateState("manual")
+ DispatchQueue.main.async { NSWorkspace.shared.open(destination) }
+ }
+
+ // Downloads the release, proves it is ours three ways — registry digest,
+ // Gatekeeper, our own code signature — swaps the bundle in place and exits.
+ // KeepAlive in the LaunchAgent relaunches us from the same path, so the new
+ // version comes back on its own without the user touching anything.
+ private func downloadAndInstallUpdate(url: URL, version: String, sha256: String?) {
+ postUpdateState("downloading")
+ let task = URLSession.shared.downloadTask(with: url) { [weak self] temporary, response, error in
+ guard let self else { return }
+ guard error == nil, let temporary,
+ (response as? HTTPURLResponse)?.statusCode == 200 else {
+ self.postUpdateState("failed")
return
}
- // Refuse to open anything that is not a notarized Developer ID disk
- // image; a compromised download source must not reach the user.
- let assess = Process()
- assess.executableURL = URL(fileURLWithPath: "/usr/sbin/spctl")
- assess.arguments = ["--assess", "--type", "install", destination.path]
- assess.standardOutput = FileHandle.nullDevice
- assess.standardError = FileHandle.nullDevice
+ let work = FileManager.default.temporaryDirectory
+ .appendingPathComponent("token-widget-update-\(UUID().uuidString)")
+ let image = work.appendingPathComponent("TokenWidget-\(version).dmg")
do {
- try assess.run()
- assess.waitUntilExit()
+ try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
+ try FileManager.default.moveItem(at: temporary, to: image)
} catch {
+ self.postUpdateState("failed")
return
}
- guard assess.terminationStatus == 0 else {
- try? FileManager.default.removeItem(at: destination)
- return
- }
- DispatchQueue.main.async { NSWorkspace.shared.open(destination) }
+ self.installVerifiedImage(image: image, work: work, version: version, sha256: sha256)
+ try? FileManager.default.removeItem(at: work)
}
task.resume()
}
+ private func installVerifiedImage(image: URL, work: URL, version: String, sha256: String?) {
+ postUpdateState("verifying")
+
+ // 1. The digest the registry published alongside the release.
+ guard let expected = sha256?.lowercased(), expected.count == 64,
+ let bytes = try? Data(contentsOf: image, options: .mappedIfSafe) else {
+ fallBackToManualInstall(image: image, version: version)
+ return
+ }
+ let actual = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined()
+ guard actual == expected else {
+ postUpdateState("failed")
+ return
+ }
+
+ // 2. Gatekeeper: notarized Developer ID disk image, or nothing.
+ guard runTool("/usr/sbin/spctl", ["--assess", "--type", "install", image.path]) == 0 else {
+ postUpdateState("failed")
+ return
+ }
+
+ // An explicit mount point keeps us off /Volumes, where an already
+ // mounted "Token Widget" would push this one to a suffixed name.
+ let mount = work.appendingPathComponent("mnt")
+ guard (try? FileManager.default.createDirectory(at: mount, withIntermediateDirectories: true)) != nil,
+ runTool("/usr/bin/hdiutil", [
+ "attach", image.path, "-mountpoint", mount.path,
+ "-nobrowse", "-readonly", "-noautoopen", "-quiet",
+ ]) == 0 else {
+ fallBackToManualInstall(image: image, version: version)
+ return
+ }
+ defer { runTool("/usr/bin/hdiutil", ["detach", mount.path, "-force", "-quiet"]) }
+
+ // 3. Our own signature, and the version the registry promised.
+ let replacement = mount.appendingPathComponent("Token Widget.app")
+ guard FileManager.default.fileExists(atPath: replacement.path),
+ signedLikeUs(replacement),
+ bundleVersion(at: replacement) == version else {
+ postUpdateState("failed")
+ return
+ }
+
+ let installed = Bundle.main.bundleURL
+ guard installed.pathExtension == "app" else {
+ fallBackToManualInstall(image: image, version: version)
+ return
+ }
+
+ postUpdateState("installing")
+ // ditto keeps the signature intact; cp would strip extended attributes.
+ // Staging next to the installed bundle keeps the swap on one volume.
+ let staging = installed.deletingLastPathComponent()
+ .appendingPathComponent(".TokenWidget-update-\(version).app")
+ try? FileManager.default.removeItem(at: staging)
+ guard runTool("/usr/bin/ditto", [replacement.path, staging.path]) == 0 else {
+ try? FileManager.default.removeItem(at: staging)
+ fallBackToManualInstall(image: image, version: version)
+ return
+ }
+ do {
+ var swapped: NSURL?
+ try FileManager.default.replaceItem(
+ at: installed, withItemAt: staging,
+ backupItemName: nil, options: [], resultingItemURL: &swapped,
+ )
+ } catch {
+ try? FileManager.default.removeItem(at: staging)
+ fallBackToManualInstall(image: image, version: version)
+ return
+ }
+
+ // Replacing our own bundle is safe while running — this process keeps
+ // the old image mapped until it exits, and launchd starts the new one.
+ postUpdateState("restarting")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ // Take the Node bridge down first: it is a child running from the
+ // bundle we just replaced, and exiting without this strands it.
+ self?.snapshotBridge.stop()
+ exit(0)
+ }
+ }
+
private func beginDrag() {
dragging = true
dragStartMouse = NSEvent.mouseLocation
diff --git a/integrations/claude-desktop/scripts/build-app.sh b/integrations/claude-desktop/scripts/build-app.sh
index bba544d..035fcc0 100755
--- a/integrations/claude-desktop/scripts/build-app.sh
+++ b/integrations/claude-desktop/scripts/build-app.sh
@@ -71,6 +71,7 @@ trap cleanup EXIT
-whole-module-optimization \
-framework AppKit \
-framework ApplicationServices \
+ -framework Security \
-framework WebKit \
-o "$STAGING/Contents/MacOS/TokenMeterClaudeOverlay"
/usr/bin/install -m 600 \
diff --git a/integrations/claude-desktop/src/overlay-bridge.mjs b/integrations/claude-desktop/src/overlay-bridge.mjs
index 4cc3876..e9ce268 100644
--- a/integrations/claude-desktop/src/overlay-bridge.mjs
+++ b/integrations/claude-desktop/src/overlay-bridge.mjs
@@ -211,7 +211,14 @@ for await (const line of input) {
if (request?.command === "update-info") {
await writeLine(
updateInfo
- ? { requestId, ok: true, version: updateInfo.version, url: updateInfo.url }
+ ? {
+ requestId,
+ ok: true,
+ version: updateInfo.version,
+ url: updateInfo.url,
+ // The installer refuses to swap the bundle without this digest.
+ sha256: updateInfo.sha256 ?? null,
+ }
: { requestId, ok: false },
);
continue;
diff --git a/package.json b/package.json
index 6c54b33..15a226d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "token-meter",
- "version": "0.2.2",
+ "version": "0.2.5",
"description": "An open-source, session-aware live token meter for Codex Desktop and Claude Code in Claude Desktop.",
"type": "module",
"private": true,
diff --git a/runtime/token-meter-ui.css b/runtime/token-meter-ui.css
index a21584a..6ad3a7d 100644
--- a/runtime/token-meter-ui.css
+++ b/runtime/token-meter-ui.css
@@ -195,13 +195,6 @@
display: none;
}
-.settings-identity small {
- display: block;
- color: rgb(32 33 31 / 0.55);
- font-size: 9px;
- letter-spacing: 0.06em;
-}
-
.settings-identity-link {
padding: 0;
color: #20211f;
@@ -1117,7 +1110,6 @@
background: rgb(107 165 231 / 0.12);
}
- .settings-identity small,
.settings-tip {
color: rgb(236 238 233 / 0.55);
}
diff --git a/runtime/token-meter-ui.js b/runtime/token-meter-ui.js
index 7e49dd6..15e7720 100644
--- a/runtime/token-meter-ui.js
+++ b/runtime/token-meter-ui.js
@@ -117,14 +117,13 @@
- IDENTITY
Anonymous meter
-
✓ Notarized by Apple · Drag to Applications · Claude Code and Codex Desktop on macOS
+
✓ Notarized by Apple · macOS · Tracks Claude Code, Codex, and Cline
Terminal
@@ -236,58 +279,139 @@
From source
-
-
-
-
- Live telemetry
-
A needle, not a percentage
-
Tokens per minute against your own baseline. Red means this turn costs several times your normal.
-
-
- Anomaly alarm
-
Catches runaway context
-
Flags context pollution, retry loops, and agent spirals before they eat another turn.
-
-
- Identity
-
Your @handle and dashboard
-
Claim a handle. Click it for your calendar, streaks, cache hit rate, and platform breakdown.
-
-
- Privacy
-
Private by default
-
Computed on your laptop. Sharing is opt-in and limited to signed totals — never content.
-
+
+
+
+
+
+
+
A needle, not a percentage
+
The meter rides along beside your agent, reading tokens per minute against your own baseline. Session total, 24-hour total, streak, lifetime — without opening anything.
+
+
+
+
+
+
+
It knows what your normal looks like
+
The alarm has no fixed number to trip. It learns the median rate of your own turns and speaks up when one runs several times past it — the signature of context pollution or a retry loop. Until it has seen enough of your work to be sure, it stays quiet.
+
+
+
+
+
+
+
Your @handle and dashboard
+
Claim a handle that is yours across the community. Behind it sits your full record: the token activity calendar, streaks, cache hit rate, and the split across Claude Code, Codex, and Cline.
+
+
+
+
+
+
+
+
Nothing leaves without you saying so
+
Every number is computed on your own machine and stays there. Sharing is a deliberate switch, and even then only signed totals travel — never a word of what you wrote.