← Back to all posts

What iOS Really Lets an Alarm App Do: Building Wake Me Smarter

Wake Me Smarter is an iOS alarm clock with one rule: to silence it, you solve a puzzle. Quick Math, a daily Wordle, a Stroop test, a maze, a "hold the button for exactly seven seconds" timing challenge. The app is Swift and SwiftUI targeting iOS 17, persists alarms with SwiftData, sells tiers through StoreKit 2, and pulls its daily puzzles from a Cloudflare Worker backed by KV.

The product idea is simple. The engineering is not, because an alarm app is the one category where the gap between what users assume an app can do and what iOS actually allows is widest. People expect a third-party alarm to behave like the built-in Clock app. For most of the iPhone's history it could not, and the codebase is full of decisions shaped by that gap. This post walks through the five problems that took the most thought, with the real code, including the places where the current code is wrong and what I would change.

1. An alarm is a notification you hope the user hears

The first thing to internalize: when the alarm time arrives, your app is almost certainly not running. iOS suspends and eventually terminates background apps, and there is no API that lets a regular app wake itself up at 6:30 and start doing work. What you get is the local notification system. You hand the OS a notification with a calendar trigger, and iOS fires it on your behalf.

Here is the core of AlarmService.scheduleAlarm:

var components = Calendar.current.dateComponents([.hour, .minute], from: alarm.time)

if alarm.repeatDays.isEmpty {
    let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
    center.add(UNNotificationRequest(identifier: alarm.id.uuidString, content: content, trigger: trigger))
} else {
    for weekday in alarm.repeatDays {
        components.weekday = weekday + 1
        let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
        let id = "\(alarm.id.uuidString)-\(weekday)"
        center.add(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
    }
}

A few details are doing real work here. Only hour and minute are extracted from the stored Date, so the trigger matches "06:30 on any day" rather than one specific instant. A "Mon, Wed, Fri" alarm becomes three separate repeating requests, because a single calendar trigger can only match one weekday. The model stores days as 0=Sun ... 6=Sat while Calendar uses 1=Sunday, hence the + 1.

The per-weekday fan-out has a cost. iOS keeps a limited number of pending local notifications per app (64 is the commonly documented ceiling), and anything past that is silently dropped. An alarm repeating every day consumes seven slots, so a user with nine daily alarms is already over. Tier caps on alarm count keep v1 safe, but the limit belongs in the design doc.

The stale identifier bug

Rescheduling starts by removing the alarm's existing requests:

center.removePendingNotificationRequests(withIdentifiers: identifiers(for: alarm))

private func identifiers(for alarm: Alarm) -> [String] {
    alarm.repeatDays.isEmpty
        ? [alarm.id.uuidString]
        : alarm.repeatDays.map { "\(alarm.id.uuidString)-\($0)" }
}

This looks correct and is not. In the edit screen, save() writes the new repeatDays onto the model and then calls scheduleAlarm. So the identifiers being removed are computed from the new days, not the old ones. Change an alarm from Mon/Wed to Fri and the Monday and Wednesday requests survive, firing forever for an alarm the user believes no longer rings on those days. Switching a repeating alarm to one-shot leaks every weekday request the same way.

The robust fix is to stop deriving identifiers from mutable state at all. Every request id already starts with the alarm's UUID, so the cancel path should ask the notification center what is pending and remove everything with that prefix:

let pending = await center.pendingNotificationRequests()
let ids = pending.map(\.identifier).filter { $0.hasPrefix(alarm.id.uuidString) }
center.removePendingNotificationRequests(withIdentifiers: ids)

The OS is the source of truth for what is scheduled. Your model only says what should be.

2. Sound: the part users notice first

The App Store listing draft says the app "plays through Silent mode". That is true, with a large asterisk, and the asterisk is the most important thing to understand about third-party alarms on iOS.

There are two completely different sound paths in this app. The first is the notification sound, set on the scheduled content as UNNotificationSound(named: "alarm.caf"). This is what plays when the app is not running, which is most mornings. It is subject to the ring/silent switch and Focus modes, and iOS caps custom notification sounds at 30 seconds. A heavy sleeper gets one burst, then silence.

The second path is AudioService, which only runs once the alarm screen is on screen:

func startAlarm(soundName: String = "alarm") {
    do {
        try AVAudioSession.sharedInstance().setCategory(.playback)
        try AVAudioSession.sharedInstance().setActive(true)
    } catch {
        print("AudioSession: \(error)")
    }
    guard let url = Bundle.main.url(forResource: soundName, withExtension: "caf") else { return }
    player = try? AVAudioPlayer(contentsOf: url)
    player?.numberOfLoops = -1
    player?.play()
}

The .playback category is what ignores the silent switch, and numberOfLoops = -1 loops until stopAlarm() is called after a solve. Combined with the audio background mode in Info.plist, the loop keeps going if the user locks the phone mid-puzzle. This is the "real" alarm. But it only starts after the user has tapped the notification and the app is in the foreground. The honest description is: the alarm is a notification that rings briefly, and the relentless part begins once you engage with it.

The options for closing that gap are limited. The Critical Alerts entitlement lets a notification bypass silent and Focus, but Apple grants it on request for categories like health and safety, and an alarm app with puzzles is not an obvious fit. The project notes record the decision to ship without it and apply after launch. Newer iOS releases add AlarmKit, a system framework for third-party alarms that ring like the Clock app; adopting it would mean raising the deployment target above iOS 17, and that is the most likely direction for a future version rather than more workarounds. The wrong answer is playing silent audio all night to stay alive in the background, which drains battery and invites rejection.

3. Puzzle-to-dismiss: designing for the person who cannot win

The puzzle architecture is deliberately narrow. Every puzzle is a SwiftUI view that takes a difficulty and a single closure:

case .quickMath:
    QuickMathView(difficulty: difficulty, onSolve: onSolve)
case .dailyWordle:
    if let w = daily?.wordle {
        DailyWordleView(word: w.word, onSolve: onSolve)
    } else { loadingView }

PuzzleHostView is a switch over 22 cases, and the alarm screen does not know or care which one is running. It starts the audio on appear and stops it inside onSolve. No protocol hierarchy, no shared state machine: a new puzzle is one file and one case. Difficulty is just a parameter each puzzle interprets, so Quick Math goes from a + b at Easy to (a + b) × c - d at Brutal, while Hold the Line shrinks its tolerance from 0.8 seconds to 0.1.

The hard design question is not how to build puzzles. It is what happens when someone half-asleep cannot solve one, because the alarm is still looping at full volume. Three puzzles in the codebase answer that question differently, and comparing them was the most useful design exercise in the project.

The rule I took from this: a dismiss puzzle must always be winnable eventually. Difficulty should control how long it takes, never whether it can end. For Wordle, that means revealing the word after six failures and requiring the user to type it, which still demands enough attention to wake up.

The second dead end is the network. Daily puzzles are fetched when the alarm screen appears:

if alarm.puzzleType.isDaily {
    Task { dailyPuzzle = try? await PuzzleService.shared.fetchTodayPuzzle() }
}

If the phone is offline at 6:30, try? swallows the error, dailyPuzzle stays nil, and the host shows "Loading today's puzzle..." forever while the alarm plays. The project's own Phase 2 checklist already names the fix: fetch the daily puzzle when the alarm is saved, not when it fires. I would go one step further and fall back to a static puzzle of similar difficulty after a short timeout. Anything an alarm depends on at fire time should already be on the device.

Snooze has a subtler problem. It creates a brand new Alarm five minutes out and inserts it into SwiftData, so every snooze leaves a permanent "Snoozed" entry in the alarm list. A snooze is an event, not an alarm. It should be a one-off notification request keyed off the original alarm's id, with no model row at all.

The notification extension

To show the puzzle without fully opening the app, there is a Notification Content Extension registered for the ALARM category. Extensions run in a separate process and cannot read the main app's SwiftData store unless both share an App Group, so the extension rebuilds the alarm from the notification's userInfo:

let alarm = Alarm(
    time: Date(),
    puzzleType: puzzleType,
    difficulty: difficulty,
    existButtonEnabled: existEnabled,
    shakeEnabled: shakeEnabled
)
alarm.id = alarmID

This is why scheduleAlarm packs the puzzle type, difficulty and option flags into userInfo: the notification has to be self-describing. The weak spot is persistence. The extension creates its own ModelContainer with no App Group configured, so solve results recorded there land in a private store the main app's streak dashboard never reads. Until the App Group is wired up, the honest choice is to have the extension forward to the app rather than half-record history.

4. The backend: one key per day in Cloudflare KV

Daily puzzles exist so everyone gets the same Wordle word on the same day. That needs a server, but a tiny one. The Worker has exactly one route, /puzzle/today, and the puzzle is a pure function of the date:

function deterministicIndex(seed: string, len: number): number {
  let h = 0;
  for (const c of seed) h = (Math.imul(31, h) + c.charCodeAt(0)) | 0;
  return Math.abs(h) % len;
}

const wordIdx = deterministicIndex(dateStr, WORDS.length);
const triviaIdx = deterministicIndex(dateStr + ":t", TRIVIA.length);

Each puzzle type gets a different salt so the Wordle index and the trivia index do not move in lockstep. The word banks, trivia questions, capitals, Connections groups and mini crosswords are JSON files bundled into the Worker. Daily Equation is generated arithmetically from the same seed.

Why KV and not D1? The access pattern is one key, puzzle:YYYY-MM-DD, read by every client and written at most a few times a day. That is exactly what KV is good at: globally replicated, eventually consistent reads of small values. There is no query to run, no relation to join. A SQL database here would be complexity without a job.

Writes happen two ways. A cron trigger at midnight UTC precomputes today and tomorrow. The fetch handler also builds the puzzle lazily on a miss:

let raw = await env.PUZZLE_KV.get(`puzzle:${today}`);
if (!raw) {
  raw = JSON.stringify(await buildPuzzle(today));
  await env.PUZZLE_KV.put(`puzzle:${today}`, raw, { expirationTtl: 60 * 60 * 72 });
}

Because buildPuzzle is deterministic, two concurrent misses write identical bytes, so the classic cache-stampede race is harmless. The cron is an optimization, not a correctness requirement. KV is effectively a memo table for a pure function, which is the most forgiving way to use an eventually consistent store. The response carries Cache-Control: public, max-age=3600, and the iOS client caches the last puzzle in UserDefaults keyed by the UTC date string.

Two decisions I would revisit. First, "today" is the UTC date on both server and client. Consistent, but a user in Israel gets the new puzzle around 2 or 3 a.m. local time, and a user in California gets "tomorrow's" puzzle in the late afternoon. For a shared daily challenge, UTC is defensible. For "the puzzle that greets you this morning", it is not.

Second, the Worker signs every response with HMAC-SHA256 and returns it in an X-Puzzle-Sig header, but the iOS client never checks it. It also cannot meaningfully check it: HMAC is symmetric, so verifying on-device means shipping the secret in the binary, at which point it protects nothing. If integrity matters, the right primitive is an asymmetric signature, a private key on the Worker and a public key in the app. In practice HTTPS already covers transport integrity, and the payload includes the answers in plaintext anyway. For a single-player alarm that is fine, since cheating on your own wake-up puzzle only hurts you. I would delete the header rather than keep a security feature that does not secure anything.

5. App Store preparation: the boring failures

None of the expensive mistakes in this phase were algorithmic. They were string mismatches and placeholder code that looked finished.

Product IDs that do not match. The App Store Connect plan in APP_STORE.md registers products as com.etielyakira.wakemesmarter.basic_monthly and so on. SubscriptionService asks StoreKit for com.wakemesmarter.basic_monthly. StoreKit does not error on unknown IDs, it returns an empty product list, and the paywall renders with nothing to buy. The tier mapping makes it worse by matching on substrings:

case let id where id.contains("premium"): highest = max(highest, .premium)
case let id where id.contains("basic"), "com.wakemesmarter.lifetime": highest = max(highest, .basic)

The substring match would survive a prefix rename, but the hard-coded lifetime ID would not, so lifetime buyers would quietly stay on the free tier. Product IDs should live in one enum, and a debug build should assert that every requested ID came back from Product.products(for:).

A webhook that trusts anyone. The App Store Server Notifications worker decodes Apple's JWS payload but explicitly skips signature verification. As written, anyone who finds the URL can POST a forged SUBSCRIBED event and write a premium record into KV. Today that is harmless because the app reads its tier from Transaction.currentEntitlements on-device, which StoreKit 2 verifies locally, and nothing reads the KV records yet. It becomes a real hole the day anything server-side trusts them. Verify the certificate chain against Apple's root before that day, not after.

A Mac is not optional. All of the Swift, the Worker, the landing page at ios.etiel.dev and the full App Store Connect copy were written on Windows. The setup guide is upfront that creating the Xcode project, adding the extension target, enabling capabilities, testing on a real device and archiving all require macOS. That means a whole class of bugs, from signing to the 30-second sound limit to the untuned 2.5 g shake threshold, stays invisible until the first device build. Plan for that phase to find things.

What I would tell myself at the start

Build the alarm path as if the app will be dead when it fires, because it will be. Treat the OS's pending notification list as the source of truth. Make every puzzle winnable. Keep everything the alarm needs on the device before bedtime. Use the backend for what genuinely needs to be shared, which here is one deterministic JSON blob per day, and let KV be a cache in front of a pure function. And be suspicious of any security or billing code that looks complete but has never processed a real transaction.

The puzzles were the fun part. The 6:30 a.m. edge cases are the product.