toki

Development

Requirements

Providers are optional — Toki shows what is installed and authenticated. Claude Code plus claude-swap covers multi-account Claude workflows; Codex, Pi, Sarvam Code, OpenCode, Copilot CLI, Gemini CLI, and Grok CLI are each picked up when present.

Building

swift build
swift run Toki
scripts/build-app.sh          # bundle to .build/Toki.app
scripts/install-app.sh        # bundle and install to ~/Applications

Before shipping a local change:

swift build
swift test
scripts/build-app.sh
plutil -p .build/Toki.app/Contents/Info.plist

Widgets

The TokiWidgets.appex extension is embedded under Contents/PlugIns by both build scripts. Toki and the extension are separate sandboxed processes, so they share a snapshot (widget-data.json) through one of two channels, chosen at package time by TokiWidgetDataMode in each Info.plist:

Because the public DMG is ad-hoc signed (not notarized), released builds ship in local mode. The one caveat that carries to another Mac is Gatekeeper: the appex inherits the same “unidentified developer” friction as the app, so the quarantine flag must be cleared from the whole bundle for the extension to load and register — xattr -dr com.apple.quarantine (note -r, recursive) does this; clearing only the top level leaves the appex quarantined and the widget absent from the gallery. Test on a second Mac or a fresh user account before relying on it, since WidgetKit registration for ad-hoc extensions is not something Apple formally supports.

When a widget looks wrong:

Releases and update channels

Every release starts from a tag push; .github/workflows/release.yml builds the DMG and publishes the GitHub release. The tag decides the channel:

Set appVersion in Sources/Toki/Config/Constants.swift to match the tag’s base version (without the v and without any prerelease suffix) before tagging — the packaging script, the in-app updater’s version check, and the DMG verification all read it. CFBundleShortVersionString must stay dotted-numeric; the prerelease identity travels in the tag, which the release workflow stamps into TokiReleaseVersion in Info.plist. The DMG filename is built from the base version (Toki_2.5.0_universal.dmg) even for a beta tag.

To graduate a beta to production:

  1. Tag v2.5.0-beta.1 (with appVersion = "2.5.0") and test on the Beta channel. Iterate with -beta.2, -beta.3, … as needed.
  2. When it’s ready, set appVersion = "2.5.0" and tag v2.5.0. That build ships to everyone: stable users see it as a normal update, and beta users are offered it too, because 2.5.0 outranks 2.5.0-beta.N — so testers land back on the production build without touching their channel setting. The same graduation happens for brew: the stable tag bumps both casks, so brew upgrade carries toki-beta users onto the stable build.

Version ordering is semver-aware (2.4.3 < 2.5.0-beta.1 < 2.5.0-beta.2 < 2.5.0); the logic and its tests live in UpdateChecker.compareVersions and Tests/TokiTests/UpdateChannelTests.swift. Homebrew’s own ordering agrees, which is what makes the single toki-beta cask work across iterations and graduation.

Homebrew

Two casks live in the tap (aashutoshrathi/homebrew-tap): toki (stable) and toki-beta (prereleases). They conflict with each other — install one.

Homebrew refuses to load a cask from a third-party tap until it is trusted, and it records trust per cask rather than per tap, so brew trust --cask aashutoshrathi/tap/toki is part of installing and installing toki does not cover toki-beta. Switching channels in the app trusts the cask it is moving to for you, naming that one cask so nothing else in the tap is trusted along with it.

homebrew/toki.rb and homebrew/toki-beta.rb in this repo are the cask bodies. The release workflow copies the matching one into the tap on every release and then stamps it, so a change anywhere in a cask is made here, never in the tap: scripts/update-cask.sh rewrites only version and sha256, and anything edited straight in the tap is overwritten at the next release.

A prerelease tag copies and stamps toki-beta alone. The stable cask is deliberately left untouched on that path, because its body would arrive carrying the template’s placeholder version with nothing to stamp a real one over it.

scripts/update-cask.sh <cask.rb> [version] rewrites a cask’s version and DMG sha256. The version argument defaults to appVersion; prerelease tags must pass the full version (e.g. 3.2.0-beta.1) because it exists only in the tag. The DMG filename always carries the base version (Toki_3.2.0_universal.dmg), which the script and the cask’s URL handle.

When Toki was installed by a cask, the in-app updater detects it (BrewCask.installedCask — the Caskroom entry is a symlink to the installed bundle) and routes “Install update” through brew upgrade --cask aashutoshrathi/tap/<toki|toki-beta> instead of swapping the DMG underneath brew. This keeps brew’s receipt in sync with the app on disk; the DMG path would desync it, and the next brew upgrade would then clobber the newer build with the cask’s older one. Dev builds and manual installs keep the direct DMG path.

Naming the tap is load-bearing, not decoration. Homebrew refreshes a third-party tap before install/upgrade only every 24 hours unless the argument is an owner/tap/cask, which drops that window to five minutes (Library/Homebrew/utils/auto-update.sh). With a bare toki, an upgrade run in the first day of a release can resolve against a snapshot taken before the release existed, warn Not upgrading toki, the latest version is already installed, and exit 0 — a handoff that succeeds and changes nothing, with the app still offering the update. The updater also treats that outcome as a failure: it runs brew update and retries the upgrade once before reporting, and what it reports carries brew’s own last line plus brew update && brew reinstall --cask ..., since repeating the upgrade only reproduces the no-op.

The channel picker and the installed cask are kept in agreement in both directions. Picking a channel moves a brew install onto the matching cask (fetch, then uninstall, then install — conflicts_with rules out installing over the top, and fetching first means a network failure cannot strand the uninstall with nothing to reinstall). Swapping casks with brew directly moves the channel instead: the cask decides what brew will install, so the preference follows it on the next check. A failed switch reinstalls the cask that was there and puts the preference back.

Concurrency checking

CI builds with stricter concurrency than a plain swift build, and the difference has broken this project’s CI more than once — a pure static helper on a @MainActor type needs nonisolated, which only the stricter mode catches. Reproduce it locally before pushing:

swift build --build-tests -Xswiftc -strict-concurrency=complete

Conventions

swift-format is not vendored here. Keep changes compiler-clean, locally scoped, and consistent with the surrounding SwiftUI/AppKit style.

Comments should explain what the code cannot: why a timeout is the value it is, why a coordinate space is not flipped, why an ordering is deliberate. Comments that restate the line below them are noise and get deleted.

Troubleshooting