Part of #6.
Every fix that ships after someone downloads the DMG reaches only the people who happen to revisit the site and download again. That includes a fix to app/Uplink/messages-db.ts#L16, the component that opens ~/Library/Messages/chat.db with Full Disk Access and reads the whole history. The data needed to notice a new release already gets written on every release, so what is missing is a comparison, somewhere to fetch the manifest from, and a line in the popover.
What is there today
pkg.versionis display only. It is passed into the agent atapp/Desktop/launcher.ts#L46, returned in the status payload atapp/Uplink/desktop-agent.ts#L265, and rendered as the idle subline atapp/Uplink/menubar.html#L175. Nothing compares it to anything.- The app makes no outbound request of its own. The only
fetchin the agent is Bun.serve's inbound handler atapp/Uplink/desktop-agent.ts#L280, and the onlyfetchin the popover is the loopback helper atapp/Uplink/menubar.html#L139, called by the status poll atapp/Uplink/menubar.html#L265on the two second interval atapp/Uplink/menubar.html#L333. resources/data/release.jsonis already a machine readable manifest: version atresources/data/release.json#L2, size at#L3, minimum macOS at#L4, and the DMG url at#L5.uplink:releaserewrites it on every release atapp/Commands/Uplink.ts#L223.- It is not reachable over HTTP. The homepage imports it at build time at
resources/views/index.stx#L47and renders it as the download subline atresources/views/index.stx#L50. There is no copy underpublic/, so a check has to fetch either the GitHub latest release JSON or a published copy of this file. - The popover has no links in it. Opening a URL is something the agent does, the way
/api/open/full-disk-accessshells out toopenatapp/Uplink/desktop-agent.ts#L300. - The agent already owns repeating timers and tears them down:
app/Uplink/desktop-agent.ts#L214and#L219, cleared instopatapp/Uplink/desktop-agent.ts#L353. - The homepage footer at
resources/views/index.stx#L374links to Stacks and to GitHub and says nothing about what the app sends anywhere.
Proposal
- Add
app/Uplink/update.tswith one function that fetches a release manifest and returns{ version, url } | nullwhen the published version is newer than the running one. Source is the GitHub latest release JSON forstacksjs/uplink, or a published copy ofresources/data/release.json. - Call it from
startDesktopAgentinapp/Uplink/desktop-agent.tsonce at launch and on a 24 hoursetIntervalalongside the two at#L214and#L219, cache the result in a local, and clear the timer instopat#L353. - Carry
update: { version, url } | nullin the status object next toversionatapp/Uplink/desktop-agent.ts#L265. - Render it in
app/Uplink/menubar.html: whenstatus.updateis set, show "0.1.2 available" in the footer next to the existing Pause and Quit buttons, as a button rather than a link. - Add
/api/open/downloadnext to the/api/open/*cases atapp/Uplink/desktop-agent.ts#L300, which spawnsopenon the url held by the agent from the last successful fetch. - If the site is to be the manifest source, copy
resources/data/release.jsonintopublic/so it is served, sinceuplink:releasealready writes it atapp/Commands/Uplink.ts#L223.
Requirements
- This is the app's first outbound request, so the privacy copy has to name it. Until now the only thing that leaves the Mac is what the Claude CLI sends. State the endpoint, that it is a GET carrying no identifier and no version, and that it can be ignored, wherever the "what leaves your Mac" copy lands near
resources/views/index.stx#L374. - Fail silently when offline. A failed or slow fetch keeps the last known result, writes at most one log line, and never becomes a check or an error in the popover. The four states at
app/Uplink/desktop-agent.ts#L262do not gain a fifth, andupdatestaysnull. - Compare versions, not strings. 0.1.10 is newer than 0.1.9, and a running version ahead of the manifest is not an update.
- The url comes from the agent, not from the request body. The origin guard at
app/Uplink/desktop-agent.ts#L284refuses cross site requests, and/api/open/downloadshould still ignore any url a caller sends so the loopback port cannot be turned into an arbitraryopen. - An in-place updater is out of scope. This issue is a notification and a link to the DMG. An appcast with signatures, or a Homebrew cask as the update channel, is separate work.
- Say that the Full Disk Access grant survives the update. macOS keys the grant to a bundle id plus its signature, which is why the source build had to take a different bundle id, documented at
app/Uplink/service.ts#L14. Replacing the app with another build signed by the same Developer ID keeps the grant, so the copy must not tell people to grant it again.
Acceptance
- A running app whose version is behind the published manifest shows "update available" within a day without being relaunched, and immediately on the next launch.
/api/statuscarriesupdate: nullwhen the running version matches or exceeds the manifest, and{ version, url }when it does not.- Clicking the footer button opens the manifest's DMG url in the browser. A POST to
/api/open/downloadcarrying a different url in its body opens nothing. - With networking off, the popover renders normally, the three setup checks are unchanged, and no error text appears.
- The homepage copy names the update request among the things the app sends.