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 @@
+
@@ -595,6 +619,16 @@ } }); + // The copy button is icon-only, so confirmation swaps the glyph rather than + // the label — replacing textContent would drop the SVG entirely. + function markCopied(button, copied) { + button.classList.toggle("copied", copied); + button.dataset.tip = copied ? "Copied" : "Copy link"; + button.setAttribute("aria-label", copied ? "Link copied" : "Copy link"); + button.querySelector(".i-link").hidden = copied; + button.querySelector(".i-check").hidden = !copied; + } + // Share targets for a public profile. The install link rides along in the // post text so every share doubles as an invitation. function buildShareLinks(handle, lifetimeLabel) { @@ -729,18 +763,20 @@ xEl.hidden = inEl.hidden = copyEl.hidden = false; publishEl.hidden = true; } else { + // Nothing public to point at yet; the PNG card is still downloadable, + // and publishing through the consent wizard is what mints a public link. xEl.hidden = inEl.hidden = copyEl.hidden = true; - // Publishing through the consent wizard is what mints a public link. publishEl.href = TOKEN ? `/share?token=${TOKEN}` : "#"; publishEl.hidden = !TOKEN; } } document.getElementById("share-copy").addEventListener("click", async (event) => { + const button = event.currentTarget; try { - await navigator.clipboard.writeText(event.currentTarget.dataset.url ?? ""); - event.target.textContent = "Copied ✓"; - setTimeout(() => { document.getElementById("share-copy").textContent = "Copy link"; }, 1500); + await navigator.clipboard.writeText(button.dataset.url ?? ""); + markCopied(button, true); + setTimeout(() => markCopied(button, false), 1500); } catch { /* clipboard unavailable */ } }); diff --git a/web/index.html b/web/index.html index 0c3e713..0a7ec93 100644 --- a/web/index.html +++ b/web/index.html @@ -35,6 +35,8 @@ --sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } + html { scroll-behavior: smooth; } + @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } body { margin: 0; background: var(--canvas); @@ -43,7 +45,8 @@ letter-spacing: 0.01em; } - nav { + /* Scoped to the page header: the feature tour uses a