ふみにわ開発記

programming
2026
swift
novel
Author

Serika Yuzuki

Published

August 9, 2026

2026年8月9日時点の開発記録。

mainはPR #65まで反映済み。AI関連の一部は作業ブランチ上にあって、実際のCodex/OpenRouter接続や公開用ビルドはまだ終わっていない。

「日本語で小説を書くための、静かで使いやすいmacOSアプリが欲しい」

ふみにわ(FUMINIWA)は、もともとそれくらいのところから始まった。

俺自身が小説を書くので、だったら自分が欲しいやつを作ればいいじゃん、というかなり単純な話だったんだけど、作っていくと当然そんな簡単には終わらなかった。

本文が入力できれば小説エディタなのかというと、全然そんなことはない。

日本語IMEを壊さず扱えるのか。自動保存って言ってるけど、本当に保存されてるのか。壊れた原稿を開いたとき、何食わぬ顔で空の新規作品を表示したりしないか。まだ実装していない機能を、それっぽいUIだけ置いて「使えそう」に見せていないか。AIを入れるなら、数秒前の校正結果を別の原稿にぶち込んだりしないか。

作れば作るほど、俺が欲しかったのは「機能がいっぱいある小説エディタ」ではなくて、原稿を安心して預けられる道具なんじゃないか、という方向に変わっていった。

というわけで、これはふみにわで何を作ったかだけじゃなく、何を間違えて、何を捨てて、どうして今の形になったのか、その辺をまとめた開発記録。

最初に作ったもの

最初の頃に気にしていたのは、機能をどれだけ積むかより、あとから自分で地雷原を作らないことだった。

UI全体はSwiftUI。ただし、一番大事な本文エディタだけはSwiftUIのTextEditorを使わず、AppKitのNSTextViewとTextKit 2を使っている。

理由は日本語IME。

ふみにわの当時の構成では、SwiftUIの状態と本文を素直に双方向Bindingすると、IMEで変換している最中に外から文字列が書き戻されて、入力が巻き戻ることがあった。日本語を書くアプリで日本語入力が怪しいのは、さすがに笑えない。

なので、編集中の本文についてはNSTextView側を正とした。モデルから本文を流し込むのは話を切り替えたときだけで、編集中にモデル側から勝手に本文へ触らない。

要するに、「この文字列、結局誰が持ってんの?」を曖昧にしないというルールを最初に作った。

保存形式も同じ発想で、巨大なJSON一個に全部詰め込むのではなく、.novelpkgというフォルダパッケージ形式にした。

本文、メモ、添付資料、メタデータは分ける。本文のファイル名も「第1話」「第2話」みたいな並び順依存にはせず、UUIDを使う。話を並べ替えただけで大量のファイル名が変わるとか、絶対あとで嫌なことになるからね。

章順と話順についても、配列だけを正にして別のorderフィールドは持たせなかった。順序を表す情報が二つあったら、いつか必ずズレる。俺はそういう「今は一致してるからヨシ!」を全く信用していない。

基盤ができてからは結構速かった。章と話の管理、本文編集、検索、話メモ、キャラクター、プロット、伏線、資料、スナップショットと、かなりの勢いで機能が増えた。

この辺はAIコーディングとの相性もよくて、仕様を決めて実装してテストして、というサイクルを高速で回せた。

で、当然ちょっと得意げになるわけよ。

これもう結構できてるんじゃね? と。

あとから考えると、機能が揃っていることと、製品として信用できることを、この頃はまだごっちゃにしていた。

UIを一回捨てた

UIについても、かなりデカい手戻りをやっている。

最初の大規模刷新では、画面を「執筆」「キャラクター」「プロット」の3モードに分けていた。キャラクターはシート形式、プロットは章レーン式のカードボード。それぞれ専用の広い画面で編集する。

実装としては普通に完成した。

完成したんだけど、実際に使ってみるとなんか違う。

小説を書いてるときって、本文だけ見てるわけじゃないんだよね。「あれ、こいつ何歳だっけ」で人物を見るし、「この伏線どこだっけ」でプロットを見るし、「そもそもこの章で何やるんだっけ」でアウトラインを見る。

なのに、そのたびにモードを切り替えると、「今ここの文章を書いていた」という文脈が毎回ぶった切られる。

これ嫌だな、と。

ということで、せっかく完成した3モード制を捨てた。そこからProject Sidebar、Outline、Editorを中心にしたWorkbench形式へ作り直した。

ところが、これも一発では決まらない。

Outlineを統一したときには、操作としてはきれいになったんだけど、今度は画面全体が妙に不透明で重くなった。俺が欲しかったmacOSっぽい透過感というか、静かな感じとは逆方向に行っていた。

なのでまた直す。

Glass UIをいじって、入力余白を変えて、2列と3列を行ったり来たりして、ツールバーの位置を変えて、章と話の階層も変えて、作品情報画面も作り直した。

完成したコードを捨てるのは嫌だった。でも「せっかく作ったし」で残す方がもっと嫌だったので、捨てた。

日本語IMEで詰まった

自動字下げでも、かなり象徴的なのがあった。

小説だと段落の頭に全角スペースを入れるので、改行したときに自動で一字下げする機能を入れている。ただし、

「こんにちは」

みたいな会話文には頭のスペースがいらない。

そこで、行頭に全角スペースがある状態でを入力したら、そのスペースを消す処理を書いた。直接を入力すると動く。

ヨシ。

で、日本語IMEから入力すると動かない。

なんでだよ。

原因はIMEGuardPluginだった。

IME変換中に余計な処理を走らせると日本語入力そのものを壊すので、「変換中は後続処理に触らせない」というガードを入れていた。これがあまりにも真面目に仕事をして、字下げ解除まで止めていた。

IMEを守る処理は正しい。字下げを消す処理も正しい。組み合わせると動かない。そういうやつ。

最終的には、「IME変換中は触らない」という原則は維持したまま、変換確定後のtextDidChangeで行頭スペースを取り除くようにした。Undoしたらちゃんと戻るよう、通常の編集経路も通している。

傍点記法も似たようなもので、最初は《《対象文字列》》を採用していた。これは特定の投稿サイトでは使えるんだけど、サービスによっては互換性がない。そこで、1文字ずつ|字《・》へ変換する方式に変えた。

こういうのは、デモ動画で30秒触るだけならまず気にならない。でも毎日何千文字も日本語を書く道具だと、こういうクソ細かいところがそのまま使い心地になる。

EPUBより先に保存を直した

Workbenchが一通りできた時点では、そのままTXTとかEPUBとか、出力機能を増やしていく予定だった。

でも一回プロジェクト全体を見直したときに、いや、出力増やしてる場合か? となった。

当時、自動保存はあった。でも保存に失敗してもユーザーにはかなり分かりにくい。作品の新規作成、作品を開く、別名保存みたいな、「エディタなら普通にあるだろ」というファイル操作もまだ微妙。

この状態でEPUB出力を豪華にして何になるんだ、と。

そこでロードマップを一旦止めて、Phase 4.5という安定化フェーズをねじ込んだ。保存状態は「未保存」「保存中」「保存済み」「保存失敗」の4つを持たせて、失敗したら再試行できるようにした。

作品を切り替えるときも、先に表示だけ切り替えるのではなく、候補作品の読み込みと現在作品の保存が両方成功してから入れ替える。スナップショットを復元するときは、復元前の状態を一回別のスナップショットとして退避する。「昔の状態へ戻そうとしたら今の原稿まで消えました」は最悪なので。

さらに保存処理を調べたら、競合もあった。

保存中に新しい編集が入ったとき、昔の実装だと「保存成功」という結果だけ返って、実は直後の変更が保存されていない、という隙間があった。そこで保存要求をrevision単位で直列化して、dirtyな変更がなくなるまで保存担当が回り続ける形に変えた。

テスト環境も一発では生えなかった。最初のNovelAppTestsは、XcodeGen側のscheme、TEST_HOSTBUNDLE_LOADERが足りなくて死亡。そこを直したら今度はSwiftFormatのimport順でローカルチェックが死ぬ。

さらにコードを直したあと、設計文書を見ると、もう存在しないAppModeの説明が残っている。

コードは新しい。文書は古い。はい文書ドリフト。

この辺から、設計書とかDecision Logも「暇なときに更新する説明書」ではなく、コードと一緒に変更するものとして扱うようになった。

性能については、先回りして保存形式を最適化する前に測った。当時の開発環境で、1MBの本文、100MBの添付、20個のスナップショットを持つパッケージを測ると、保存時間は約0.039秒だった。

じゃあ今直す必要ないじゃん。

将来遅くなるかもしれないからと先に複雑にするのはやめて、実際に遅くなったら、もう一度測って直すことにした。そのあと、書き出しはTXT/Markdown/EPUB 3まで実装した。

macOSファースト

途中からWindows版も視野に入れ始めた。

ただし、SwiftUIのコードを無理やりWindowsでも使えるようにするとかはやらない。Windows側はWinUI 3+C#/.NETで別に作る予定。

共有するのは、.novelpkgのschema、UUIDや順序の意味、入出力fixture、保存トランザクションの考え方。コードではなく、原稿を扱うための契約を共有する。

UIまで無理に共通化すると、macOSとWindowsで違うIME、Undo、ファイル操作、デザインの作法まで無理に揃えることになる。多分それ、一番誰も幸せにならないやつ。

だから、「同じコードが動く」より「同じ原稿を安全に往復できる」を優先することにした。

もちろんWindows版がもうあるわけではない。2026年8月9日時点では、言語非依存schemaとgolden fixtureを固定するW0がまだ終わっていなくて、本実装はそのあと。

商業化できるか見直した

一度、ふみにわを「俺しか使わないアプリ」ではなく、「これを知らない人へ渡して金を取れるか」という目線でかなり徹底的に監査した。

出てきた致命的な問題は、「AI機能がない」とか「PDF出力できない」とかではなかった。もっと地味で、もっと嫌なやつ。

起動直後、読み込みを待たずに編集可能な仮作品を出していた。なので起動してすぐユーザーが文章を書き始めると、そのあと読み込み終わった前回作品が表示され、今書いた文章を上書きする可能性があった。

怖すぎる。

作品の読み込みに失敗したら、空の新規作品へ黙ってfallbackするコードもあった。本文ファイルが壊れていたり、不正なUTF-8だったりしても、「読めなかったので空文字です」ということにして処理を続ける場所まであった。

いやいやいや。

エディタが「原稿を読めませんでした」を「原稿は空でした」に変換したらダメだろ。

他にも、外部変更、孤児化した本文、署名、公証、AppIcon、アクセシビリティ、未接続AIの常設表示とか、まあ色々出た。

ここで、ふみにわの中心って結局何なんだろう、とかなり考えた。たどり着いたのは、昨日の続きを昨日の場所から始められることと、異常が起きたら黙って空にせず、救出できる状態で止まることだった。

起動処理はLoadingReadyRecoveryの3状態に分けた。作品を安全に読み込めるまで、編集可能なWorkbench自体を出さない。

前回開いていた作品が壊れているとか、Finderから渡された作品が読めないとか、そういうときも勝手に新規作品へfallbackしない。Recovery画面で止めて、「再試行」「Finderで表示」「別作品を選択」「新規作品を作る」のどれかをユーザー自身に選んでもらう。

本文が読めないなら、空文字として開かない。小説アプリなら「開けました! 中身は空です!」より、「すみません、これ開けません」の方が百倍マシだろ。

作品ライフサイクルをさらにレビューしていたら、もっと厄介な競合も出てきた。

Swiftには@MainActorがあるので、一見すると状態変更は一個ずつ処理される。じゃあ安全じゃん、と思うんだけど、awaitが入ると話が変わる。ファイルI/Oを待っている間に別の操作が入れる。

例えば、作品Aのスナップショットを復元する。途中でファイルI/Oをawaitする。その間にFinderから作品Bを開く。そのあと、Aの復元処理が再開する。

そこで「現在の保存先」を動的に読み直すと、もう現在の作品はBになっている。結果、AのスナップショットをBへ書き戻す可能性がある。

最悪である。

起動処理にも似た競合があった。最初の読み込みが遅いと、その途中でFinderから別作品を開いても、あとから終わった起動処理が古い作品を再表示する可能性があった。

そこで、作品操作全体をFIFOのgateで直列化した。さらに各操作について、開始した瞬間の作品ID、URL、generationをsession tokenとして固定する。待っている間に作品が変わっていたら、その操作は保存層へ触る前に拒否する。

作品を切り替える前には、日本語IMEの未確定文字も旧作品側へ確定して保存する。終了処理が始まったら新しい作品操作は受けない。lockの取得順も固定した。

同時に触らせなければ大丈夫、ではなかった。その処理がどの作品に対して始まったのかまで固定しないといけない。

非同期処理、怖いね。

改名で古いprojectを踏んだ

途中で製品名をNovelWriterから「ふみにわ/FUMINIWA」に変えた。

画面に出ているNovelWriterを全部FUMINIWAにreplaceすれば終わり!

なわけがない。

旧bundle domainには「最近開いた作品」とか、エディタ設定とかが保存されている。そこまで雑に変えたら、アップデートした瞬間、新しいアプリからは設定も履歴も消えたように見える。

なので移行対象をallowlistで限定して、一回だけ旧domainから移す。新しい値がもうあるなら上書きしない。旧作品や旧フォルダも勝手に移動したり削除したりしない。

.novelpkgの内部形式、NovelKitみたいなドメイン名、toolbarの永続IDなんかも、名前が古いからといって変えず、互換資産として残した。

さらに改名直後、訳の分からないビルドエラーも出た。

現行のFUMINIWA.xcodeprojは普通にビルドできる。なのにXcodeが「NovelWriterApp.swiftがありません」とか言ってくる。

そりゃないよ。消したから。

調べたら、Xcodeが古いNovelWriter.xcodeprojを開いていた。コードではなく、古い生成物が原因だった。

その後、XcodeGenの共通スクリプトから現行projectを生成して、古いやつは退避するようにした。生成物を正として扱わないことの大事さを、身をもって知った瞬間だった。

使えないAIは一旦消した

昔のWorkbenchには、下の方にAI Assistant Panelがあった。入力欄があって、AIの状態が見えて、ショートカットまである。見た目は結構それっぽい。

問題は、AI providerも通信処理も存在していなかったこと。

つまり、ただのハリボテ。

開発者側からすると「ここに将来AIを入れるからね」というプレースホルダなんだけど、使う側からしたらそんなことは知らない。ボタンがあれば使えると思う。

しかも小説エディタにAI欄が常設されていたら、「え、俺の本文どこかへ送られてる?」という不安まで生まれる。何もしていないのに。

これはよくないなと思って、未実装AIの入口は通常版から全部消した。この辺はProduct Truthという名前でルール化した。本当に存在する機能だけUIに出して、まだできないものを未来感だけで先に置かない。

以下は、2026年8月9日時点でAI作業ブランチにあった仕組みの話。

実際の外部送信はまだつないでいない。その前に、providerへ渡す範囲をかなり意図的に小さくした。まずは選択範囲の校正だけで、ユーザーが明示的に選んだ本文しか渡さない。作品名も章名も人物情報もプロットもローカルファイルも、「役に立ちそうだから」で勝手に追加しない。

送信前には、実際に送るpromptとresponse schemaをそのままpreviewする。確認したあとに本文やproviderが変わったら、その確認は無効。同じ確認を使って二回送信することもできない。

返ってきた結果も自動適用しない。原文との差分を出して、ユーザーがApplyしたときだけ、EditorKitの通常編集経路を通して置換する。Undo一回で戻せる。

AIへ送信して待っている間に、作品、話、エディタ、選択範囲、元の文章のどれかが変わったら、その結果はstale扱いで適用を拒否する。「同じ文章が見つかったから多分ここだろ」で、別の場所へ勝手に当て直したりもしない。

この一連の流れをfake providerでテストしてから、実providerをつなぐ予定だった。

通常版のFUMINIWAと個人実験用のFUMINIWAExperimentalも、別target、別bundle ID、別保存rootに分けた。少なくともこの時点の通常版targetのbuild graphには、NovelAI、AI UI、provider依存、sidecar artifactを含めていない。設定で隠すだけではなく、通常版の実行経路から分けている。

実providerの第一候補はCodex TypeScript SDK。ただ、Swiftから直接使えるSDKではないので、Node sidecarを挟む必要がある。

まあ、そこはいい。

調べていくと分かったのは、俺が欲しかった安全側の保証が意外と簡単には取れないことだった。

8月9日時点の調査では、toolを完全に無効化できる保証、ephemeral threadの保証、上流側でのoutput token capなんかを、公開APIだけでは確認できなかった。

FUMINIWA側で結果をmemory onlyにしても、providerやSDK側まで本当に何も残らないとは限らない。ローカル側でbyte数や実行時間を制限しても、それはAPI料金の上限ではない。

ここで一番やっちゃいけないのが、「多分保存されないだろう」からの、UIに「保存なし」。

分からないなら「未保証」。ない保証をこっちで勝手に生やさない。

なので、次にやることも「とりあえずAPIを叩いて返事が来た! やった!」ではなくなった。

先にやるのは、SDK/CLI/Nodeのversion、path、hashを固定して、専用の空cwdとCODEX_HOMEで動かすところから。親のenvironmentも丸ごと渡さず、認証情報はKeychainへ置く。cancel/timeout後にprocessが残らないか、local artifactが生えないか、OSレベルの隔離が効くかも実測する。

ここまでやって、ようやくExperimental UIへ実Codex adapterをつなぐ。そのあとOpenRouterも、独立したHTTP adapterとして追加する予定。

ちなみに、Codexが失敗したら勝手にOpenRouterへfallback、みたいなのもやらない。送信先が変わるなら、それはもう別の確認だから。ユーザーに見せて、もう一回previewしてもらう。

8月9日時点で、どこまでできたか

macOSで小説を書くための主要なところは一通り動いている。日本語IME対応の本文エディタ、章と話の管理、自動保存、検索、スナップショット、人物、プロット、伏線、資料、世界観ノート、TXT/Markdown/EPUB書き出し。「俺が小説を書く」だけなら、かなり普通に使えるところまで来た。

ただ、一般公開できるかと言われたら、まだだと思っている。

個人用AIは、まずこのCodex sidecarの隔離実証から。そのあとOpenRouter adapterへ進む。

公開Releaseでは、まずPackage Validatorを作りたい。重複ID、不正参照、symlink、巨大パッケージ、孤児化した本文なんかを検出する。問題があったときも、元の作品を直接書き換えて「直しました!」ではなく、修復コピーとして救出する。

その次がConflict Gate。Finderで作品を移動したり消したり、同期サービスが触ったり、別プロセスがファイルを書き換えたりしたとき、それを検出する。

あとはAppIcon、Developer ID署名、公証、更新機構、別MacでのGatekeeper確認、VoiceOver、Light/Dark、Reduce Transparency、長文や大量データでの実機QA。まだ普通にいっぱいある。

Windows版、PDF、iOS/iPadOSも、8月9日時点ではまだ未実装だった。

なので、できていないものはできていない。「もうほぼ完成です!」みたいに書こうと思えば書けるんだけど、それをやらないこと自体、8月9日時点のふみにわでは結構大事だと思っていた。

ふみにわはまだ完成していない。というか、作れば作るほど「完成ってなんだ?」という感じにはなっている。

ただ、最初より何を作りたいのかはかなりはっきりした。

作者の文章を勝手に消さない。勝手に外へ送らない。AIが勝手に書き換えない。何かおかしくなったら、正常なふりをして処理を続けず、ちゃんと止まる。

そして次の日にアプリを開いたとき、昨日書いていた場所へそのまま戻れる。

こう書くとめちゃくちゃ当たり前なんだけど、小説を書く道具なら、結局その当たり前が一番大事なんじゃないかと思っている。

少なくとも、この時点でふみにわが目指していたのはそこ。

Back to top