電子帳簿リネーマー開発日記: Security-Scoped Bookmarks実装で「macOSアプリらしさ」を獲得した日

Flutter desktopで開発中の電子帳簿リネーマー、今日も大幅に前進しました。
リリースに向けた最大の関門と言える Security-Scoped Bookmarks を実装できたのが、本日の最大の成果です。

目次

今日やったこと(7コミット分)

  • ✅ 警告フラグ機能(🔴🟡💡)を追加
  • ✅ PDFテキストパースの大幅改善(NFKC正規化, Apple Japan対応など)
  • ✅ Security-Scoped Bookmarksの実装(macos_secure_bookmarksパッケージ)
  • ✅ サンドボックスを再有効化
  • ✅ 二重押下防止
  • ✅ PDFium WASMモジュールの除去(ビルドサイズ4MB削減)
  • ✅ UI改善(取引先候補ボタンの日本語ラベル化)

1. 警告フラグ機能で「直感的な誘導」

PDFのテキスト抽出が失敗するケース(楽天モバイルの請求書など)では、ユーザーに手入力をお願いする設計にしています。ただ、「手入力できる」というだけでは不十分で、 「どこを手入力すべきか」 をユーザーに伝える必要がある。

そこで導入したのが警告フラグ機能です。

記号状態表示
🔴日付・金額が空赤枠 + 「日付を入力してください」
🟡金額が0オレンジ枠(ポイント購入想定、確定可能)
💡取引先が空青枠 + 「取引先を入力してください」

判定は テキストフィールドの現在値 で行うので、ユーザーが手入力するとフラグが即座に消えます。これがユーザーに「入力できた」というフィードバックを返してくれる。

設計時のひらめきとして、 「警告フラグはユーザーへの誘導サインである」 という捉え方ができました。「ここが空ですよ」と直接書くより、🔴 という視覚記号で「ここ要チェック」と伝える方がUIとして格段に分かりやすい。

2. PDFテキストパースの罠: Apple Japanの「⾦」問題

開発中、AppleのPDFで「ご請求金額」がどうしても認識されない事象が発生しました。コードを何度見直しても問題ない。

そこで、PDFテキストの文字コードを1つずつ出力するデバッグコードを仕込んだら、衝撃の事実が判明。

[6] "⾦" U+2FA6   ← これ!

普通の 金 (U+91D1) ではなく、 ⾦ (U+2FA6, CJK部首補助) という超マイナーな文字が混入していました。見た目はそっくりだけど、コンピュータ的には完全に別の文字。

Apple JapanのPDFジェネレーターが、フォントマッピングの関係で部首字形を出力してしまっているようです。世界中のApple領収書パース勢が同じ罠にハマっているはず。

解決策: NFKC正規化

Dartの unorm_dart パッケージを導入し、PDFテキスト抽出後にNFKC正規化をかけることで一発解決。

final fullText = unorm.nfkc(buffer.toString());

NFKCは「見た目同じだけど別文字」を統一する仕様。 ⾦ → 金 はもちろん、全角数字 → 半角数字なども一括で正規化してくれます。副次的に金額抽出も安定するという嬉しいおまけ付き。

学び: PDFのテキスト抽出は「見た目で判断するな、文字コードを見ろ」。

3. Security-Scoped Bookmarks実装 (今日の本丸)

これが今日の最大の成果です。

問題の発端

開発中はサンドボックスをOFFにして動かしていたのですが、ある日 Operation not permitted, errno = 1 のエラーが出るようになりました。

これは macOS の TCC (Transparency, Consent, and Control) という権限制御の仕組みが、デスクトップフォルダへのアクセスをブロックしている状態。サンドボックスOFFでもTCCは動いているのです。

解決の仕組み: Security-Scoped Bookmarks

macOSアプリで「ユーザーが選んだフォルダへの永続的なアクセス権」を得るには、 Security-Scoped Bookmarks (SSB) という仕組みを使う必要があります。

仕組みを簡単に説明すると:

  1. ユーザーがフォルダ選択ダイアログで保存先を選ぶ
  2. macOSが「security-scoped URL」というアクセス権を発行
  3. これを bookmark というバイナリにシリアライズして保存
  4. アプリ起動時にbookmarkを復元して、 startAccessingSecurityScopedResource() を呼ぶ
  5. これでサンドボックス内でもフォルダにアクセスできる

つまり 「一度許可してもらった権利をデータとして保存し、起動のたびに再利用する」 仕組み。

実装にあたって

最初は「Method Channel書いてSwiftでNSURL.bookmarkDataWithOptions:を呼ぶしかないか…」と覚悟していましたが、調査したら macos_secure_bookmarks という Flutterパッケージがありました。少し古いパッケージ(2022年9月最終更新)ですが、APIがシンプルで既存のFileオペレーションをそのまま使えるのが魅力。

// 保存
final bookmark = await secureBookmarks.bookmark(directory);
await prefs.setString('save_directory_bookmark', bookmark);

// 復元
final dir = await secureBookmarks.resolveBookmark(savedBookmark);
await secureBookmarks.startAccessingSecurityScopedResource(dir);
try {
  // ... File操作はそのまま ...
} finally {
  await secureBookmarks.stopAccessingSecurityScopedResource(dir);
}

try-finally パターンで必ず stopAccessing を呼ぶのが鉄則。これを忘れるとリソースリークが起きます。

サンドボックスON化

entitlementsファイルに以下を設定:

<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>

サンドボックスON化後、初回は 保存先フォルダを選び直す必要があります。サンドボックス内のデータ領域が新しくなり、過去のbookmarkが失効するため。これは想定挙動。

動作確認

flutter: Bookmark復元: (972文字)
flutter: アクセス開始: true
flutter: リネーム成功: .../2024/05/20240506_81800_Apple.pdf
flutter: アクセス終了

このログが出た瞬間は、本当に嬉しかった。長年「やらなきゃ」と思っていたサンドボックス対応を、ついに突破した瞬間です。

4. 二重押下防止のシンプルな対策

「この名前で確定する」を連打すると、1回目で移動したファイルを2回目で探そうとして No such file or directory エラーが出る問題が発生。

対策はシンプルで、確定ボタンを押した瞬間に _currentPdfPath を空にする:

Future<void> _confirmRename() async {
  if (_currentPdfPath.isEmpty) {
    return;  // 二重押下防止
  }

  final currentPdfPath = _currentPdfPath;
  setState(() {
    _currentPdfPath = '';  // 即座にUIを無効化
  });

  // ... 以降の処理 ...
}

_currentPdfPath が空になると確定ボタンが無効化されるので、物理的に連打不可能になります。

5. PDFium WASM除去でビルドサイズ4MB削減

pdfrxパッケージはデフォルトでWeb版用のWASMモジュールも含まれており、macOSデスクトップビルドには不要なファイルが約4MB含まれていました。

解決はワンコマンド:

dart run pdfrx:remove_wasm_modules

これでpdfrxパッケージ内の pubspec.yaml の - assets/ 行が自動でコメントアウトされます。気持ちいいくらいシンプル。

地味だけどリリース時のサイズ削減は重要ですね。起動も少し速くなるかもしれません。

6. UI改善: 「日本語UIに振り切る」決断

取引先入力欄の右にあった▼アイコンだけのボタンは、初見では何のボタンか分かりにくかった。tooltipで「候補から選ぶ」とは出るけど、ホバーしないと見えない。

「このアプリは日本以外では使えないので、日本語UIに振り切ろう」と決めて、 「☰ 候補 ▼」 という分かりやすいラベル付きボタンに変更。

PopupMenuButton<String>(
  tooltip: '履歴や内蔵候補から選ぶ',
  child: Container(
    // ...
    child: const Row(
      children: [
        Icon(Icons.list, size: 18),
        SizedBox(width: 4),
        Text('候補', style: TextStyle(fontSize: 13)),
        Icon(Icons.arrow_drop_down, size: 18),
      ],
    ),
  ),
  // ...
);

「グローバル化のために英語も用意しよう」と中途半端に対応するより、 ターゲット読者(日本の個人事業主)に最適化する 方が圧倒的に分かりやすい。潔く振り切る判断、大事です。

まとめ: リリースまであと一息

今日の進捗で、リリースまでの距離が大きく縮まりました。

完了残り
✅ 警告フラグ機能⬜ アプリアイコン(夜にCanvaで作成中)
✅ Security-Scoped Bookmarks⬜ スプラッシュ画面
✅ サンドボックスON⬜ Lemonsqueezy連携(無料版25ファイル制限)
✅ PDFium WASM除去⬜ 価格設定(買い切り2,980円)
✅ UI改善⬜ Mac公証(Notarization)
⬜ 販売ページ作成

今日の最大の収穫は、SSB対応もそうですが、 「PDFのテキスト抽出は文字コードレベルで違うことがある」 という学び。これは他のFlutter desktop開発者にも参考になるはず。

明日は外貨建てPDFや税金関係のPDF対応を見ながら、引き続きリリース準備を進めます。


技術スタック今日のハイライト

  • macos_secure_bookmarks ^0.5.0
  • unorm_dart ^0.3.0
  • pdfrx remove_wasm_modules コマンド
  • macOS entitlements com.apple.security.files.bookmarks.app-scope

関連リンク

目次