Development

Prerequisites

Setup

git clone https://github.com/iambriansreed/menu-otp.git && cd menu-otp

The repository holds two subprojects: the Swift app in app/ and this website in web/. There's nothing to install to build the app, which has no third-party dependencies; the app icon and menu bar icons in app/Resources/ are committed, so a fresh clone builds straight away. The website and the release tooling share one package.json at the repository root, so run npm install there before working on either. Every command below is written to run from the repository root.

Running the app

app/scripts/demo.sh

Builds a debug copy and runs it with the sample accounts in app/scripts/demo-data.txt. A demo run keeps its data in a temporary folder with a throwaway key, so it never reads or changes your real accounts or touches the Keychain, and it can run alongside the installed app. To load a different file, run MENU_OTP_DEMO_FILE=path/to/urls.txt app/scripts/demo.sh.

app/scripts/bundle.sh release && open "app/build/Menu OTP.app"

Builds and opens the real, optimized app with your real accounts. The first time it saves them, macOS asks whether it may use its Keychain item; choose Always Allow. Every rebuild asks once more, because each build is ad-hoc signed and so counts as a new app to the Keychain.

Only one copy runs per data folder. Launching another asks the running copy to open Settings and then quits.

Testing

app/scripts/test.sh

Runs the unit tests for OTPCore, the part of the app with no user interface. With Xcode selected, plain swift test works too; the script also works when only the Command Line Tools are installed. Two groups of tests are off by default: set MENU_OTP_KEYCHAIN_TESTS=1 to use the real login Keychain, or MENU_OTP_NETWORK_TESTS=1 to ask the real icon services.

app/scripts/demo.sh --self-test

Drives the real menu and Settings window in demo mode, prints a PASS or FAIL line for each check, and exits with the result. Menus and windows flash on screen while it runs, and a click elsewhere can close the menu mid-check, so leave the Mac alone for the few seconds it takes.

app/scripts/demo.sh --snapshot /tmp/snaps --real-icons

Saves screenshots of the menu and Settings in several states. Without --real-icons a few accounts get placeholder icons; with it they get the services' real favicons, as on this site.

Building

app/scripts/make-dmg.sh

Builds a universal (Apple silicon and Intel) release and packages it as app/build/Menu OTP-<version>.dmg. The app is ad-hoc signed and not notarized, so a downloaded copy needs the first-launch steps on the home page.

app/scripts/make-icons.sh

Regenerates app/Resources/AppIcon.icns and the menu bar icons from app/Resources/icon.png. To use a different icon, replace that 1024-pixel PNG and run it again.

Commits and releasing

git config core.hooksPath .scripts/hooks

Commit subjects follow Conventional Commits (feat: ..., fix(popover): ..., docs: ...), because they choose the next version and write CHANGELOG.md. That command, run once per clone, turns on a hook that rejects a subject in any other form.

Pushing to main is the release process; nobody picks a version number. A GitHub Actions workflow runs the tests and, when the commits since the last release include a feat, fix, perf or breaking change, bumps the version, adds the release to the changelog, builds the .dmg with app/scripts/make-dmg.sh and publishes it as a GitHub release. Then it deploys this site. Below 1.0.0 a breaking change bumps the minor version and anything else the patch. A push of only docs, chores or refactors releases nothing.

.scripts/release.mjs --dry-run

Shows what the next release would be. The version itself lives in one place, CFBundleShortVersionString in app/Resources/Info.plist: the app's Settings and About panel, the .dmg name and the release tag read it from there, and a release also updates the generated copy in web/version.json that this site shows. The build number is the git commit count, stamped in at build time.

How it works

Two parts

OTPCore holds everything that can be tested without a screen: codes, parsing, encrypted storage, icon lookup, and the account model the menu and Settings share. MenuOTP is the app around it: the menu bar icon, the menu, and the Settings window.

Storage

Accounts are saved as accounts.enc in ~/Library/Application Support/Menu OTP/, encrypted with AES-256-GCM. The key is a random value kept in the login Keychain. Each save writes a new file and swaps it in, so a crash can't leave a half-written one. A file that can't be decrypted is moved aside, never deleted, and the app says so.

Codes

app/Sources/OTPCore/TOTP.swift generates RFC 6238 codes with 6 digits, a 30-second step, and HMAC-SHA1. The algorithm, digits, and period parameters of an otpauth:// URL are ignored. Secrets are checked as base32 when they're entered.

Icons

app/Sources/OTPCore/IssuerDomains.swift turns an issuer name into likely domains, and FaviconService.swift asks DuckDuckGo's icon service for each one, then Google's if DuckDuckGo had nothing. macOS can read .ico files itself but drops their transparency, so IconImage.swift includes its own ICO decoder. Icons are stored with the account as 32x32 PNGs. At launch, accounts without an icon are looked up four at a time; when a service answers that it has none, that's remembered for a week, but a failed lookup (offline, say) is simply tried again next launch.

The menu

The menu bar dropdown is a borderless panel window drawn with SwiftUI, not a native menu. A native menu can't show grey icons that take on color when highlighted, or use custom row spacing. The panel never activates the app, so opening it over a full-screen app doesn't switch Spaces. The comments in app/Sources/MenuOTP/Popover/ explain the focus and placement handling, and are worth reading before changing it.

Settings

Settings is deliberately not a SwiftUI List: on macOS a List holds a click on a text field in one of its rows for the double-click interval (half a second) before the field takes focus. Only Reorder mode, which has no text fields, uses a List, for its native drag and drop.

Scripts

ScriptDescription
app/scripts/demo.shRun a debug copy with the sample accounts in app/scripts/demo-data.txt
app/scripts/demo.sh --self-testCheck the real menu and Settings window, then exit with the result
app/scripts/demo.sh --snapshot <dir>Save screenshots of the menu and Settings
app/scripts/test.shRun the unit tests
app/scripts/bundle.sh [debug|release]Build app/build/Menu OTP.app; release is optimized and universal
app/scripts/make-dmg.shBuild a distributable .dmg
app/scripts/make-icons.shRegenerate the app and menu bar icons from app/Resources/icon.png
npm run devRun the demo app and this site's dev server together; the app rebuilds and restarts when anything under app/ changes
npm run buildBuild this site into web/dist
.scripts/release.mjs --dry-runShow what the next release would be

Back to home