TreeMap
A free, open-source disk-space visualizer. Squarified treemap, duplicate hunting, disk-full forecasting, and cleanup that only ever moves files to the Trash.
A macOS menu-bar app that finds updates for the apps you already have, and installs them the way each app expects to be updated.
ProductivitySwift · PythonGitHub @jizhi0v0OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Keeps macOS apps up to date through each app's own release channel — Sparkle, App Store, Homebrew, GitHub releases, or a per-app recipe against the vendor's own endpoint — with signature-verified installs and rollback. Pure Swift.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
A macOS menu-bar app that finds updates for the apps you already have, and
installs them the way each app expects to be updated.
duoupdater.app — the download, the release notes,
and what it checks before replacing an app.Read the full README ↗A macOS menu-bar app that finds updates for the apps you already have, and installs them the way each app expects to be updated.
duoupdater.app — the download, the release notes, and what it checks before replacing an app. Or install it with Homebrew:
brew install --cask jizhi0v0/tap/duoupdater
DuoUpdater updates itself after that, so brew upgrade leaves it alone. It needs
macOS 15 or later.
Most updaters pick one mechanism and push every app through it. This one reads each app's own release channel — its Sparkle appcast, its App Store listing, its Homebrew cask, its vendor's release feed — and uses that. When an app ships its own updater, it hands over instead of fighting it; when it can't do something safely, it says so rather than guessing. Pure Swift, no telemetry, and the only server is the one that serves its own update feed (Privacy).
Each row says what you are going from and to, and the button says what will actually happen: Update installs, Relaunch means it is already updated on disk and only the running copy is stale. A green dot marks an app that is running, so you know before you click whether something is about to be quit and reopened, and a channel tag appears where an app is not on its default track. When a release keeps the same marketing version, the row shows the build number beside it.
The Homebrew row at the bottom is everything Homebrew installs that isn't an
app: command-line formulae, and casks that install no .app at all — a CLI, a
font, a driver. None of those need a per-app decision, and they have no bundle to
scan, so without that row they would be invisible entirely. A cask that does
install an app gets an ordinary row like anything else, and is never touched by
the upgrade here, so nothing is counted twice. Other tools is the same idea
for command-line tools installed some other way — Claude Code, Codex, uv, Bun,
npm install -g packages — each found where its own installer put it and
updated through that tool's own update command.
Opening the window gives you everything it scanned, and the release notes for
whatever you select. Where a vendor publishes notes in a form worth parsing,
they are pulled apart and rendered as native text — version, date, one bullet per
change — instead of an embedded web page. Sparkle appcasts and GitHub releases
carry their notes inline; for the rest there is a per-app recipe, and vendors
whose page resists parsing fall back to the page itself in a WKWebView.
The CLI tab does the same for command-line tools: where each one is installed, what installed it, who signed it, which channel it follows, and its release notes.
Most of the settings are about how much autonomy you want to give it — whether to restart apps for you, whether to keep a rollback backup, and how to route the two awkward cases: Mac App Store apps, and apps that ship their own updater.
Xcode betas and release candidates aren't on the App Store, and Apple only hands
them out to a signed-in developer account. Sign in once under Settings → Xcode
and a new beta becomes a one-click update like any other row. The same page lists
every build Apple and xcodereleases.com know of: Install puts the one you pick
in Applications as its own copy — Xcode-27.2-beta-2.app, say — beside any Xcode
already there, and nothing installed is replaced; Download Only saves the
.xip to Downloads instead.
Apps are scanned from /Applications, /Applications/Utilities, and
~/Applications, then checked against multiple update sources, in priority order:
mac-software listings and
wrapped iPhone/iPad apps; an iOS-on-Mac app with a separate Mac release line is
read from its Mac product page instead, and gets no version if that page has
none, to avoid phantom updates)..app filename, falling back to bundle id
(so pkg-only casks like AweSun are still found). Only for apps Homebrew
installed and keeps up to date (not auto_updates casks), so that updating
one does not leave brew upgrade to install the same release again.SUFeedURL appcast.The first source that recognises an app answers for it; a nil means "does not
apply, try the next one". Two kinds of app never reach that stack at all:
JetBrains Toolbox-managed apps and TestFlight builds are answered in
UpdateChecker before the first source is consulted, because Toolbox and
TestFlight each own the update and there is no second opinion worth having.
How much DuoUpdater does about TestFlight betas is your choice — Settings → General → TestFlight betas — because the answer lives in a database only TestFlight itself ever writes. Off reads nothing and says so on those rows. When I refresh reads that database on every check and asks TestFlight for a fresh answer when you press Refresh. Keep it fresh also lets DuoUpdater ask on its own — at most once an hour, and whenever it can see a beta has moved on without it — which starts TestFlight in the background for a few seconds (about 0.7 MB) each time. Both "on" settings need Full Disk Access; see below. A Mac that already had Full Disk Access when it first ran this version starts at When I refresh, everyone else at Off.
It respects each app's own update channel:
| Channel | Action |
|---|---|
| Sparkle | Native install — EdDSA when the app supplies a key, then code-signature + Team ID + bundle ID + runnable-architecture checks → swap. The relaunch is a separate step (autoRestartAfterUpdate, on by default), not part of the installer |
| Mac App Store | Full download through mas and the privileged helper. Where that is unavailable — helper not approved, or the app locked to another region — it falls back to a deep link or the App Store's own updates page; region-locked apps are flagged |
| Self-updating (Electron/Squirrel) | Open the app and let it update itself |
| Homebrew app cask | brew install --cask --force |
Homebrew pkg cask | Download the official package and open the system installer |
.pkg / .mpkg, including those inside disk images) do not pass DuoUpdater's
runnable-architecture or native-to-Intel downgrade gates. Package signature,
Team ID and destination checks do not inspect payload Mach-O executables, so
an Intel-only package can pass those checks. DuoUpdater does not verify or
automatically roll back architecture changes after the system installer runs.
duo verify --pkgarch sweeps every pkg spec's declared hostArchitectures for
drift, but that is not the same thing as an install-time gate: measured across
the registry, the declaration has never once said "Intel-only", and the
packages that actually vary by architecture are exactly the ones that declare
nothing — so a gate built on this field would not catch the case it exists for.
The OS floor is checked, though not guaranteed: when DuoUpdater can read the
payload app's stated minimum macOS, a package that needs a newer macOS than
this Mac runs is refused before the system installer opens — the same refusal
the other routes make against a downloaded bundle. A package whose payload it
cannot read is still handed over, as it always was.terminate() — the app runs its own save
prompts and can refuse. One that refuses is left running and keeps a
Restart button, so unsaved work is never at risk from a forced exit. Note
the restart happens after the new version is on disk, so a refusal leaves an
updated bundle beside a process still running the old code until you relaunch
it yourself.duo install both re-read the
bundle and re-query its source immediately before backup/replace, not just
when the list was last drawn.Every version it sees gets recorded, so over time you get a log of when the software you use actually ships — and, in aggregate, when its developers tend to release.
The interesting part is what it refuses to claim. A release time is only exact
when the vendor's own feed timestamps it — Sparkle, GitHub, Alcove and
electron-builder manifests do. Every
other source tells us a version exists but not when it appeared, so all we
honestly know is that it happened between the last check that saw the old version
and the first that saw the new one. Those are shown as a window with a ≈, and a
wide window is visibly low confidence rather than a precise-looking lie.
The heatmap then uses only the exact tier. That is why the window above says 525 releases while the pattern reads from 226: the other 299 are real releases whose timing is an inference, and an inference has no business shaping a claim about when somebody ships.
MacUpdater was discontinued on 1 January 2026 — for financial rather than technical reasons, per its author — which is why this exists. The obvious open-source option is Latest, and it is a good app. The two make opposite bets, so it is worth knowing which one you want.
Latest delegates the install. For Sparkle apps it runs SPUUpdater inside
its own process with the target app as the host bundle, acting as the user
driver and auto-answering the prompts; for App Store apps it drives Apple's
private CommerceKit. It never replaces a bundle itself. That is an elegant bet:
almost no trusted code of its own, and each install is performed by the machinery
that app already ships with. The cost is reach — Sparkle and the App Store are
the two paths it can take, its Homebrew support is detection-only (it opens the
app and lets that app update itself, and says so in the UI), and when Apple
changes a private framework the App Store path has to be rebuilt.
This one owns the install. It downloads, verifies and swaps the bundle itself, which is what lets it also cover vendors that ship neither a Sparkle feed nor an App Store listing — per-app recipes against a vendor's own endpoint, GitHub releases, Homebrew casks, JetBrains Toolbox. Owning the install is what makes the checks in Install safety possible: EdDSA where the app publishes a key, then a Developer ID signature, Team ID, bundle id and runnable architecture that must match the app being replaced, plus a backup you can roll back to. Latest inherits whatever Sparkle and CommerceKit check and adds none of its own, and keeps no backup.
The trade is maintenance. Per-app recipes break whenever a vendor rewrites a download page, which is a real running cost — the same cost that a 100,000-app database turned out not to be able to carry commercially. This project answers that with a nightly sweep that re-checks every recipe against its live endpoint and files an issue for each one that stopped working, and by covering a few dozen apps properly rather than every app approximately.
Pick Latest if your apps are Sparkle and App Store apps and you would rather trust less code. Pick this if you have apps neither of those reaches, or you want the update chain to verify who signed the thing replacing your app.
There is no telemetry and no analytics SDK. Every network request for the apps
being checked goes straight to their vendor (or to api.github.com /
formulae.brew.sh, and for formula icons the sites named just below), and
carries nothing about you beyond what that request needs: the app's own
version, so a vendor feed can answer for the right channel.
DuoUpdater's own update check. The one server of ours is the one that
serves DuoUpdater's own update feed, updates.duoupdater.app/appcast.xml, a
Cloudflare Worker whose entire source is
workers/appcast. It counts roughly how many
installs check for updates each day, so we know whether anyone is using
DuoUpdater, and is built so that it cannot learn who:
DuoUpdater/<version> Sparkle/<version>.Cloudflare, like GitHub before it, sees the request's IP while delivering it, and keeps its own request log for the domain, which the account owner can browse in Cloudflare's dashboard; it cannot be switched off on our plan. We do not export or query it, and the numbers above do not come from it.
Homebrew formula icons. For a formula DuoUpdater bundles no logo for, the
Homebrew group in the CLI tab shows the formula's own icon, fetched when its row
first appears. It is
asked for only where the project itself publishes one: the formula's homepage
(the page's icon links, then /favicon.ico, and only those on the homepage's
own host or its subdomains: a link to a CDN or another host is skipped, and a
redirect elsewhere is not followed), or, for a project on GitHub,
api.github.com/users/<owner> and, when the owner is an organisation, its
avatar on avatars.githubusercontent.com. A person's avatar is never fetched,
and code-hosting sites (SourceForge, GitLab and the like) are not asked. Each of
those sites, and GitHub, can therefore tell that a formula of theirs is installed
on this Mac. Icons are cached on disk; a formula with none is not asked again
for a week.
Four things are worth calling out explicitly, because they involve reading outside our own container:
activationKey is read from its
preferences and used to request the personalised appcast that its own updater
uses. Without it, CleanShot's feed reports the trial channel. The key is sent
only to legit.maketheweb.io, is never logged, and is excluded from the HTTP
disk cache.IsReceiveBetaBuild preference is read so we detect on the
same channel the app itself is set to. If a licence file (.licensemac) is
there — only its existence is checked — a temporary copy of TablePlus's URL
cache (Cache.db) is read for its cached licenses/devices response, and only
the "updates available until" date is taken from it, to leave out builds the
licence no longer covers. The response also holds a device id, which is not
used or kept. Nothing read there leaves the Mac.GITHUB_TOKEN/GH_TOKEN or, failing that, from gh auth token.
It is sent only to api.github.com, and is stripped from any redirect that
leaves that host.Credentials you enter yourself (a GitHub token, an Alcove licence) are stored in
the login Keychain as AfterFirstUnlockThisDeviceOnly — not synced, not in a
plist. Changelog panes render vendor pages in a WKWebView with a non-persistent
data store, so vendor cookies do not survive a relaunch.
Failed updates are recorded on this Mac — the app, the two versions, the
update source and the error shown — so Settings → Diagnostics → Recent
failures can list them after the row's error is gone. They are kept for up to 30
days and sent nowhere. Copy Report puts one on the clipboard, with your home
folder shortened to ~, for you to paste into an issue or an email if you
choose to.
macOS asks for some of these the first time they are needed, and for one of them it never asks at all. Nothing here is required to see your apps — the list, the version checks and the changelogs all work with everything denied. What each permission buys is stated below, including what you lose without it. The Full Disk Access and Automation lines were measured on macOS 27 with an app that had been granted nothing.
Full Disk Access — only for TestFlight betas, CotEditor and Open Screen Sharing. macOS never asks for it: you add DuoUpdater yourself in System Settings → Privacy & Security → Full Disk Access, and because the app is signed with a stable identity the grant survives every update. It covers three reads, and nothing else DuoUpdater looks at needs it:
Other apps' settings — the release channel of Fork, TablePlus, OrbStack, IINA, Tailscale, CleanShot and the rest — your App Store storefront, and files under Application Support are all read without it. Without Full Disk Access DuoUpdater does not attempt the three reads at all: every attempt would be refused, and on macOS 27 a refused TestFlight read posts a "Data Access Blocked" notice. If you have a TestFlight beta, CotEditor or Open Screen Sharing, opening the menu explains this once, and at most once more if another such app appears; the welcome window and Settings → Diagnostics show whether it is granted, and tapping the question mark on a TestFlight row says why it is there — offering to grant it when that is the reason. A stable CotEditor or Open Screen Sharing carries a small lock beside its name that does the same. With TestFlight detection Off none of that mentions TestFlight: nothing was attempted, so the permission is not what is missing, and the beta rows carry a minus rather than a question mark — asked-and-not-told and never-asked are different things to say.
App Management — required to install anything. Replacing an app in
/Applications that some other installer put there is gated on this, and macOS
provides no API to ask for it in advance, so the first install triggers the
system prompt. Deny it and detection still works; installs fail, and DuoUpdater
opens the setting for you.
Notifications — asked for at launch, only for telling you updates were found and for the Dock badge count. Entirely optional. Note the badge needs the Badges switch specifically, not just alerts.
Background helper — App Store updates run through a background item that macOS asks you to approve once, under Login Items & Extensions. Without it, App Store updates fail and DuoUpdater says where to switch it on.
Accessibility — not needed by default. It is used if you switch App Store installs to the GUI route in Settings, and to close Installer's window after a package update; without it that window stays open for you to close.
Automation — DuoUpdater's own code never asks for it: quitting and
relaunching an app after updating it does not. Apps updated through Homebrew are the
exception: when the cask names something to quit (uninstall quit:) and it is
still running, Homebrew quits it with osascript. macOS may then ask whether
DuoUpdater can control that app, or refuse without asking (DuoUpdater doesn't
carry the Apple Events entitlement); if refused, Homebrew gives up after 10 s,
warns that the app did not quit, and goes on. Neither outcome was measured.
duo, the command lineThe same engine has a CLI. It links the real DuoUpdaterCore, so it uses the
same sources in the same order, the same install policy and the same ignore and
skip rules as the menu bar — a disagreement between the two is a bug, not a
difference of opinion.
make cli # → ~/.local/libexec/duo, symlinked at ~/.local/bin/duo
duo list # what's installed, without touching the network
duo check --json # what has an update, one JSON object per line
duo install Cursor # apply one, or --all
duo doctor # whether this machine can actually install anything
duo backups # list rollback points, or put one back
duo check and duo list also take --source sparkle,github,… and
--include-hidden; duo ignore / duo skip write the same preferences the app
reads, so hiding something in one hides it in the other.
The command-line tools the app's CLI tab lists are there too, after the apps:
duo check shows each one's path and latest version, and duo install uv (or
its path, or an npm package's name) runs the same update command the row's
Update button runs, with the tool's own output on stderr. An update the app
holds back, or one that needs an administrator password, is not run: duo
prints the app's reason and the command to run yourself.
TestFlight follows the app's rule too: without Full Disk Access duo does not
attempt TestFlight's reads, and duo check says the betas were not checked
instead of calling everything up to date. The grant that counts is the one on the
process macOS holds responsible — from a terminal, usually the terminal app; for
a launchd job that runs duo directly, duo itself, and for one that runs it
through a script, the script's interpreter.
Two things it deliberately refuses rather than half-doing:
SMAppService registration requires an app bundle — or the Accessibility API
driving App Store.app. A CLI has neither, so it says so instead of failing
part-way.duo
exits and names the holder rather than swapping a bundle underneath it.duo verify, duo triage and duo reconcile are the maintenance side: they
sweep every hand-written recipe — and every Sparkle feed address we hand out for
an app whose own bundle does not state one — against its live endpoint, ask a
model why a broken one broke, and turn the result into issues. For a changelog recipe whose
entries vanished or collapsed, duo autofix goes one step further: run by
.github/workflows/recipe-autofix.yml when the issue opens, it prepares a draft pull request
when the model's proposed values pass the production extractor, the goldens, make test and
duo verify. A second model then reads the page and the entries those values produce; when
it finds nothing wrong, the workflow merges the change without a person, but only while the
repository variable RECIPE_AUTOFIX_AUTOMERGE is 1. That is what the nightly
check runs; they are not needed for ordinary use. duo verify-install is the half the
sweep cannot see: it downloads every installer those recipes resolve and checks it the
way an install would — published digests, unpacking, code signature, and the bundle id
and Team ID the install gates compare — without installing anything, and holds each
one against what that installer was when first seen (verify/identities.json). It runs on a
hosted runner (.github/workflows/install-verify.yml), where that traffic is free.
Sunset notices need a vendor's announcement, and apps nobody has onboarded get no
audit to find one. .github/workflows/sunset-sweep.yml runs scripts/sunset_sweep.py
daily: it reads Homebrew's discontinued-style cask marks and newly archived GitHub
repositories across every cask, has Haiku search the week's news, and files a
sunset-lead issue for each new match. Haiku first reads each lead's vendor page; a
lead it finds no end date for is labelled sunset-lead-undated too. A lead is checked
on the vendor's own page before any sunsets entry is written.
DuoUpdaterCore/ — Swift Package with the detection engine and install
pipeline (no UI). Buildable and testable on its own.App/ — the SwiftUI MenuBarExtra app, generated with
XcodeGen from App/project.yml.CLI/ — the duo executable and DuoKit, its command implementations.# Core package
cd DuoUpdaterCore && swift build && swift test # some tests hit the network
# App (open in Xcode to iterate)
cd App && xcodegen generate && open DuoUpdater.xcodeproj
Runs on macOS 15+. That is the deployment target for the app and all
packages. Every Apple Silicon Mac can run a newer macOS, so the floor asks a Mac
still on 14 to upgrade rather than leaving any hardware behind. Newer-only
surfaces — Liquid Glass, and the App Store install route's system changes — sit
behind #available checks, so 15 builds and runs; it is just not where this
gets exercised day to day.
Apple Silicon only (ARCHS: arm64 in App/project.yml). This is a product
decision, not an oversight. The vendor recipe registry pins arm64 throughout —
arm64 feed URLs in VendorProbeRecipe, -aarch64/-arm64 asset patterns in
GitHubReleasesSource — so a universal build would run on an Intel Mac and then
hand it arm64-only builds of every app it updates: the install reports success
and leaves behind an app that will not launch. Serving Intel properly means a
second set of URLs for every recipe, each verified against the real endpoint, not
an architecture switch. Until that exists, shipping arm64-only keeps the machine
we can't serve and the machine that can't run us the same machine.
Building needs Swift 6.4 (Xcode 27): the install path awaits its cleanup in
defer blocks (SE-0493), which older compilers reject. That is a compiler
requirement only — it does not raise the deployment target. Swift 6 language mode
throughout (swift-tools-version: 6.0, SWIFT_VERSION: 6.0).
xcodegen is needed only for the app — App/DuoUpdater.xcodeproj is
generated from App/project.yml and not checked in. The scripts that sign
(install.sh, build-cli.sh, notarize.sh, publish-release.sh) do not use
the one on your PATH: they fetch a pinned, SHA-256-checked XcodeGen into
~/Library/Caches/duo-updater through scripts/pinned-xcodegen.sh (network the
first time). DuoUpdaterCore/ and CLI/
are plain Swift packages: swift build and swift test need nothing extra
beyond network access the first time, to fetch swift-subprocess (whose manifest
needs Swift 6.2 or later — the Swift 6.4 requirement above already implies it).
The build signs with the author's team by default. To build a fork, set your own
team once — the build scripts and App/project.yml both read it:
export DUO_TEAM_ID=YOURTEAMID
make install
That is the only change required. The privileged helper and the app pin each
other with code-signing requirements, but each derives the team from its own
signature at runtime (App/Shared/OwnTeamIdentifier.swift), so the pin follows
whatever identity you built with — no source edit, and no way to end up with an
app and helper that disagree.
The requirement stays "same team as me", never "no team": a build with no resolvable team identifier (unsigned, or ad-hoc) refuses to connect at all rather than talking to an unpinned root daemon. If you are hacking on this with an ad-hoc signature, expect Mac App Store installs to be unavailable — everything else works.
make install # build + sign (Developer ID) + deploy to /Applications
make install builds a Release with a stable Developer ID signature and
deploys the single canonical copy to /Applications/DuoUpdater.app. This matters
for permissions: macOS keys TCC grants (Full Disk Access — the gate behind the
"access data from other apps" prompt — plus Accessibility, App Management) to the
app's code identity. An ad-hoc signature changes its CDHash every rebuild and
invalidates the grant; the Developer ID identity is stable, so a grant given once
survives all future rebuilds. The script refuses to deploy an ad-hoc binary. After
the first install, add the app to Full Disk Access once (System Settings →
Privacy & Security) — the script prints the reminder.
Notarized builds are published as GitHub Releases on this repository, and
appcast.xml at the repository root is the Sparkle feed the app updates itself
from — it starts empty and each release appends to it. Set RELEASE_REPO to
publish somewhere else.
The script never commits into your working tree: it makes a shallow throwaway clone to update the appcast, so a release does not depend on which branch you have checked out or what is uncommitted beside it.
# One-time local setup
xcrun notarytool store-credentials "duoupdater-notary" \
--apple-id "YOUR_APPLE_ID" \
--team-id "YOUR_TEAM_ID" \
--password "YOUR_APP_SPECIFIC_PASSWORD"
# Build + notarize + publish a GitHub Release to the public repo
NOTARYTOOL_PROFILE=duoupdater-notary make release
Useful overrides:
RELEASE_REPO=owner/repo to publish the release and appcast elsewhereTAG=v0.1.0 / TITLE="DuoUpdater 0.1.0" to override the generated release nameRELEASE_NOTES_FILE=/path/to/notes.md to publish custom notes| Component | Where | Licence |
|---|---|---|
| Sparkle 2.10.1 | SPM dependency, used to install other apps' Sparkle updates and to update this app | MIT |
| swift-subprocess 1.0.2 | SPM dependency of DuoUpdaterCore, runs every child process (ChildProcess) | Apache 2.0 |
| swift-system 1.8.1 | SPM dependency of swift-subprocess, fetched and pinned in Package.resolved but compiled only off macOS (Apple platforms use the OS's System), so not in the shipped binaries | Apache 2.0 |
mas | App/Resources/mas, a prebuilt universal binary invoked for Mac App Store installs | MIT |
| PermissionFlow | App/Sources/Vendor/PermissionFlow/ (vendored source) | MIT — see the LICENSE and NOTICE.md in that directory |
App/Resources/mas is checked in as a binary because it has to be embedded and
re-signed with the app's own identity for the privileged helper to invoke it.
If you would rather not trust a committed binary, build mas from source and
replace the file — scripts/install.sh re-signs whatever is there.