【開発日誌 Day1】Captioが好きすぎて、もう一度"あの体験"を作りたくなった

Captioというアプリを知っているだろうか。

メモを書いて、送信ボタンを押す。それだけで、自分のメールアドレスにメモが届く。起動した瞬間にキーボードが表示され、書いたら送る、送ったら消える。余計な機能は一切ない。ただそれだけのアプリだった。

でも「ただそれだけ」が、とてつもなく心地よかった。

通勤電車の中で思いついたアイデア、会議中にふと浮かんだタスク、寝る前に頭をよぎった一言——Captioはそのすべてを、1秒以内にメールボックスに届けてくれた。メモアプリを開いて、フォルダを選んで、タイトルを付けて……という煩わしさが一切ない。その「摩擦ゼロ」の体験は、一度知ったら手放せなくなるものだった。

そのCaptioが、サービスを終了した。

代替アプリをいくつか試したが、どれもあの体験には程遠かった。起動が遅い、UIに余計な要素がある、送信後にメモが残る——些細な違いに見えるかもしれないが、Captioが作り上げた体験の本質は、まさにそういった「些細なこと」の積み重ねだった。

だから、自分で作ることにした。Captioの体験を、もう一度。

これは「Captio式シンプルメモ」の開発日誌Day1。この記事では、Captioの体験を分解し、それを現代の技術スタックでどう再現するか、すべての技術的判断を記録する。

1. Captioの「何」が良かったのか——体験を分解してみた

Captioの体験を言語化するために、3つの要素に分解してみた。

要素1:起動即入力(Instant Launch-to-Input)

Captioを起動すると、即座にキーボードが表示され、テキスト入力が可能になる。スプラッシュスクリーンの後にホーム画面が表示されて、そこからメモ作成ボタンを押して……という手順がない。起動=入力。この「起動からテキスト入力可能になるまでの時間」を、私たちはTime-to-Textと呼んでいる。Captioのそれは、体感で0.5秒以下だった。

要素2:送ったらすぐ消える(Instant Clear After Send)

送信ボタンを押すと、画面は即座にクリアされる。「送信中…」のプログレスバーを見つめて待つ時間がない。書いたメモが消えた瞬間、「ああ、送れたな」という安心感とともに次の行動に移れる。この「送信ボタンを押してからUIがクリアされるまでの時間」を、私たちはSend-to-Resetと呼んでいる。目標は150ms以下だ。

要素3:余計なものが一切ない(Nothing Unnecessary)

フォルダ機能、タグ機能、リッチテキスト、マークダウン対応——Captioにはそのどれもなかった。あるのはテキスト入力欄と送信ボタンだけ。この潔さが、認知負荷をゼロに近づけていた。「何をすべきか」を考える必要がないアプリ。それがCaptioだった。

この3つの要素を、現代のiOS開発で再現する。それが「Captio式シンプルメモ」のミッションだ。

2. アーキテクチャ:なぜSwiftUI「ではなく」UIKitなのか

2026年のiOS開発で、SwiftUIではなくUIKitを選ぶのは異端に見えるかもしれない。しかし、私たちの最優先目標は明確だった——Time-to-Text 500ms以下

SwiftUIの`body`再評価、`StateObject`の初期化、`@Environment`の解決——これらのオーバーヘッドは通常のアプリ開発では無視できる範囲だが、「起動即入力」を実現するには無視できない。1msでも速く、テキストフィールドにフォーカスを当てたい。

そこで採用したのが、Storyboardを使わないUIKit直接構築だ。

SceneDelegateでwindowを生成し、rootViewControllerに直接ComposeViewControllerを設定する。Storyboardのパース処理すらスキップする。

// SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = ComposeViewController()
    window.makeKeyAndVisible()
    self.window = window
}

そしてComposeViewControllerの`viewDidAppear`で、即座にテキストビューにフォーカスを当てる。

// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    textView.becomeFirstResponder()
}

この2つのコードだけで、起動からキーボード表示までのパスが最短になる。

実機計測の結果は200〜300ms。目標の500msを大幅に下回った。SwiftUIで同等のことを実現しようとすると、`@FocusState`の遅延やbody再評価のタイミングの問題で、安定して500ms以下を達成するのは困難だった。

UIKitは「古い」かもしれない。しかし「速い」。この1点において、UIKitは今でも最良の選択肢だ。

3. 「送ったら消える」を150ミリ秒で実現する設計

Captioの「送ったら消える」体験を再現するために、最も重要な設計判断がある。それは、送信アニメーションとUIクリアを、ネットワークレスポンスから完全に分離することだ。

一般的なアプリの送信フローはこうだ:

  1. 送信ボタンを押す
  2. ローディングインジケーターを表示
  3. サーバーからのレスポンスを待つ
  4. 成功ならUIをクリア、失敗ならエラーを表示

このフローでは、ネットワークの遅延がそのままUIの遅延になる。私たちのフローはこうだ:

  1. 送信ボタンを押す
  2. 即座にUIをクリア(アニメーション: 0.25秒)
  3. バックグラウンドでネットワーク送信
  4. 成功→静かにOutboxから削除 / 失敗→バックグラウンドでリトライ
// ComposeViewController.swift
private func performSendAnimation() {
    UIView.animate(withDuration: 0.25,
                   delay: 0,
                   options: .curveEaseOut) {
        self.textView.alpha = 0
        self.subjectField.alpha = 0
    } completion: { _ in
        self.textView.text = ""
        self.subjectField.text = ""
        self.textView.alpha = 1
        self.subjectField.alpha = 1
        self.textView.becomeFirstResponder()
    }
}

ユーザーにとっては、送信ボタンを押した瞬間にメモが「消えて」、次のメモを書ける状態になる。ネットワークの成功・失敗は、ユーザーの目に見えないところで処理される。

「でも、送信に失敗したらどうするの?」——その疑問に答えるのが、次のセクションのOutboxアーキテクチャだ。

4. 「取りこぼしゼロ」を実現するOutboxアーキテクチャ

送信UIをネットワークレスポンスから分離するということは、「UIはクリアされたけど、実はメール送信に失敗していた」という状況が起こり得るということだ。これは絶対に許容できない。ユーザーが「送った」と思ったメモが、実は送られていなかった——これはメモアプリにとって致命的な信頼の喪失だ。

そこで採用したのがOutboxパターンだ。

メッセージの送信フローは以下の通り:

  1. 送信ボタンタップ:メッセージをAES-GCM暗号化してOutbox(ローカルストレージ)に保存
  2. UIクリア:performSendAnimationで即座に画面をクリア
  3. バックグラウンド送信:Outboxからメッセージを取り出してRelay APIに送信
  4. 成功:Outboxからメッセージを削除
  5. 失敗:Exponential Backoffでリトライ(1秒→2秒→4秒→8秒…)
  6. オフライン:NWPathMonitorで接続を監視し、復帰時に自動再送

重要なのは、ネットワーク送信の前にOutboxに保存するという順序だ。これにより、アプリが突然クラッシュしても、端末の電源が落ちても、メッセージは失われない。

Outboxに保存されるメッセージは、CryptoKitを使ったAES-GCM暗号化で保護される。暗号化キーはKeychainに保存され、アプリのサンドボックス外からはアクセスできない。

// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
    let key = try KeychainHelper.getOrCreateSymmetricKey()
    let sealedBox = try AES.GCM.seal(
        message.plainData,
        using: key
    )
    let encrypted = EncryptedOutboxEntry(
        id: message.id,
        sealedData: sealedBox.combined!,
        createdAt: Date(),
        retryCount: 0
    )
    try persistenceStore.save(encrypted)
}

また、すべてを自前で実装しているため、外部ライブラリへの依存はゼロだ。CryptoKit、NWPathMonitor、URLSession——すべてApple純正のフレームワークで完結する。サードパーティライブラリのバージョン管理やセキュリティ監査から解放されるのは、小規模チームにとって大きなメリットだ。

5. Relay API:Gmail依存からの脱却

Captioは当初、ユーザーのGmailアカウントを通じてメールを送信していた。しかし、GoogleのOAuth要件の変更やAPI制限により、この方式は持続可能ではない。

私たちはCloudflare Workers + Resend APIという構成を選んだ。

Cloudflare Workersはエッジコンピューティング基盤で、世界中のユーザーに最も近いデータセンターでコードが実行される。日本からのリクエストは日本のエッジで処理され、アメリカからのリクエストはアメリカのエッジで処理される。これにより、従来のサーバーレス(AWS Lambda等)と比べて、コールドスタートの問題がほぼ解消される。

メール送信にはResend APIを使用する。Resendはデベロッパーフレンドリーなメール送信APIで、高い到達率と安定性を提供する。

メール認証フロー

ユーザーが宛先メールアドレスを設定する際、6桁の認証コードによるメール認証を行う。これにより、第三者のメールアドレスへの不正送信を防ぐ。

多層レート制限

不正利用を防ぐために、多層のレート制限を実装している。

// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
  devicePerMinute: 30,    // 1デバイスあたり1分30回
  devicePerDay: 200,      // 1デバイスあたり1日200回
  ipPerHour: 120,         // 1IPあたり1時間120回
  globalPerDay: 300       // サービス全体で1日300回(調整可能)
};

デバイス単位、IP単位、グローバル単位の3層でレート制限をかけることで、単一デバイスの暴走もボットネット攻撃もブロックできる。

冪等性の保証

ネットワークの不安定さによる重複送信を防ぐため、各メッセージにUUIDを割り当て、Relay API側で重複チェックを行う。同じUUIDのメッセージが再送された場合、2回目以降はメール送信を行わずに成功レスポンスを返す。これにより、Outboxパターンのリトライ機構と組み合わせても、ユーザーが同じメモを二重に受信することはない。

6. 今日やったこと:小さな違和感を、ひとつずつ消す

Day1の実装作業の多くは、星評価ダイアログの見直しに費やした。App Storeの評価はアプリの生命線だが、ユーザー体験を損なう形で評価を求めてはいけない。

表示条件の再設計

星評価ダイアログの表示条件を以下のように設定した:

  • 100通ごとに表示:十分にアプリを使い込んだユーザーにのみ表示
  • 最低14日間の間隔:前回表示してから14日以上経過していること
  • 閉じた後は30日間表示しない:ユーザーが閉じた場合、30日間は再表示しない

星アイコンのタップ領域修正

Apple Human Interface Guidelinesでは、タッチターゲットのサイズは最低44x44ptが推奨されている。星アイコンのタップ領域がこの基準を満たしていなかったため修正した。

// StarRatingView.swift
private func createStarButton() -> UIButton {
    let button = UIButton(type: .custom)
    button.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
        button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
    ])
    return button
}

星アイコンの歪み修正

UIStackViewの`.fillEqually` distributionが原因で星アイコンが歪んでいた問題を修正。`contentMode`を`.scaleAspectFit`に設定し、StackViewのdistributionを`.equalSpacing`に変更した。

評価後のフロー

  • 5つ星:App Storeのレビュー画面に遷移(`SKStoreReviewController`)
  • 4つ星以下:「ありがとうございます」のトーストを静かに表示して終了。ネガティブなレビューをApp Storeに誘導しない。

7. Historyの表示ステータス不整合を解消した

送信履歴画面(History)で、あるバグに気づいた。送信が完了しているにも関わらず、ステータスが「送信中」のまま変わらないメッセージがあった。

原因を調査した結果、ステータス更新のロジックに問題があることが判明した。従来の実装では、メッセージのテキスト内容をキーにしてステータスを更新していた。しかし、同じ内容のメモを複数回送信した場合、テキストベースの検索では正しいメッセージを特定できない。

修正は単純明快だった。テキストベースの検索をIDベースのステータス更新に変更した。各メッセージにはOutbox保存時にUUIDが割り当てられているため、このIDを使ってHistoryのステータスを更新する。

// HistoryManager.swift
// Before: テキストベースの検索(バグの原因)
// func updateStatus(forText text: String, status: SendStatus)

// After: IDベースのステータス更新
func updateStatus(forMessageId id: UUID, status: SendStatus) {
    guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
        return
    }
    entries[index].status = status
    persistEntries()
}

この修正により、同じ内容のメモを何度送っても、それぞれのステータスが正しく表示されるようになった。

8. プライバシーへの偏執的なこだわり

メモアプリにはプライベートな情報が書き込まれる。パスワード、個人的な悩み、ビジネスアイデア——ユーザーが何を書くかは予測できない。だからこそ、プライバシー保護は「十分」ではなく「過剰」なくらいがちょうどいい。

アプリスイッチャーでのプライバシーオーバーレイ

iOSのアプリスイッチャーでは、アプリの画面がサムネイルとして表示される。メモの内容が第三者に見られるリスクを排除するため、アプリがバックグラウンドに移行する際にプライバシーオーバーレイを表示する。

Ephemeral URLSession

通常のURLSessionは、キャッシュ、クッキー、認証情報をディスクに保存する。私たちは`URLSessionConfiguration.ephemeral`を使用することで、これらの情報がディスクに一切残らないようにしている。メモの内容がキャッシュとしてストレージに残るリスクをゼロにする。

ログの無害化

開発中のデバッグログには注意が必要だ。「送信したメモの内容をログに出力する」という一見無害な処理が、セキュリティホールになり得る。私たちのポリシーは明確だ:

  • メモの内容は、DEBUGビルドであっても一切ログに記録しない
  • エラーログでは`localizedDescription`を使わず、`type(of: error)`のみを記録
  • 理由:`localizedDescription`にはユーザー入力が含まれる可能性があるため
// NetworkManager.swift
// BAD: エラーの詳細をログに出力
// Logger.error("Send failed: \(error.localizedDescription)")

// GOOD: エラーの型のみをログに出力
Logger.error("Send failed: \(type(of: error))")

偏執的に見えるかもしれない。しかし、メモアプリを信頼して使ってもらうためには、この程度の偏執さが必要だと考えている。

9. 10言語対応:世界中のCaptioユーザーに届けたい

Captioは英語圏を中心に世界中にユーザーがいた。その元ユーザーたちに「Captio式シンプルメモ」を届けるために、初回リリースから10言語に対応した。

対応言語:日本語、英語、スペイン語、フランス語、ドイツ語、イタリア語、ポルトガル語、韓国語、中国語(簡体字)、アラビア語。

特にアラビア語のRTL(Right-to-Left)対応は技術的な挑戦だった。テキストの方向だけでなく、UIレイアウト全体をミラーリングする必要がある。Auto Layoutの`leading`/`trailing`制約を一貫して使うことで、RTL環境でも自然なレイアウトを実現している。

アプリ内言語切り替え

iOS 16以降のアプリ内言語切り替えに加え、独自の言語切り替えメカニズムも実装している。言語が切り替わった際は、ViewControllerを`cross-dissolve`トランジションで差し替えることで、スムーズな切り替え体験を提供する。

// LanguageManager.swift
func applyLanguageChange() {
    guard let window = UIApplication.shared.connectedScenes
        .compactMap({ $0 as? UIWindowScene })
        .first?.windows.first else { return }

    let newVC = ComposeViewController()
    newVC.view.frame = window.bounds
    UIView.transition(with: window,
                      duration: 0.3,
                      options: .transitionCrossDissolve,
                      animations: {
        window.rootViewController = newVC
    })
}

10. 今日の学び:プロダクトは「安心感」の設計

Day1を終えて思うのは、このプロダクトの本質は「安心感」の設計だということだ。

技術的な数値をまとめてみる:

  • Time-to-Text:500ms以下(実測200〜300ms)
  • Send-to-Reset:150ms以下
  • メッセージの取りこぼし:ゼロ(Outboxパターン)
  • 暗号化:AES-GCM(CryptoKit)
  • 外部ライブラリ依存:ゼロ

しかし、これらの数値はすべて「安心感」という一つの目標に集約される。

起動が速い→「いつでもすぐ書ける」安心感。
送ったら消える→「処理された」安心感。
取りこぼしゼロ→「絶対に届く」安心感。
暗号化→「誰にも見られない」安心感。

Captioが愛されたのは、機能が優れていたからではない。「安心して使える」体験を、徹底的に磨き上げていたからだ。それを引き継ぎたい。

11. 次回(Day2)予告

Day2では、以下のテーマに取り組む予定だ:

  • 送信アニメーションの洗練:現在の0.25秒フェードアウトから、より心地よいアニメーションへ
  • Historyステータス更新のタイミング最適化:リアルタイム更新 vs バッチ更新の比較検討
  • 成功トーストは本当に必要か?:送信成功のフィードバックについて、「表示しない」という選択肢の検討

Captioの「何もない」潔さを目指して、Day2へ続く。

[Dev Log Day 1] Loved Captio So Much, I Had to Recreate That Experience

Have you ever heard of an app called Captio?

You write a memo, tap the send button. That's it -- your memo arrives in your email inbox. The keyboard appeared the instant you launched it. You wrote, you sent, it cleared. No extra features whatsoever. That was all the app did.

But that "all it did" was extraordinarily comfortable.

An idea on the commuter train, a task that popped into your head during a meeting, a thought before falling asleep -- Captio delivered all of them to your inbox within a second. No opening a notes app, choosing a folder, typing a title... none of that friction. Once you experienced that "zero friction" workflow, you couldn't let it go.

Then Captio shut down.

I tried several alternatives, but none came close to that experience. Slow launch times, unnecessary UI elements, memos lingering after send -- these might seem like minor differences, but the essence of what Captio had built was precisely the accumulation of these "minor things."

So I decided to build it myself. The Captio experience, once more.

This is Day 1 of the "Simple Memo (Captio-style)" development diary. In this article, I'll deconstruct the Captio experience and document every technical decision made to recreate it with a modern tech stack.

1. What Made Captio So Good -- Deconstructing the Experience

To articulate the Captio experience, I broke it down into three elements.

Element 1: Instant Launch-to-Input

When you launched Captio, the keyboard appeared instantly and text input was ready. There was no splash screen followed by a home screen where you then tap a "compose" button. Launch equals input. We call the time from launch to text input readiness Time-to-Text. Captio's felt like under 0.5 seconds.

Element 2: Instant Clear After Send

When you tapped send, the screen cleared immediately. No staring at a "Sending..." progress bar. The moment your memo disappeared, you felt the reassurance of "it's sent" and moved on. We call the time from tapping send to UI clearing Send-to-Reset. Our target is under 150ms.

Element 3: Nothing Unnecessary

Folders, tags, rich text, markdown support -- Captio had none of these. Just a text field and a send button. This decisiveness brought cognitive load close to zero. An app where you never have to think about "what should I do." That was Captio.

Recreating these three elements with modern iOS development. That's the mission of "Simple Memo (Captio-style)."

2. Architecture: Why UIKit Instead of SwiftUI

Choosing UIKit over SwiftUI in 2026 iOS development might seem unorthodox. But our top priority was clear -- Time-to-Text under 500ms.

SwiftUI's `body` re-evaluation, `StateObject` initialization, `@Environment` resolution -- these overheads are negligible in typical app development, but not when you need "instant launch-to-input." We wanted to focus the text field even 1ms faster.

So we adopted direct UIKit construction without Storyboard.

We create the window in SceneDelegate and set ComposeViewController directly as the rootViewController. We even skip Storyboard parsing.

// SceneDelegate.swift
func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = ComposeViewController()
    window.makeKeyAndVisible()
    self.window = window
}

Then in ComposeViewController's `viewDidAppear`, we immediately focus the text view.

// ComposeViewController.swift
override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    textView.becomeFirstResponder()
}

These two pieces of code alone create the shortest path from launch to keyboard display.

Real device measurements: 200-300ms. Well below our 500ms target. Trying to achieve the same with SwiftUI, we found that `@FocusState` delays and body re-evaluation timing made it difficult to consistently stay under 500ms.

UIKit may be "old." But it's "fast." On this single point, UIKit remains the best choice.

3. Achieving "Send and Clear" in 150 Milliseconds

To recreate Captio's "send and it's gone" experience, the most critical design decision was to completely decouple the send animation and UI clear from the network response.

A typical app's send flow looks like this:

  1. Tap the send button
  2. Show a loading indicator
  3. Wait for the server response
  4. Clear UI on success, show error on failure

In this flow, network latency directly becomes UI latency. Our flow works differently:

  1. Tap the send button
  2. Immediately clear the UI (animation: 0.25s)
  3. Send via network in the background
  4. Success: silently remove from Outbox / Failure: retry in background
// ComposeViewController.swift
private func performSendAnimation() {
    UIView.animate(withDuration: 0.25,
                   delay: 0,
                   options: .curveEaseOut) {
        self.textView.alpha = 0
        self.subjectField.alpha = 0
    } completion: { _ in
        self.textView.text = ""
        self.subjectField.text = ""
        self.textView.alpha = 1
        self.subjectField.alpha = 1
        self.textView.becomeFirstResponder()
    }
}

For the user, the moment they tap send, the memo "vanishes" and they're ready to write the next one. Network success or failure is handled invisibly in the background.

"But what if the send fails?" -- that question is answered by the Outbox architecture in the next section.

4. Zero Message Loss: The Outbox Architecture

Decoupling the send UI from the network response means that "the UI cleared but the email actually failed to send" could happen. This is absolutely unacceptable. A user thinking they sent a memo that was never actually delivered -- this is a fatal loss of trust for a memo app.

That's why we adopted the Outbox pattern.

The message send flow works as follows:

  1. Tap send: Save the message to the Outbox (local storage) with AES-GCM encryption
  2. UI clear: performSendAnimation immediately clears the screen
  3. Background send: Retrieve the message from the Outbox and send to the Relay API
  4. Success: Delete the message from the Outbox
  5. Failure: Retry with exponential backoff (1s, 2s, 4s, 8s...)
  6. Offline: Monitor connectivity with NWPathMonitor, auto-resend when restored

The critical point is that the message is saved to the Outbox before the network send. This means even if the app crashes suddenly or the device loses power, the message is never lost.

Messages stored in the Outbox are protected with AES-GCM encryption using CryptoKit. Encryption keys are stored in the Keychain, inaccessible from outside the app's sandbox.

// OutboxManager.swift
func enqueue(message: OutboxMessage) throws {
    let key = try KeychainHelper.getOrCreateSymmetricKey()
    let sealedBox = try AES.GCM.seal(
        message.plainData,
        using: key
    )
    let encrypted = EncryptedOutboxEntry(
        id: message.id,
        sealedData: sealedBox.combined!,
        createdAt: Date(),
        retryCount: 0
    )
    try persistenceStore.save(encrypted)
}

Furthermore, everything is implemented in-house, resulting in zero external library dependencies. CryptoKit, NWPathMonitor, URLSession -- all Apple-native frameworks. Being free from third-party library version management and security audits is a significant advantage for a small team.

5. Relay API: Breaking Free from Gmail Dependency

Captio originally sent emails through users' Gmail accounts. However, changes in Google's OAuth requirements and API restrictions made this approach unsustainable.

We chose a Cloudflare Workers + Resend API architecture.

Cloudflare Workers is an edge computing platform where code runs at the data center closest to each user. Requests from Japan are processed at Japan's edge, requests from the US at the US edge. This virtually eliminates the cold start problem compared to traditional serverless platforms (AWS Lambda, etc.).

For email delivery, we use the Resend API. Resend is a developer-friendly email API offering high deliverability and reliability.

Email Verification Flow

When users set their destination email address, a 6-digit verification code is sent for email verification. This prevents unauthorized sending to third-party email addresses.

Multi-Layer Rate Limiting

To prevent abuse, we implement multi-layer rate limiting.

// Cloudflare Worker - Rate Limit Configuration
const RATE_LIMITS = {
  devicePerMinute: 30,    // 30 per minute per device
  devicePerDay: 200,      // 200 per day per device
  ipPerHour: 120,         // 120 per hour per IP
  globalPerDay: 300       // 300 per day globally (adjustable)
};

By applying rate limits at three layers -- device, IP, and global -- we can block both single device runaway and botnet attacks.

Idempotency Guarantee

To prevent duplicate sends due to network instability, each message is assigned a UUID, and the Relay API performs duplicate checking. If the same UUID is resent, subsequent attempts return a success response without actually sending the email. Combined with the Outbox pattern's retry mechanism, this ensures users never receive the same memo twice.

6. Today's Work: Eliminating Small Annoyances One by One

Much of Day 1's implementation work was spent redesigning the star rating dialog. App Store ratings are an app's lifeline, but you shouldn't ask for ratings in a way that damages the user experience.

Redesigned Display Conditions

The star rating dialog display conditions were set as follows:

  • Shows every 100 sends: Only displayed to users who have used the app extensively
  • Minimum 14-day interval: At least 14 days must have passed since the last display
  • 30 days after dismiss: If the user dismisses it, it won't reappear for 30 days

Star Icon Tap Area Fix

Apple's Human Interface Guidelines recommend a minimum touch target size of 44x44pt. The star icon tap areas didn't meet this standard, so we fixed it.

// StarRatingView.swift
private func createStarButton() -> UIButton {
    let button = UIButton(type: .custom)
    button.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
        button.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
        button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
    ])
    return button
}

Star Icon Distortion Fix

Star icons were being distorted due to UIStackView's `.fillEqually` distribution. Fixed by setting `contentMode` to `.scaleAspectFit` and changing the StackView distribution to `.equalSpacing`.

Post-Rating Flow

  • 5 stars: Navigate to the App Store review screen (`SKStoreReviewController`)
  • 4 stars and below: Quietly display a "Thank you" toast and close. Don't funnel negative reviews to the App Store.

7. Fixing History Status Display Inconsistencies

In the send history screen (History), I noticed a bug. Some messages showed "sending" status indefinitely even though delivery had already completed.

After investigating, I found the issue in the status update logic. The previous implementation used the message's text content as the key for status updates. However, when the same memo content is sent multiple times, text-based lookup can't identify the correct message.

The fix was straightforward. We changed from text-based lookup to ID-based status updates. Since each message is assigned a UUID when saved to the Outbox, we use this ID to update History status.

// HistoryManager.swift
// Before: Text-based lookup (cause of the bug)
// func updateStatus(forText text: String, status: SendStatus)

// After: ID-based status update
func updateStatus(forMessageId id: UUID, status: SendStatus) {
    guard let index = entries.firstIndex(where: { $0.messageId == id }) else {
        return
    }
    entries[index].status = status
    persistEntries()
}

With this fix, even when sending the same memo content multiple times, each message's status is displayed correctly.

8. An Obsessive Commitment to Privacy

Private information gets written into memo apps. Passwords, personal concerns, business ideas -- we can't predict what users will write. That's exactly why privacy protection should be not just "adequate" but "excessive" -- that level is just right.

Privacy Overlay in App Switcher

iOS's app switcher shows app screens as thumbnails. To eliminate the risk of third parties seeing memo content, we display a privacy overlay when the app transitions to the background.

Ephemeral URLSession

A standard URLSession saves cache, cookies, and authentication data to disk. We use `URLSessionConfiguration.ephemeral` to ensure none of this information persists on disk. This eliminates any risk of memo content remaining as cached data in storage.

Sanitized Logging

Debug logs during development require careful attention. A seemingly harmless practice like "logging the content of sent memos" can become a security hole. Our policy is clear:

  • Memo content is never logged, even in DEBUG builds
  • Error logs use only `type(of: error)`, never `localizedDescription`
  • Reason: `localizedDescription` may contain user input
// NetworkManager.swift
// BAD: Logging error details
// Logger.error("Send failed: \(error.localizedDescription)")

// GOOD: Logging only the error type
Logger.error("Send failed: \(type(of: error))")

It might seem paranoid. But to earn users' trust in a memo app, we believe this level of paranoia is necessary.

9. 10 Languages: Reaching Captio Users Worldwide

Captio had users worldwide, centered around English-speaking countries. To reach those former users with "Simple Memo (Captio-style)," we supported 10 languages from the initial release.

Supported languages: Japanese, English, Spanish, French, German, Italian, Portuguese, Korean, Chinese (Simplified), and Arabic.

The technical challenge of Arabic RTL (Right-to-Left) support was particularly notable. Not just text direction, but the entire UI layout needs to be mirrored. By consistently using `leading`/`trailing` constraints in Auto Layout, we achieve a natural layout in RTL environments.

In-App Language Switching

In addition to iOS 16+'s built-in per-app language settings, we implemented our own language switching mechanism. When the language changes, the ViewController is replaced with a `cross-dissolve` transition for a smooth switching experience.

// LanguageManager.swift
func applyLanguageChange() {
    guard let window = UIApplication.shared.connectedScenes
        .compactMap({ $0 as? UIWindowScene })
        .first?.windows.first else { return }

    let newVC = ComposeViewController()
    newVC.view.frame = window.bounds
    UIView.transition(with: window,
                      duration: 0.3,
                      options: .transitionCrossDissolve,
                      animations: {
        window.rootViewController = newVC
    })
}

10. Today's Lesson: Product Design Is About Trust

Finishing Day 1, what strikes me is that the essence of this product is designing for trust.

Let me summarize the technical metrics:

  • Time-to-Text: Under 500ms (measured 200-300ms)
  • Send-to-Reset: Under 150ms
  • Message loss: Zero (Outbox pattern)
  • Encryption: AES-GCM (CryptoKit)
  • External library dependencies: Zero

But all these metrics converge on a single goal: trust.

Fast launch: the trust that "I can always write immediately."
Send and clear: the trust that "it's been processed."
Zero message loss: the trust that "it will absolutely be delivered."
Encryption: the trust that "nobody can see it."

Captio wasn't loved because its features were superior. It was loved because it had meticulously polished an experience you could use with complete trust. That's what we want to carry forward.

11. Day 2 Preview

In Day 2, we plan to tackle the following themes:

  • Send animation refinement: Moving beyond the current 0.25s fade-out to a more pleasant animation
  • History status update timing optimization: Comparing real-time updates vs. batch updates
  • Is the success toast even needed?: Considering the option of "not showing" any send success feedback

Aiming for Captio's "nothing there" decisiveness. On to Day 2.

参考文献 References