A native macOS version of the /timer page: the same look, the same keys, and the same
options, plus the things only a Mac app can do. It lives in the Dock and the menu bar, keeps
the display awake while it runs, and can go fullscreen on a projector.
Requires macOS 14 (Sonoma) or later.
The setup screen is laid out like System Settings: saved and recent timers in the sidebar, and
the timer's options in a grouped form under a live preview of the countdown: the web form's
options, plus each timer's accent color (Unreasonable's colors first; point at one for its name) and sound (None, Classic, Singing bowl or Marimba, with
buttons to hear them; the singing bowl rings three times at zero). The sounds are made by
scripts/make-sounds.py. Pick a duration with the fields or the 1–60 minute buttons. Open Timer (or
Return) opens the countdown paused, as the web page does; double-click a timer in the sidebar to
open it straight away. The countdown keeps the web page's look.
| Key | Action |
|---|---|
| Space or click | Start / pause. After zero, press twice to reset (the first press asks) |
| ↑ / ↓ | Add / remove 15 seconds; hold to keep going (holding ↓ stops just above zero) |
| R | Reset |
| F | Fullscreen on the selected display |
| Esc | Leave fullscreen; otherwise back to editing the timer (while counting, press twice) |
| ⌘+ / ⌘- / ⌘0 | Zoom the countdown in / out / back to actual size (⌘= works too) |
| ⌘E | Back to the setup form |
Click or drag along the progress bar to jump to that point; hovering it shows the time. Right-click the countdown for a menu of all of these, with their keys. The cursor and window buttons hide after 2.5 seconds of stillness.
Use Save as Timer… at the bottom of the setup screen. Saved timers appear in its sidebar, the menu bar menu, the Dock menu and the Timer menu, where the first nine get ⌘1 to ⌘9. Select one to edit it, then Update to keep the change; right-click to start or delete it. The five most recent runs are kept under Recent, newest first, without repeats. Timers started from the menu bar, Dock, hotkeys, Shortcuts or a link start running immediately; timers opened from the setup form start paused.
Everything is stored in ~/Library/Application Support/Unreasonable Timer/timers.json. There is
no sync with Eco.
Paste a copied Eco /timer?… link into any field of the setup form (or press ⇧⌘V) and the form
fills in. The app also answers to its own scheme, with the same parameters as the web page:
untimer://start?time=300&lead=Intro&text=Wrap%20up&done=Stop&over=0&people=12&theme=light
untimer://open?time=300 # opens paused
time is in seconds (1 to 86,400). text is the text at 15 seconds left (an empty text=
means none). over=0 stops at zero. people is 1 to 10,000. theme=light selects light mode.
The Mac app also reads accent=E8743B (accent color), sound=none|classic|singing-bowl|marimba,
chime15=1 (also chime at 15 seconds left) and text sizes in percent, 50 to 200: timersize,
leadsize, textsize (the text at 15 seconds left), donesize and peoplesize. The web page
ignores them.
Right-click a timer in the sidebar (or the countdown) and choose Copy Link or Share…, or
use the Share button at the bottom of the setup screen. The link points at the Eco web timer,
https://unreasonable.eco/timer?time=…, with the timer's text, sizes, accent and sound, and the
saved timer's name:
- On a Mac with the app, it opens in the app, paused, once shared links are set up below (Safari, Mail, Messages and other apps that hand universal links to apps; Chrome and other browsers open the web page). "Save as Timer…" then suggests the shared name.
- Everywhere else it opens the web timer, which reads the same params and ignores the Mac-only ones.
macOS only opens an https link in an app when the app is signed with that domain (Associated Domains) and the domain lists the app. Three one-time steps:
- In Certificates, Identifiers & Profiles,
open the App ID
com.unreasonablegroup.timer(create it, as a macOS App ID, if it isn't there) and turn on Associated Domains. - Under Profiles, add a Developer ID distribution profile for that App ID and the Developer
ID Application certificate, download it, and add it to the
releaseenvironment:base64 -i Unreasonable_Timer.provisionprofile | gh secret set DEVELOPER_ID_PROFILE --env release --repo parterburn/unreasonable-timer. (It isn't sensitive: it ships inside every copy of the app.) On a Mac, setPROVISIONING_PROFILE=<path>forscripts/release.shinstead. - Serve
https://unreasonable.eco/.well-known/apple-app-site-associationfrom Eco, listingB79A4CAS56.com.unreasonablegroup.timerfor/timer?time=…. The change for Eco, including an "Open in Mac app" link on the web timer for browsers that don't hand links to apps, is in docs/eco-shared-links.md.
The next release embeds the profile (its log shows Associated domains: applinks:unreasonable.eco)
and checks that the signed app still launches before publishing. Apple's CDN caches the
association file, so a change there can take a day to reach Macs.
- Shortcuts / Spotlight: Start Saved Timer, Start Timer (N minutes) and Pause or Resume Timer.
- Show the timer from any app: record a shortcut in Settings ▸ App (click the box, press the keys). None is set by default, so it can't collide with anything you already use. Every other key works while the app is in front; Settings lists them, and right-clicking the timer shows them too.
The gear at the bottom left of the setup screen (or ⌘,) shows the app settings on one page: keep the display awake while running (on by default), keep the timer above other windows, which display to go fullscreen on, menu bar item, open at login, the shortcut to show the timer, a list of the built-in keys, and updates. While a timer is on screen, ⌘, opens them in their own window instead, so the countdown keeps going. Sound, accent color and theme belong to each timer, on the setup screen.
brew install xcodegen
xcodegen generate
open UnreasonableTimer.xcodeprojThe project is generated from project.yml; the .xcodeproj isn't committed. Debug builds
sign ad hoc and need no Apple account.
The countdown logic lives in its own Swift package (Package.swift, Sources/Engine), so its
tests run anywhere Swift does:
swift testIn Xcode, use the UnreasonableTimer scheme (⌘U). The views and system integrations
(menu bar, Dock, power assertion, fullscreen, hotkeys, intents) are checked by hand.
Releases are Developer ID signed and notarized, shipped as a DMG on GitHub Releases, and
updated in place with Sparkle. The installed app fetches
releases/latest/download/appcast.xml without logging in, so this repository must be public
(the release script warns if it isn't).
-
Developer ID certificate. In Xcode ▸ Settings ▸ Accounts ▸ Manage Certificates, add a Developer ID Application certificate. Find its name and your team ID with
security find-identity -v -p codesigning. -
Notarization credentials. In App Store Connect ▸ Users and Access ▸ Integrations ▸ App Store Connect API, create a team key with Developer access and download its .p8 (once only), then:
xcrun notarytool store-credentials unreasonable-timer \ --key AuthKey_ABC123DEFG.p8 --key-id ABC123DEFG --issuer <issuer id>
-
Sparkle update key (needed for self-updating). Without it you still get a signed, notarized DMG, but installed copies can't update themselves. Run:
scripts/setup-sparkle.sh
It creates the EdDSA signing key in your login Keychain (or reuses the one there) and writes the public half into
project.ymlasSUPublicEDKey. The private key never touches the repo. Back it up with thegenerate_keys -x <file>command the script prints, and keep the file in your password manager: without that key you can never ship an update to copies that are already installed. Commit theproject.ymlchange.
-
Bump
MARKETING_VERSIONandCURRENT_PROJECT_VERSIONinproject.yml. Sparkle only offers an update whose build number (CURRENT_PROJECT_VERSION) is higher than the installed one, so the script refuses to build if it isn't higher than the newest build inrelease/updates/appcast.xml. Optionally write what changed inrelease-notes/<version>.md; it is shown in the update window. -
Run:
scripts/release.sh
With one Developer ID Application certificate in your keychain, the script finds the identity and team ID itself; otherwise set
DEVELOPER_ID_APPLICATION(andTEAM_ID). It checks your notarization profile first, then archives unsigned, signs the app inside out withscripts/sign-app.sh(hardened runtime, secure timestamp), notarizes and staples it, and builds, signs, notarizes and staples the DMG. Signing is done withcodesignrather than byxcodebuildbecause an identity passed toxcodebuildis applied to every target, including the Swift package dependencies, and the archive then fails. If a Sparkle key is configured it also regeneratesrelease/updates/appcast.xml; otherwise that step is skipped and you still get a DMG. -
Publish with
PUBLISH=1 scripts/release.sh. It uploads three files: the versioned DMG (what the appcast points at), the same DMG asUnreasonableTimer.dmg, andappcast.xml. If you upload by hand, include all three: the app readsreleases/latest/download/appcast.xml, and the fixed name keeps this link pointing at the newest version:https://github.com/unreasonable/timer/releases/latest/download/UnreasonableTimer.dmg
Keep the release/updates folder between releases; it holds the older DMGs the appcast history
is built from. The first run after setup-sparkle.sh makes macOS ask whether generate_appcast
may use the key in your Keychain: choose Always Allow.
generate_appcast gives every version in the feed the newest release's download prefix, so
scripts/fix-appcast.swift points each one back at its own release. It also removes delta
updates, which are turned off (--maximum-deltas 0): the full DMG is a few megabytes.
The Release workflow (.github/workflows/release.yml) runs PUBLISH=1 scripts/release.sh on a
macOS runner, so a release doesn't need a particular Mac. It runs in
parterburn/unreasonable-timer, a fork that
holds the signing secrets, and publishes to unreasonable/timer's Releases, where installed
apps get updates and the Eco page links its download. It rebuilds the appcast history from the
earlier releases and checks the published links at the end.
Each release:
- Bump the version and add
release-notes/<version>.md(as above) on the fork'smain. - Open a pull request into
unreasonable/timerand merge it with a merge commit. A release is tagged at the fork's commit in unreasonable/timer, sorelease.shrefuses a commit that isn't on unreasonable/timer'smainyet. (After a squash merge, sync the fork from upstream first.) - Run Release from the fork's Actions tab.
One-time setup, in the fork: enable Actions (forks start with them off), then under Settings ▸
Environments create an environment named release, limit its deployment branches to main, and
add these secrets to it:
| Secret | What it is |
|---|---|
NOTARY_KEY, NOTARY_KEY_ID, NOTARY_ISSUER_ID |
An App Store Connect API key (team key, Developer access): the .p8 file's contents, its key ID and the issuer ID |
DEVELOPER_ID_P12 |
Your Developer ID Application certificate and key, exported from Keychain Access as .p12, base64-encoded |
DEVELOPER_ID_P12_PASSWORD |
The password you gave that export |
SPARKLE_PRIVATE_KEY |
The contents of the file generate_keys -x <file> writes |
UPSTREAM_RELEASE_TOKEN |
A fine-grained token with Contents read/write on unreasonable/timer only, to publish there |
DEVELOPER_ID_PROFILE (optional) |
See "Shared links: opening them in the app" |
REPO=parterburn/unreasonable-timer
gh secret set NOTARY_KEY --env release --repo $REPO < AuthKey_ABC123DEFG.p8
gh secret set NOTARY_KEY_ID --env release --repo $REPO --body ABC123DEFG
gh secret set NOTARY_ISSUER_ID --env release --repo $REPO --body <issuer id>
base64 -i DeveloperID.p12 | gh secret set DEVELOPER_ID_P12 --env release --repo $REPO
gh secret set DEVELOPER_ID_P12_PASSWORD --env release --repo $REPO
gh secret set SPARKLE_PRIVATE_KEY --env release --repo $REPO < sparkle-key && rm -P sparkle-key
gh secret set UPSTREAM_RELEASE_TOKEN --env release --repo $REPOThe secrets stay in the fork, so only people with write access to it can sign or notarize as
you. The token can only write to unreasonable/timer's contents; note its expiry date, because
releases fail once it lapses. (NOTARY_APPLE_ID and NOTARY_PASSWORD, an Apple ID with an
app-specific password, still work in place of the API key.)
CI checks that the appcast is generated and signed, but only a real install can prove the update
itself works. Release 1.0.0, install it from the DMG, then release a build with a higher
CURRENT_PROJECT_VERSION and choose Check for Updates… in the installed app. It should offer
the new version, download it, replace itself and relaunch.
project.yml XcodeGen project (app + test targets, Hardened Runtime)
Package.swift the TimerCore package, for `swift test`
Sources/Engine/ TimerConfig, TimerEngine, history: pure logic, no UI
Sources/Store/ PresetStore: saved timers and recent runs (JSON)
Sources/Views/ Theme, CountdownView, SetupView, menu bar, Settings
Sources/System/ KeepAwake, Notifier, DockTile, DisplayPresenter, Hotkeys, Updater
Sources/Intents/ App Intents for Shortcuts and Spotlight
Sources/App/ app entry, TimerController, Dock menu, Timer menu
Resources/ asset catalog, bundled Inter (OFL) fonts
scripts/ release.sh, make_icon.py
Tests/ TimerEngine unit tests