Skip to content

Commit c224165

Browse files
GeneralDCopilot
andauthored
feat(#41): config ホットリロードを全ビジュアル要素で完結 (#337)
![agent](https://img.shields.io/badge/agent-claude-D97757) ![type](https://img.shields.io/badge/type-feat-blue) ![closes](https://img.shields.io/badge/closes-%2341-green) ![version](https://img.shields.io/badge/version-2.23.0%20%E2%86%92%202.27.1-blue) ![tests](https://img.shields.io/badge/tests-1325%20green-brightgreen) ![patch coverage](https://img.shields.io/badge/patch%20coverage-100%25-brightgreen) Closes #41 ## 概要 `config.toml` の編集を daemon 再起動なしで**全ビジュアル要素**へライブ反映する #41 の集大成。開発ブランチ `feat/41-hot-reload/dev` に積み上げた PR1〜PR4(6 コミット)を `main` へ統合します。これで config 編集に `lyra restart` は一切不要になります。 ## 変更内容 設計仕様 `docs/superpowers/specs/2026-07-12-config-hot-reload-design.md` に沿ったスタック PR の集約です。 - **PR1 中核 (#324)** — ホットリロード基盤。`ConfigUseCaseImpl` を lazy-frozen ロードから mutable な `OSAllocatedUnfairLock<AppStyle>` ストア+`reload() -> ConfigReloadOutcome` に置換(失敗時はストア不変で `.invalid` に理由を載せる)。`FileWatchGateway`(`DispatchSource.makeFileSystemObjectSource`、親ディレクトリ監視で atomic rename に強い)+`ConfigInteractorImpl`(150ms デバウンス、`appStyleChanges`/`invalidConfig` を publish)。不正 config は `ConfigStatusOverlay` が amber の崩れた測地線インジケータで可視化。lyrics の `[lyrics] fallback_command`/`timeout_ms` も呼び出しごと再読込に。 - **PR2 (#327)** — `HeaderPresenter`/`LyricsPresenter` を `start()`(購読は一度だけ)と冪等な `applyStyle()`(live config を `@Published` へ流す)に分割。購読を張り替えないので旧 push モデルの二重購読・リプレイ・monitor リークが構造的に消える。`DecodeEffectState` で decode エフェクトを境界ごとに再構築。 - **#330 整合 (#333)** — ホットリロードの optional `[ai]`/`[lyrics]` セクション検証を起動時の寛容判定に揃える(不正な拡張セクションは `nil` 降格で、正当な text/wallpaper 編集をブロックしない。`healthcheck` のみ厳格)。 - **#329 (#334)** — config ファイルが未存在でもディレクトリ監視を armed にし、daemon 起動後に作成された config を再起動なしで初回ロード。 - **PR3 (#335)** — ripple/spectrum オーバーレイの `enabled` トグルとスタイルをホットリロード。`RipplePresenter.applyStyle()` は ripple config を diff(rebuild 時に hover フラグをリセット、原点リップル湧出を防止)、`SpectrumPresenter.applyStyle()` は `SpectrumStyle` 全体を diff し `objectWillChange` 再発行で `SpectrumView` を再描画。バンド設定は `resolvedAnalyzer` のメモ化キーを全ビルド入力に拡張して即反映。DisplayLink のフレームハンドラは常時装着し、enabled ガードは各ハンドラ内へ移動(#252/#258 のゼロアイドルコストを維持)。 - **PR4 (#336)** — 重量級の 2 コンシューマで完結。`WallpaperPresenter` を `start()`/`applyStyle()` に分割し、`WallpaperInteractor.wallpaperSource` を diff して実変化時のみ再解決(無関係な編集で再生中の動画に触れない)。ソース差替は同一 AVPlayer 上で `replaceCurrentItem`(ブラックアウトなし)、全削除時は player を破棄し `onPlayerCleared`→`AppWindow.detachPlayerLayer()` で `AVPlayerLayer` を外して透過背景へ復帰(黒画面残留を解消)。applied source は解決成功時のみ確定し、一時的な 0 件はリトライ可能に。superseded な load は空クリーンアップ前に bail。`AppPresenter` は `appStyleChanges` を layout パイプラインへ合流し、`[screen]` selector 変化で `resolveLayout()` 再実行+vacant ポーリングを張り直す。 - **main 追従マージ** — dev 分岐後に main へ入った #332(歌詞解決デバッグトレース `DeveloperLog`/#331)と `LyraKit.md` 更新を取り込み。衝突は加算的な docs(ARCHITECTURE の Mermaid ノード・Layer Summary・Key Design Decisions に `FileWatchGateway`/#41 と `DeveloperLog`/#331 を併記)と version のみで、両者を結合して解消。 - **lifecycle リファクタ** — `AppRouter`(Router=ワイヤーフレーム)が `@Dependency(\.configInteractor)` を直接保持し `start()`/`stop()` を呼んでいたのを、front する `ConfigStatusPresenter` へ移譲(`SpectrumPresenter` が `SpectrumInteractor` を持つのと同型)。Router が Interactor 層へ直接触れなくなり、`AppRouter.stop()` の `withBootstrap { configInteractor.stop() }` 回避策も不要になり削除。 - **本 PR レビュー対応(Copilot / Codex)** — ① `WallpaperPresenter` に pending な `targetSource` を導入し、スロー解決中の無関係な編集ピンで in-flight ダウンロードを再起動しない+applied source への revert は正しく pending swap をキャンセル。② 旧 playlist/scale のリセットを「置換の最初の item 到着まで」遅延し、空解決時も旧壁紙が scale 跳ね・advance 不能にならない。③ `ConfigDataSourceImpl.load()` に last-good 保持を追加し、不正編集がディスク上にある間も `[lyrics]` fallback / `[ai]` endpoint が直前の承認済み config で動き続ける(ファイル削除は defaults 復帰)。④ 重複していたテスト待機ヘルパーは Copilot の suggested changeset を採用して共有 `waitUntil` に統一。⑤ `ConfigInteractorImpl` の teardown race を解消 — debounce タスクの armed/cancelled チェックと publish を `stop()` と同一 lock 内で原子的に実行し、stop 後の spurious publish を構造的に排除。⑥ `reload()` の `existingConfigPath` を先頭で一度だけ捕捉し、atomic save 中でも存在判定と報告 path が乖離しない(suggested changeset 採用)。 - **codecov patch coverage 100%** — 未カバーだった 13 行をテストで解消。`AppRouter` のインライン default windowFactory を `defaultFrameSchedulerFactory` と同型の static メソッドへ抽出して実 `AppWindow` 構築を直接テスト、壁紙削除リロード→`detachPlayerLayer` の end-to-end 検証、`ConfigUseCase` の `.unreadable` 経路、`WallpaperInteractor.wallpaperSource` 委譲、`OverlayContentView` の configStatus 分岐レンダリングを追加。 - **実機スモークで発見した watch バグの修正** — ディレクトリ fd の DispatchSource はエントリ変化(rename/create/delete)でしか発火せず、**リネームせず保存するエディタ・`cp`・追記・truncate などの in-place 上書きではリロードが一切走らなかった**(手動テストで「構文が正しいのにエラー表示が残る」「live reload が効かない」として顕在化)。`ConfigWatchGateway` に `watch(file:)` を追加した**二段 watch** とし、file fd は rename で無効化されるためイベント毎に再アーム。さらに縮小 `ftruncate` は `NOTE_WRITE` ではなく `NOTE_ATTRIB` のみ発火するため eventMask に `.attrib` を追加(chmod/touch 等の偽イベントは debounce+冪等 reload が吸収)。加えて **`includes` で取り込んだファイルも watch 対象に**: decode のマージと同じ解決ヘルパーで watch 対象を算出(乖離なし)し、configDir 外の include はその親ディレクトリも watch する(atomic save 対策)。実機の lsof で発見した configDir 二重 watch も解消。 - **アーキテクチャ・ブラッシュアップ(依存の段飛ばし解消)** — 上記 watch 実装は当初 `ConfigInteractorImpl` が `ConfigWatchGateway`(OS 境界)を直接消費する段飛ばしになっていたため、`watchChanges(onChange:)` を `ConfigInteractor → ConfigUseCase → ConfigRepository → ConfigDataSource → ConfigWatchGateway` の隣接連鎖に再配置。watch 対象解決(config file / includes / 外部 include 親 dir)と再アームは `ConfigDataSourceImpl` の `ConfigWatchSession` が所有し(`AudioTapDataSource → AudioTapGateway` と同じ消費層)、再アームはイベント毎にディスクの `includes` を再読するため reload 完了に依存しない。Files 由来パス同士の比較になったことで末尾スラッシュ正規化ハックも消滅。`ConfigInteractorImpl` は debounce・reload 指揮・publish のみに縮退し、watch のためだけに UseCase/Repository へ露出していた `configDir`/`includedConfigPaths` は契約から削除。watch 対象解決のテストは `ConfigWatchTests`(DataSource 層、実 FS での include 編集・atomic save 再アーム統合テスト含む)へ移設。 ## 背景・動機 従来は config 編集のたびに `lyra restart` が必要で、オーバーレイ表示のチューニング体験を大きく損なっていた。ホットリロードは #41 最難関の課題で、過去に「購読を push で張り替える」実装が二重購読・壁紙ブラックアウト・monitor リークで破綻していた。本統合は全 Presenter を **`start()`(購読は一度だけ)+冪等な `applyStyle()`(live config を diff)** の単一パターンに統一し、その破綻要因を構造的に排除する。 ## テスト計画 - [x] 全 **1348** テスト green(PR1〜PR4 の新規スイート+main 追従分+`ConfigStatusPresenter` の lifecycle 検証+レビュー対応の回帰テスト+patch coverage 100% 化のテスト群+watch バグ修正の file watch/truncate テスト群+DataSource 層移設後の `ConfigWatchTests`+Round 4 対応の回帰テスト群を含む) - [x] codecov patch coverage **100%**(llvm-cov でローカル実測確認済み) - [x] `make lint` clean - [x] 各スタック PR が個別に CI green(lint/test/codecov/CodeRabbit) - [x] 実機スモーク(debug daemon を brew サービス停止の上で単独起動、スクリーンショットで確認): - 壁紙描画・scale のライブ反映(atomic save 経由) - トップレベル構文エラー → amber 崩れ球体インジケータ+last-good スタイル維持 → 修復でインジケータ消滅 - **in-place 追記**(`>>`)→ エラー検知・インジケータ表示(修正前は未検知) - **縮小 truncate による修復** → リロード・インジケータ消滅(修正前は未検知、`.attrib` で解消) - includes 先(koko.toml)への file watch fd が実際に張られることを lsof で確認(configDir/config.toml/koko.toml 各 1 本) ## 備考 - **Version**: 2.23.0 → 2.27.2(PR1〜PR4 の minor bump 累積 + lifecycle リファクタ・watch バグ修正の patch bump)。 - スタック PR #324/#327/#333/#334/#335/#336 はすべて `feat/41-hot-reload/dev` へ squash マージ済み。本 PR のマージで #41 が default ブランチ上でクローズされます。 - スタック PR のレビュー指摘(F2/F3/F5/F7/F8/F9)は各 PR 内で、本 PR への Copilot/Codex 指摘はレビュー対応コミットで対応済み(各スレッドに返信あり)。 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added live configuration reload without restarting the daemon. * Config-driven visual updates now apply immediately, including styles, lyrics presentation, overlays, wallpaper, and screen behavior. * Added an on-screen amber “shattered” indicator when a config reload fails (previous styling remains active). * **Bug Fixes** * Invalid edits now preserve the last known good styling instead of disrupting playback/rendering. * Wallpaper changes no longer cause unnecessary blackouts, and removing wallpaper detaches the player layer cleanly. * Malformed optional `[ai]`/`[lyrics]` sections no longer block otherwise valid updates. * **Documentation** * Updated live reload and validation/fallback behavior documentation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 9f7907f commit c224165

81 files changed

Lines changed: 5922 additions & 314 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/architecture-boundaries.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@
1111
| **CLI Command** | Argument parsing, thin glue | Loops, task groups, complex branching |
1212
| **DataStore** | Persistence/cache of *domain data* — Entity values a Repository reads back (SQLite caches, wallpaper files) | Caching *computational resources* (memoized engines, FFT setups, formatters) — those stay private state of the owning implementation (see docs/ARCHITECTURE.md "Analyzer memoization", #313) |
1313

14+
## Dependency Chain — Adjacent Layers Only, No Skipping
15+
16+
Within the VIPER lane, each layer depends only on the layer directly beneath it:
17+
18+
```text
19+
View → Presenter → Interactor → UseCase → Repository → DataSource/DataStore → Gateway (OS boundary)
20+
```
21+
22+
- **No layer skipping.** An upper layer must not consume a lower-lower layer
23+
directly, even when `@Dependency` makes it a one-liner — an Interactor
24+
holding a Gateway is a violation regardless of how convenient the DI graph
25+
makes it (#337: `ConfigInteractor``ConfigWatchGateway` was refactored
26+
into the adjacent chain).
27+
- **OS-boundary gateways are consumed at the DataSource layer.** Precedents:
28+
`AudioTapDataSource → AudioTapGateway`, `ConfigDataSource →
29+
ConfigWatchGateway`. When an upper layer needs OS-boundary behavior, thread
30+
a contract through the adjacent layers as pass-throughs (e.g.
31+
`watchChanges(onChange:)` on UseCase → Repository → DataSource) instead of
32+
reaching down.
33+
- **Knowledge placement follows the chain too.** Path resolution, normalization,
34+
and other persistence-shaped knowledge live in the DataSource; the layers
35+
above see only the contract.
36+
1437
## CLI Command Pattern
1538

1639
All commands follow: **inject → call → write → guard**. No exceptions.

AGENTS.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,42 @@ Shared conventions:
106106
back to the raw title/artist -- never an unvalidated candidate guess --
107107
when nothing validates. See `docs/ARCHITECTURE.md` (Key Design Decisions,
108108
#308) for full detail.
109+
- Config hot-reloads without a daemon restart. `ConfigUseCase.reload()` keeps
110+
the previous `AppStyle` in effect when the **required** structure fails to
111+
validate (unreadable file, decode error in a core section, or a file that
112+
resolves to defaults while it still exists) -- never regress on a bad edit.
113+
A malformed **optional** `[ai]`/`[lyrics]` section instead degrades to `nil`
114+
like startup and does not block a valid edit; `lyra healthcheck` still
115+
reports it (the strictness is the `strictOptionalSections` flag on
116+
`validate`/`tryDecode`, #330). The `ConfigWatchGateway` (Domain) /
117+
`FileWatchGateway` (Support) pair watches the config's *parent directory*
118+
(atomic saves rename the file) plus a re-armed file tier via
119+
`DispatchSource`. The gateway is consumed at the **DataSource layer**:
120+
`ConfigDataSourceImpl.watchChanges(onChange:)` owns target resolution
121+
(config file, `includes`, foreign include parents) and per-event re-arming,
122+
and the watch reaches the interactor only through `ConfigRepository` /
123+
`ConfigUseCase` pass-throughs -- adjacent layers only, no layer skipping.
124+
The directory watch arms whether or not the file exists yet, so a config
125+
created after the daemon starts (`lyra config init`, a manual save) is
126+
picked up as the initial load without a restart, as long as the config
127+
directory exists at start (#329). The
128+
`ConfigInteractor` module debounces watch events before calling
129+
`reload()` and republishing the outcome over Combine. An invalid reload
130+
is shown graphically (an amber "destabilized" geodesic sphere via
131+
`ConfigStatusPresenter`/`ConfigStatusOverlay`) since a daemon has no
132+
visible stderr. See `docs/ARCHITECTURE.md` (Key Design Decisions, #41) for
133+
full detail. Header/Lyrics styling, the ripple/spectrum overlays (styling
134+
plus the `enabled` toggle), the wallpaper source, and the screen selection
135+
now all re-render live -- each Presenter subscribes once to `appStyleChanges`
136+
and reflects the change in an idempotent `applyStyle()`, and the DisplayLink
137+
fan-out always includes the ripple/spectrum frame handlers (their enabled
138+
guard lives inside the handler, #41 PR3). The wallpaper reload diffs the
139+
source and swaps the video via `replaceCurrentItem` on the same AVPlayer, so
140+
the overlay never blacks out; removing all wallpaper tears the player down and
141+
detaches the layer, restoring the transparent backing rather than leaving a
142+
black surface; the screen reload re-runs `resolveLayout()` and restarts vacant
143+
polling on a new selector/debounce (#41 PR4). Config hot-reload now covers
144+
every visual element -- #41 is functionally complete.
109145

110146
## Testing Rules
111147

@@ -122,8 +158,11 @@ Shared conventions:
122158
## Change Checklist
123159

124160
- When adding or removing modules, update `Package.swift`,
125-
`DependencyInjection`, `docs/ARCHITECTURE.md`, `.claude/CLAUDE.md`, this
126-
`AGENTS.md`, and `README.md`.
161+
`DependencyInjection`, `docs/ARCHITECTURE.md`, this `AGENTS.md`, and
162+
`README.md`. Update `.claude/CLAUDE.md` only when Build & Test commands
163+
change or the layer chain itself changes — its architecture section is a
164+
short summary + pointer, and `docs/ARCHITECTURE.md` is the canonical
165+
reference (see `.claude/rules/module-checklist.md`).
127166
- Handler additions also need Entity result types, Domain protocols,
128167
`StandardOutput` support, CLI wiring, and tests.
129168
- Do not commit directly to `main`. Use branch -> PR -> merge.

Package.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ let package = Package(
118118
"Domain"
119119
]
120120
),
121+
122+
// ── FileWatchGateway ──
123+
.target(
124+
name: "FileWatchGateway",
125+
dependencies: [
126+
"Domain"
127+
]
128+
),
121129
.target(
122130
name: "AppKitScreenProvider",
123131
dependencies: [
@@ -244,6 +252,7 @@ let package = Package(
244252
"DeveloperLog",
245253
"TrackInteractor",
246254
"ScreenInteractor",
255+
"ConfigInteractor",
247256
"WallpaperInteractor",
248257
"SpectrumInteractor",
249258
"ConfigUseCase",
@@ -267,6 +276,7 @@ let package = Package(
267276
"SQLiteDataStore",
268277
"DarwinGateway",
269278
"CoreAudioTapGateway",
279+
"FileWatchGateway",
270280
"FrequencyAnalyzer",
271281
"ProcessHandler",
272282
"VersionHandler",
@@ -295,6 +305,13 @@ let package = Package(
295305
.product(name: "Dependencies", package: "swift-dependencies"),
296306
]
297307
),
308+
.target(
309+
name: "ConfigInteractor",
310+
dependencies: [
311+
"Domain",
312+
.product(name: "Dependencies", package: "swift-dependencies"),
313+
]
314+
),
298315
.target(
299316
name: "WallpaperInteractor",
300317
dependencies: [
@@ -415,6 +432,7 @@ let package = Package(
415432
name: "ConfigDataSource",
416433
dependencies: [
417434
"Domain",
435+
.product(name: "Dependencies", package: "swift-dependencies"),
418436
.product(name: "Files", package: "Files"),
419437
.product(name: "TOMLKit", package: "TOMLKit"),
420438
]
@@ -511,6 +529,14 @@ let package = Package(
511529
.product(name: "Dependencies", package: "swift-dependencies"),
512530
]
513531
),
532+
.testTarget(
533+
name: "ConfigInteractorTests",
534+
dependencies: [
535+
"ConfigInteractor",
536+
"Domain",
537+
.product(name: "Dependencies", package: "swift-dependencies"),
538+
]
539+
),
514540
.testTarget(
515541
name: "WallpaperInteractorTests",
516542
dependencies: [
@@ -567,6 +593,10 @@ let package = Package(
567593
name: "CoreAudioTapGatewayTests",
568594
dependencies: ["CoreAudioTapGateway"]
569595
),
596+
.testTarget(
597+
name: "FileWatchGatewayTests",
598+
dependencies: ["FileWatchGateway", "Domain"]
599+
),
570600
.testTarget(name: "EntityTests", dependencies: ["Entity"]),
571601
.testTarget(
572602
name: "AsyncRunnableCommandTests",
@@ -750,14 +780,29 @@ let package = Package(
750780
dependencies: [
751781
"ConfigDataSource",
752782
"Domain",
783+
"FileWatchGateway",
784+
.product(name: "Dependencies", package: "swift-dependencies"),
753785
.product(name: "TOMLKit", package: "TOMLKit"),
754786
]
755787
),
788+
.testTarget(
789+
name: "ConfigHotReloadTests",
790+
dependencies: [
791+
"ConfigUseCase",
792+
"ConfigInteractor",
793+
"ConfigRepository",
794+
"ConfigDataSource",
795+
"Domain",
796+
"Entity",
797+
.product(name: "Dependencies", package: "swift-dependencies"),
798+
]
799+
),
756800
.testTarget(
757801
name: "LyricsDataSourceTests",
758802
dependencies: [
759803
"LyricsDataSource",
760804
"Domain",
805+
"ConfigDataSource",
761806
.product(name: "Dependencies", package: "swift-dependencies"),
762807
.product(name: "Papyrus", package: "papyrus"),
763808
]

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ Or create `~/.config/lyra/config.toml` (or `config.json`) manually. All fields a
107107

108108
Alternative paths: `~/.lyra/config.toml`, `$XDG_CONFIG_HOME/lyra/config.toml`
109109

110+
### Live reload
111+
112+
The daemon watches the config directory for edits and re-validates on every save — no `lyra restart` needed to pick up a config file change. Saves are debounced briefly to coalesce rapid writes, then applied automatically. The watch arms even when no config file exists yet, so a config created after the daemon starts (via `lyra config init` or a manual save) is picked up without a restart, as long as its directory (`~/.config/lyra`) already exists.
113+
114+
This now covers every visual element: config validation, the header and lyrics styling (fonts, colors, sizes, decode effect, and artwork all re-render live), the lyrics `[lyrics] fallback_command`/`timeout_ms` settings (re-read on every fallback invocation), the ripple and spectrum overlays (their styling and their `enabled` toggle both take effect live), the wallpaper source (swapping the video, changing playback mode, or removing it entirely all apply live without blacking out the overlay), and the screen selection (`[screen]` selector and debounce). No `lyra restart` is required for any config edit.
115+
116+
If an edit breaks the config's required structure (invalid TOML/JSON, or bad values in the core text/wallpaper/spectrum sections), lyra keeps the last valid style in effect rather than falling back to defaults or crashing. Since a background daemon has no visible terminal to log an error to, a small amber "shattered" sphere appears in the corner of the overlay to flag that the last edit wasn't applied — fix the file and save again to clear it.
117+
118+
A malformed optional `[ai]` or `[lyrics]` section is tolerated instead of blocking the whole edit: that section is skipped while the rest of the valid config still applies, exactly as it does at startup (so no "shattered" sphere appears for it). `lyra healthcheck` still reports a malformed `[ai]`/`[lyrics]` so the problem stays discoverable.
119+
110120
### Top-level
111121

112122
| Key | Type | Default | Description |

Sources/AppRouter/AppDependencyBootstrap.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import Foundation
8787

8888
private struct UITestWallpaperInteractor: WallpaperInteractor {
8989
var playbackMode: WallpaperPlaybackMode { .cycle }
90+
var wallpaperSource: WallpaperStyle? { nil }
9091
func resolvedWallpapers() -> AsyncStream<ResolvedWallpaperItem> {
9192
AsyncStream { $0.finish() }
9293
}

Sources/AppRouter/AppRouter.swift

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import Views
99
public final class AppRouter {
1010
private let bootstrap: AppDependencyBootstrap
1111
private let windowFactory:
12-
@MainActor (ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter)
12+
@MainActor (
13+
ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter,
14+
ConfigStatusPresenter?
15+
)
1316
-> any OverlayWindow
1417
private let frameSchedulerFactory: @MainActor (@escaping @MainActor (Double) -> Void) -> any FrameScheduler
1518
private var appPresenter: AppPresenter?
@@ -18,6 +21,7 @@ public final class AppRouter {
1821
private var wallpaperPresenter: WallpaperPresenter?
1922
private var ripplePresenter: RipplePresenter?
2023
private var spectrumPresenter: SpectrumPresenter?
24+
private var configStatusPresenter: ConfigStatusPresenter?
2125

2226
private var appWindow: (any OverlayWindow)?
2327
private var frameScheduler: (any FrameScheduler)?
@@ -28,19 +32,30 @@ public final class AppRouter {
2832
DisplayLinkDriver(onFrame: onFrame)
2933
}
3034

35+
static func defaultWindowFactory(
36+
layout: ScreenLayout,
37+
headerPresenter: HeaderPresenter,
38+
lyricsPresenter: LyricsPresenter,
39+
ripplePresenter: RipplePresenter,
40+
spectrumPresenter: SpectrumPresenter,
41+
wallpaperPresenter: WallpaperPresenter,
42+
configStatusPresenter: ConfigStatusPresenter?
43+
) -> any OverlayWindow {
44+
AppWindow(
45+
initialLayout: layout,
46+
headerPresenter: headerPresenter,
47+
lyricsPresenter: lyricsPresenter,
48+
ripplePresenter: ripplePresenter,
49+
spectrumPresenter: spectrumPresenter,
50+
wallpaperPresenter: wallpaperPresenter,
51+
configStatusPresenter: configStatusPresenter
52+
)
53+
}
54+
3155
public convenience init(launchEnvironment: AppLaunchEnvironment = .current) {
3256
self.init(
3357
bootstrap: AppDependencyBootstrap(launchEnvironment: launchEnvironment),
34-
windowFactory: { layout, headerPresenter, lyricsPresenter, ripplePresenter, spectrumPresenter, wallpaperPresenter in
35-
AppWindow(
36-
initialLayout: layout,
37-
headerPresenter: headerPresenter,
38-
lyricsPresenter: lyricsPresenter,
39-
ripplePresenter: ripplePresenter,
40-
spectrumPresenter: spectrumPresenter,
41-
wallpaperPresenter: wallpaperPresenter
42-
)
43-
},
58+
windowFactory: Self.defaultWindowFactory,
4459
frameSchedulerFactory: Self.defaultFrameSchedulerFactory
4560
)
4661
}
@@ -49,7 +64,8 @@ public final class AppRouter {
4964
launchEnvironment: AppLaunchEnvironment,
5065
windowFactory:
5166
@escaping @MainActor (
52-
ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter
67+
ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter,
68+
ConfigStatusPresenter?
5369
) -> any OverlayWindow,
5470
frameSchedulerFactory: @escaping @MainActor (@escaping @MainActor (Double) -> Void) -> any FrameScheduler
5571
) {
@@ -64,7 +80,8 @@ public final class AppRouter {
6480
bootstrap: AppDependencyBootstrap,
6581
windowFactory:
6682
@escaping @MainActor (
67-
ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter
83+
ScreenLayout, HeaderPresenter, LyricsPresenter, RipplePresenter, SpectrumPresenter, WallpaperPresenter,
84+
ConfigStatusPresenter?
6885
) -> any OverlayWindow,
6986
frameSchedulerFactory: @escaping @MainActor (@escaping @MainActor (Double) -> Void) -> any FrameScheduler
7087
) {
@@ -93,15 +110,22 @@ public final class AppRouter {
93110
self.ripplePresenter = ripplePresenter
94111
let spectrumPresenter = SpectrumPresenter()
95112
self.spectrumPresenter = spectrumPresenter
113+
let configStatusPresenter = ConfigStatusPresenter()
114+
self.configStatusPresenter = configStatusPresenter
96115

97116
headerPresenter.start()
98117
lyricsPresenter.start()
99118
ripplePresenter.start()
100119
spectrumPresenter.start()
101120
wallpaperPresenter.start()
121+
// ConfigStatusPresenter owns the ConfigInteractor lifecycle (arming the config-file
122+
// watch). Start it last so all `appStyleChanges` subscribers above are live before
123+
// any watch-triggered reload event is published.
124+
configStatusPresenter.start()
102125

103126
let window = windowFactory(
104-
layout, headerPresenter, lyricsPresenter, ripplePresenter, spectrumPresenter, wallpaperPresenter)
127+
layout, headerPresenter, lyricsPresenter, ripplePresenter, spectrumPresenter, wallpaperPresenter,
128+
configStatusPresenter)
105129
appWindow = window
106130
window.show()
107131

@@ -113,22 +137,25 @@ public final class AppRouter {
113137
window?.attachPlayerLayer(for: player)
114138
window?.applyWallpaperScale(wallpaperPresenter?.wallpaperScale ?? 1.0)
115139
}
140+
wallpaperPresenter.onPlayerCleared { [weak window] in
141+
window?.detachPlayerLayer()
142+
}
116143
wallpaperPresenter.onWallpaperScaleChange { [weak window] scale in
117144
window?.applyWallpaperScale(scale)
118145
}
119146

120-
// Only enabled features pay a per-frame cost: each optional
121-
// handler is included in the frame fan-out only when its feature
122-
// is on, so a disabled ripple/spectrum adds zero work per tick.
147+
// Ripple and spectrum handlers are always installed so enabling either
148+
// at runtime resumes its per-frame work without rebuilding the fan-out
149+
// (#41 PR3). Each bails cheaply while its feature is inactive —
150+
// `idle()` on an enabled guard, `tick()` on its capturing/residue guard
151+
// — so a disabled feature still adds no real per-frame cost (#252/#258).
123152
let frameHandlers: [@MainActor @Sendable (Double) -> Void] = [
124-
ripplePresenter.isEnabled
125-
? { @MainActor @Sendable [weak self] _ in self?.ripplePresenter?.idle() } : nil,
126-
spectrumPresenter.isEnabled
127-
? { @MainActor @Sendable [weak self] interval in
128-
self?.spectrumPresenter?.tick(frameInterval: interval)
129-
} : nil,
153+
{ @MainActor @Sendable [weak self] _ in self?.ripplePresenter?.idle() },
154+
{ @MainActor @Sendable [weak self] interval in
155+
self?.spectrumPresenter?.tick(frameInterval: interval)
156+
},
130157
{ @MainActor @Sendable [weak self] _ in self?.lyricsPresenter?.updateActiveLineTick() },
131-
].compactMap { $0 }
158+
]
132159
let onFrame: @MainActor @Sendable (Double) -> Void = { interval in
133160
for handler in frameHandlers { handler(interval) }
134161
}
@@ -157,6 +184,13 @@ public final class AppRouter {
157184
spectrumPresenter?.stop()
158185
defer { spectrumPresenter = nil }
159186

187+
// ConfigStatusPresenter owns the ConfigInteractor lifecycle: its
188+
// stop() disarms the watch. Because the presenter was constructed
189+
// inside the bootstrap scope in start(), its `@Dependency` resolves the
190+
// same interactor instance here without a manual `withBootstrap` wrap.
191+
configStatusPresenter?.stop()
192+
defer { configStatusPresenter = nil }
193+
160194
frameScheduler?.stop()
161195
defer { frameScheduler = nil }
162196

Sources/AppRouter/OverlayWindow.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ protocol OverlayWindow: AnyObject {
77
func show()
88
func applyLayout(_ layout: ScreenLayout)
99
func attachPlayerLayer(for player: AVPlayer)
10+
func detachPlayerLayer()
1011
func applyWallpaperScale(_ scale: Double)
1112
func close()
1213
}

0 commit comments

Comments
 (0)