git clone https://github.com/iambriansreed/menu-otp.git && cd menu-otpThe 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.
app/scripts/demo.shBuilds 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.
app/scripts/test.shRuns 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-testDrives 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-iconsSaves 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.
app/scripts/make-dmg.shBuilds 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.shRegenerates 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.
git config core.hooksPath .scripts/hooksCommit 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-runShows 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.
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.
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.
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.
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 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 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.
| Script | Description |
|---|---|
app/scripts/demo.sh | Run a debug copy with the sample accounts in app/scripts/demo-data.txt |
app/scripts/demo.sh --self-test | Check 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.sh | Run the unit tests |
app/scripts/bundle.sh [debug|release] | Build app/build/Menu OTP.app; release is optimized and universal |
app/scripts/make-dmg.sh | Build a distributable .dmg |
app/scripts/make-icons.sh | Regenerate the app and menu bar icons from app/Resources/icon.png |
npm run dev | Run the demo app and this site's dev server together; the app rebuilds and restarts when anything under app/ changes |
npm run build | Build this site into web/dist |
.scripts/release.mjs --dry-run | Show what the next release would be |