Skip to content
Merged
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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added assets/dmg/background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/dmg/background@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions docs/install-claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
203 changes: 177 additions & 26 deletions integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import AppKit
import ApplicationServices
import CryptoKit
import Foundation
import Security
import WebKit

private let claudeBundleID = "com.anthropic.claudefordesktop"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions integrations/claude-desktop/scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
9 changes: 8 additions & 1 deletion integrations/claude-desktop/src/overlay-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
8 changes: 0 additions & 8 deletions runtime/token-meter-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1117,7 +1110,6 @@
background: rgb(107 165 231 / 0.12);
}

.settings-identity small,
.settings-tip {
color: rgb(236 238 233 / 0.55);
}
Expand Down
Loading
Loading