ふみにわ開発記
2026年8月9日時点の開発記録。
mainはPR #65まで反映済み。AI関連の一部は現在の作業ブランチ上にあり、実際のCodex/OpenRouter接続や公開用ビルドは未完成である。
「日本語で小説を書くための、静かで使いやすいmacOSアプリを作りたい」
ふみにわ(FUMINIWA)は、そんな比較的素朴な目標から始まった。
ところが、実際に開発を進めると、問題は「本文を入力できるか」では終わらなかった。
日本語IMEを壊さず扱えるか。
自動保存は、本当に作者を守っているか。
開けない原稿を、空の新規作品に見せかけていないか。
便利そうな未実装機能を、あたかも使えるように表示していないか。
AIを接続したとき、古い校正結果を別の原稿へ誤適用しないか。
開発が進むほど、目指すものは「機能の多い小説エディタ」から、「原稿を安心して預けられる道具」へ変わっていった。
これは、その過程で何を作り、何を間違え、何を捨て、これからどこへ進もうとしているのかをまとめた記録である。
最初に決めたのは、機能ではなく境界だった
開発初期に最も重視したのは、画面の見た目や機能数ではなく、あとから壊れにくい構造だった。
アプリ全体のUIにはSwiftUIを使う。ただし、本文エディタにはSwiftUIのTextEditorを使わず、AppKitのNSTextViewとTextKit 2を採用した。
理由は日本語IMEである。
SwiftUIの状態更新と本文を素朴な双方向Bindingで結ぶと、IME変換中に外側から文字列が書き戻され、入力が巻き戻ることがある。そのため、編集中の本文の正はNSTextView側に置き、モデルから本文を流し込むのは話を切り替えたときだけ、という「テキスト所有権ルール」を最初に定めた。
保存形式には、単一の巨大なJSONではなく、フォルダパッケージ形式の.novelpkgを採用した。
本文、メモ、添付資料、メタデータを分けて保持し、本文ファイル名には並び順ではなくUUIDを使う。章や話を並べ替えても、ファイルを一斉にリネームしなくて済むからだ。
章順と話順は配列だけを正とし、別のorderフィールドは持たない。二つの順序情報を持てば、いつか必ず食い違う。
この頃から、ふみにわの設計には一つの傾向があった。
「便利そうな情報を増やす」よりも、「正が二つ存在する状態を作らない」ことを優先していた。
機能は、驚くほど速く増えていった
基盤を作ったあと、開発はかなり速い速度で進んだ。
章と話の管理、本文編集、自動字下げ、検索、文字数表示、話メモ、キャラクター管理、登場箇所へのジャンプ、プロットカード、伏線管理、資料添付、世界観ノート、スナップショット保存と復元。
さらに、作品の新規作成、既存作品を開く、別名保存、Finder表示、TXT/Markdown/EPUB 3への書き出しまで実装した。
Phase 0からPhase 5までを通して、「小説を書くために必要そうな機能」は急速に揃っていった。
しかし、ここで最初の大きな認識違いが見えてきた。
機能が揃っていることと、製品として信頼できることは別だった。
一度完成させたUIを、いったん捨てた
UIでも大きな手戻りがあった。
最初の刷新では、画面を「執筆」「キャラクター」「プロット」の3モードに分けた。キャラクターはシート形式、プロットは章レーン式のカードボードにし、それぞれを広い画面で扱えるようにした。
このUIは、実装としては完成した。
それでも、使い方を見直すと問題があった。
小説を書いている最中には、本文だけでなく、章構造、人物、プロット、資料を行き来する。モードを切り替えるたびに、今どこを書いているのかという文脈が途切れてしまう。
そこで、完成済みの3モード制を捨て、Project Sidebar、Outline、Editorを中心とするWorkbenchへ作り直した。
ところが、これで終わりではなかった。
Outlineを統一しようとした修正では、意図していたmacOSらしい透過感とは反対に、画面全体を不透明で重い方向へ寄せてしまった。操作の整合性は上がったが、見た目の方向が違っていた。
そのため、Glass UI、入力余白、2列/3列構成、ツールバー配置、章と話の階層、作品情報画面などを何度も再設計した。
この過程で学んだのは、「完成したコードを残すこと」よりも、「違う方向へ進んだと認めること」の方が重要だということだった。
UIの作り直しは失敗の隠蔽ではない。実際に使った結果、理解が更新された証拠でもある。
日本語入力は、仕様書どおりには動かなかった
自動字下げにも、象徴的な失敗があった。
当初は、行頭に全角スペースがある状態で「を入力したら、そのスペースを削除するルールを用意していた。直接入力では動作した。
しかし、日本語IMEから「を入力すると動かなかった。
原因は、IME変換中の安全を守るために置いたIMEGuardPluginだった。変換中は後続処理へ介入させない設計が正しく働き、字下げ解除の処理まで止めていたのである。
つまり、個々の機能はそれぞれ正しくても、組み合わせると期待した動作にならなかった。
修正では、IME変換中には触らない原則を維持したまま、変換確定後のtextDidChangeで行頭スペースを取り除く処理を追加した。もちろん、Undoで元に戻せる通常の編集経路を通している。
傍点記法でも変更があった。
最初に採用した《《対象文字列》》は特定の投稿サイトでは使えるが、他の主要サービスでは通用しない。そこで、1文字ずつ|字《・》へ変換する方式へ変更した。
こうした問題は、デモでは目立ちにくい。
しかし、日本語で毎日何千文字も書く道具では、こういう細部が製品の本体になる。
出力へ進む直前に、ロードマップを止めた
Workbenchが完成した時点では、次にTXTやEPUBなどの書き出し機能へ進む予定だった。
だが、プロジェクト全体を見直した結果、その順番は危険だと判断した。
当時は、保存に失敗しても利用者が気づきにくく、新規作成、作品を開く、別名保存といった通常のファイル操作も十分に揃っていなかった。
そこで、出力の前にPhase 4.5という安定化フェーズを挿入した。
保存状態を「未保存」「保存中」「保存済み」「保存失敗」として表示し、失敗時には再試行できるようにした。作品切り替えでは、候補作品の読み込みと現在作品の保存が両方成功してから状態を入れ替えるようにした。
スナップショット復元では、復元前の現在状態を先に別スナップショットへ退避するようにした。復元が失敗しても、現在の作品を維持するためである。
保存処理そのものにも競合が見つかった。
保存中に新しい変更が入ったとき、古い実装では変更が保存されていないのに成功が返る隙間があった。保存要求をrevision単位で直列化し、dirtyな変更がなくなるまで保存担当が処理を続ける形へ修正した。
テスト環境の追加も一度では成功しなかった。
最初のNovelAppTestsは、XcodeGen側のscheme、TEST_HOST、BUNDLE_LOADERが不足して失敗した。その後はSwiftFormatのimport順でもローカルチェックが止まった。コードを直したあと、設計文書に削除済みのAppModeが残るという文書ドリフトも起きた。
この経験から、ふみにわでは設計書やDecision Logを単なる説明資料ではなく、実装と同時に更新する成果物として扱うようになった。
性能については、先回りして保存形式を書き換えることを避けた。
1MBの本文、100MBの添付、20個のスナップショットを持つ代表パッケージで測定したところ、APFS上の保存時間は当時約0.039秒だった。想像で最適化するのではなく、予算を超えてから直す方針を選んだ。
macOS専用から、macOSファーストへ
途中で、Windows版も視野に入れる方針が加わった。
ただし、SwiftUIやAppKitのコードをWindowsへ無理に共有することはしない。
共有するのは.novelpkgのschema、UUIDや順序の意味、入出力fixture、保存トランザクションの考え方である。Windows側はWinUI 3とC#/.NETで、同じ契約を別実装する。
UIまで共通化すれば、各OSのIME、Undo、ファイル操作、デザイン慣習を損なう。
コードを共有することより、同じ原稿を安全に往復できることを優先した。
もっとも、これはWindows版が完成したという意味ではない。現在は、言語非依存schemaとgolden fixtureを固定するW0が未完了であり、実装はその次である。
商業化監査で、製品の中心が変わった
一度、ふみにわを「他人へ渡せる製品」として徹底的に監査した。
そこで見つかった重大な問題は、派手な新機能の不足ではなかった。
起動直後に編集可能な仮の作品を表示していたため、利用者が入力した本文を、遅れて読み込まれた前回作品が上書きする可能性があった。
作品の読み込みに失敗したとき、空の新規作品へ黙ってfallbackする経路があった。
本文ファイルの読み込み失敗や不正なUTF-8を、空文字へ変換して処理を続ける箇所もあった。
さらに、外部変更、孤児化した本文、署名や公証、AppIcon、アクセシビリティ、未接続AIの常設表示など、多数の問題が見つかった。
この監査を境に、製品の中心価値は次のように整理された。
昨日の続きを、昨日の場所から始められること。
そして異常が起きたとき、黙って空にせず、救出可能な状態で止まること。
ふみにわにとって、信用は機能数ではなく、失敗時の振る舞いに宿っていた。
「開けない」を正直に表示する
起動処理はLoading、Ready、Recoveryの三状態へ分離した。
作品を安全に読み込めるまで、編集可能なWorkbenchを表示しない。前回作品やFinderから指定された作品を開けなかった場合も、新規作品へ自動fallbackしない。
代わりにRecovery画面で止まり、再試行、Finderで表示、別作品を選択、明示的な新規作成を提示する。
本文が読めないなら、空文字として開かない。
執筆アプリでは、「開けません」と伝える方が、「空で開けました」と装うよりはるかに安全だからだ。
@MainActorだけでは、原稿を守れなかった
作品ライフサイクルのレビューでは、さらに厄介な競合が見つかった。
Swiftの@MainActorを使えば、状態は一度に一つずつ変更されるように見える。しかし、ファイルI/Oのawait中には別の操作が入り込める。
作品Aのスナップショット復元を待っている間に、Finderから作品Bを開く。すると、復元処理が再開したあと、動的に読み直した保存先へAのスナップショットを書き戻し、Bを壊す可能性があった。
起動処理でも、遅い初回読み込みが、あとからFinderで開いた作品を古い作品へ巻き戻す競合があった。
対策では、作品操作全体をFIFOのgateで直列化し、各操作が呼び出された時点の作品ID、URL、世代をsession tokenとして固定した。
待機中に作品が変わった操作は、保存層へ触る前に拒否する。
作品切り替え前には、日本語IMEの未確定文字を旧作品へ確定して保存する。終了要求後は新しい作品操作を受け付けない。lockの取得順も固定した。
ここで重要だったのは、「同時に触らない」だけでは足りないということだった。
「その操作が、どの作品に対して始まったのか」まで固定しなければならなかった。
改名は、文字列置換ではなかった
製品名は、NovelWriterから「ふみにわ/FUMINIWA」へ変更した。
だが、改名を画面上の文字列置換として行えば、既存作品や設定を壊す。
旧bundle domainに保存されていた最近開いた作品やエディタ設定は、対象をallowlistで限定して一度だけ移行する。新しい値がすでにある場合は上書きせず、旧作品や旧フォルダも移動・削除しない。
.novelpkgの内部形式、NovelKitなどのドメイン名、toolbarの永続IDも互換資産として維持した。
この改名の直後には、別の分かりにくい問題も起きた。
Xcodeが古いNovelWriter.xcodeprojを開き、すでに削除されたNovelWriterApp.swiftを参照していた。現在のFUMINIWA.xcodeprojはビルドできるのに、アプリ全体が壊れたように見えた。
原因はコードではなく、古い生成物だった。
現在は、XcodeGenの共通スクリプトから現行projectを生成し、旧projectを退避するようにしている。
生成物を正として扱わない、という原則が実際の障害として現れた例だった。
使えないAIを、先に見せない
かつてのWorkbenchには、下部にAI Assistant Panelがあった。
まだproviderも通信処理も存在しないのに、入力欄やAI状態、ショートカットだけが置かれていた。
見栄えとしては未来を感じさせる。しかし、利用者から見れば「使える機能」に見える。さらに、本文が外部へ送られるのかもしれないという不安だけを生む。
そこで、未実装AIの入口を通常版からすべて撤去した。
この判断は「Product Truth」として明文化した。
実在する機能だけをUIへ出す。
まだできないことを、雰囲気で先に見せない。
AIを再び表示するには、送信範囲、送信先、保持情報、明示確認、取消、失敗時の挙動、差分、適用確認まで成立していなければならない。
AIは、接続より先に誤適用を防ぐ
現在のAI実装は、選択範囲の校正案だけに絞っている。
利用者が明示的に選択した本文だけを対象にし、作品名、章名、人物、プロット、ローカルファイルを暗黙に追加しない。
送信前には、実際に送るpromptとresponse schemaをそのままpreviewする。確認後に内容やproviderが変わった場合は、確認を無効にする。同じ確認から送信できるのは一度だけである。
結果は自動適用しない。原文との差分を表示し、利用者が明示的にApplyしたときだけ、EditorKitの正規編集経路で1回のUndo単位として置換する。
通信中に作品、話、エディタ、選択範囲、原文のどれかが変わった場合、その結果はstaleとして適用を拒否する。同じ文字列を別の場所から検索して、勝手に結び直すこともしない。
現在は、この一連の流れをfake providerで検証している。
通常版のFUMINIWAと、個人実験用のFUMINIWAExperimentalは別target、別bundle ID、別保存rootに分けた。通常版にはNovelAI、AI UI、provider依存、sidecar artifactをbuild graphの段階で含めない。
これは、設定で隠しているだけではない。
通常版には、そもそもAIの実行経路を入れないという分離である。
Codexをつなぐ前に見つかった、都合の悪い現実
実providerの第一候補はCodex TypeScript SDKである。
しかし、Swiftから直接使えるSDKではないため、Node sidecarを介して接続する必要がある。
さらに調査時点では、期待していたtool完全無効化、ephemeral thread、上流のoutput token capなどを、公開API上で保証できなかった。
FUMINIWA側で結果をmemory onlyにしても、providerやSDK側に履歴やartifactが残らないとは限らない。
ローカルでbyte数や実行時間を制限しても、それは上流の料金上限ではない。
そのため、存在しない保証をUI上で「無効」「保存なし」「上限あり」と補うことはしない。確認できないものは、未保証として表示する。
次に必要なのは、単に通信を成功させることではない。
SDK、CLI、Nodeのversion・path・hash固定、専用の空cwdとCODEX_HOME、親environmentを継承しないallowlist、Keychain、OSレベルのファイル隔離、cancel/timeout後のprocess tree回収、orphan processが残らないこと、local artifactの実測が必要になる。
ここまで成立して初めて、個人用Experimental UIへ実Codex adapterを接続できる。
その後、OpenRouterを独立したHTTP adapterとして同じUIへ追加する予定である。Codexが失敗したからOpenRouterへ自動で切り替える、といったfallbackは行わない。
送信先が変わるなら、利用者もそれを知り、新しいpreviewを確認しなければならない。
現在地と、これから
ふみにわには現在、macOS上で小説を書くための主要機能が一通り揃っている。
日本語IME対応の本文エディタ、章と話の管理、自動保存、検索、スナップショット、人物、プロット、伏線、資料、世界観ノート、TXT/Markdown/EPUB書き出しまで動作する。
一方で、一般公開できる状態とは考えていない。
今後は、個人用AIと公開Releaseを別トラックとして進める。
個人用AIでは、Codex sidecarの固定protocolと隔離feasibilityを先に実証し、その後に実adapterを共通UIへ接続する。続いてOpenRouter adapterを追加する。
公開Releaseでは、まずPackage Validatorを作る。重複ID、不正参照、symlink、巨大パッケージ、孤児化した本文を検出し、元作品を直接書き換えず、修復コピーとして救出できるようにする。
その次に、Finderでの移動や削除、同期サービス、別プロセスによる外部変更を検出するConflict Gateへ進む。
さらにAppIcon、Developer ID署名、公証、更新機構、別MacでのGatekeeper検証、VoiceOver、Light/Dark、Reduce Transparency、長文・大量データの実機QAが残っている。
Windows版はW0のschemaとgolden fixtureから始める。PDFとiOS/iPadOSも未実装である。
できていないことを、できたことのようには書かない。
それ自体が、現在のふみにわにとって重要な製品要件になっている。
この開発で学んだこと
一連の開発を振り返ると、重要だったのは成功した実装より、途中で前提を捨てられたことだった。
- 完成したUIでも、使い方に合わなければ作り直す
- 保存成功だけでなく、失敗、取消、競合、復旧を先に設計する
- 画面の見た目だけでなく、実際の状態遷移を検証する
@MainActorや自動保存という言葉だけで、安全だと思い込まない- AIは接続できることより、何を送り、どこへ適用するかを固定する
- 未実装機能や未確認の保証をUIで補わない
- 測定せずに最適化しない
- 設計文書も、コードと一緒に更新される実装成果物として扱う
ふみにわは、まだ完成していない。
しかし、目標は以前より明確になった。
派手な機能を最速で増やすことではない。作者の文章を勝手に消さず、勝手に送り、勝手に書き換えず、異常時には正直に止まること。
小説を書く人が、道具の都合を意識せず物語へ戻れること。
その当たり前を積み上げて、「書けるアプリ」から「原稿を預けられるアプリ」へ進んでいく。
それが、現在のふみにわが目指している場所である。