diff --git a/docs/progress.md b/docs/progress.md index 9b6aca5..c3df141 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -735,6 +735,162 @@ Phase B5完了後、実機で追加のフィードバックが8件出た。う Phase B6は8タスク全完了。実機検証は次回実機起動時に通しで実施予定。 +### Phase B7: RichHomeレイアウト微調整 + バッジ + 命名整理 + アートワーク安定化(2026-07-31〜) + +Phase B6完了後の実機フィードバックで、歌詞ボックスの垂直センタリング未解決(横ではなく縦方向)、 +シークバーとアートワークの間隔、再生元バッジの不要な黒円+アイコンサイズ、 +`AtollStyleExpandedView`という参考先アプリ名を含む命名の中立化、Spotify/Apple Musicの +アートワーク取得不安定の計6件の指摘があった。詳細な真因分析は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B7セクション参照。 + +- [x] B7-1: 命名整理 — `AtollStyleExpandedView` → `RichHomeView`(ファイル名・型名・ + 12ファイルのコメント参照)に機械的リネーム。過去のGit履歴は書き換えない + (AskUserQuestionでユーザーと合意済み)。コミット`77383b0`。 +- [x] B7-2: 歌詞ボックスの縦方向センタリング — `RichHomeView.swift`のHStack + `alignment: .top` → `.center`。あわせて`.frame(idealWidth: SurfaceMetrics.baseContentWidth)` + 追加で横方向のfixedSize/nilプロポーザル起因の中央寄せ崩れも修正。コミット`eed3b29`。 +- [x] B7-3: シークバーとアートワークの間隔調整 — `progressSection`に`.padding(.top, 8)`追加。 + コミット`d3aee25`。 +- [x] B7-4: 再生元バッジの黒円削除+アイコン拡大(18pt→40pt)。コミット`70ce418`。 +- [x] B7-5: Spotifyアートワークキャッシュの成功後書き込み化(`ArtworkFetcher.swift`)。 + コミット`a206251`。 +- [x] B7-6: Apple Musicアートワークのバウンド付き再試行(`NowPlayingManager.swift`)。 + `pollAppleMusicPosition()`の既存1.5秒間隔ポーリングに相乗りし上限3回まで再試行。 + コミット`1e7ba74`。 + +Phase B7は6タスク全完了。実機検証は次回実機起動時に通しで実施予定 +(歌詞ボックスの垂直/水平センタリング、シークバー間隔、バッジ、Spotify/Apple Music +アートワーク安定性を重点確認)。 + +### Phase B8: 歌詞ウィンドウ表示化 + 垂直センタリング再修正 + ヘッダー整理(2026-07-31〜) + +Phase B7の実機検証で、歌詞ボックスの垂直中央がまだズレている(今度は下寄り、ヘッダー分の高さを +考慮していなかった)、歌詞前後の行が透けて見える(`.mask`によるフェードが実質縁取りのみで +opacityが0にならない設計だった)、シークバーがまだアートワークに近すぎる(非対称パディング)、 +再生元バッジ拡大に伴う曲名エリアとの近接、ヘッダーの閉じるボタンが不要、ModuleSwitcherの +アイコン間隔が広すぎる、の6件の指摘があった。詳細な真因分析は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B8セクション参照。 + +- [x] B8-1: 歌詞ウィンドウ表示化 — `LyricsView.swift`をScrollView+mask方式から固定4行の + ウィンドウ表示に作り替え、前後行の透け見えを根治。コミット`f6a4525`。 +- [x] B8-2: 歌詞ボックスの垂直センタリング再修正 — `SurfaceMetrics.headerHeight`定数を追加し + `RichHomeView.swift`のcenterColumnに`.offset(y: -headerHeight/2)`でヘッダー分を補正。 + コミット`a4ed7e4`。 +- [x] B8-3: シークバーをartwork行とcontrolsSectionの中間に揃える — + `progressSection`の`.padding(.top, 8)` → `.padding(.vertical, 8)`。コミット`34f1e60`。 +- [x] B8-4: trackInfoを上にずらす+アルバム名の改行制限撤廃 — 上側Spacer削除、 + アルバムの`.lineLimit(2)`削除。コミット`c261396`。 +- [x] B8-5: ヘッダーの閉じるボタン削除(`IslandTopBar.swift`)。コミット`89632df`。 +- [x] B8-6: ModuleSwitcherのアイコン間隔を詰める(spacing 24→8)。コミット`22a0de7`。 + +Phase B8は6タスク全完了。実機検証は次回実機起動時に通しで実施予定 +(歌詞の垂直位置・4行固定表示・シークバー位置・trackInfo位置・ヘッダーの見た目を重点確認)。 + +### Phase B9: CFBundleIdentifier欠落修正 + trackInfo位置微調整 + 歌詞遷移速度調整(2026-07-31〜) + +Phase B8実機検証で「`just run`起動だとアートワーク取得・コントロールボタンが効かないが、 +Command+Space直接起動だとどちらも動く」という不可解な報告があった。調査の結果、 +`perch/Resources/Info.plist`に`CFBundleIdentifier`が定義されておらず、ビルドごとに異なる +仮識別子・署名アイデンティティが生成されていたことが判明——macOSのTCC(Automation権限)は +署名アイデンティティ単位でも権限を区別するため、`/Applications`のad-hoc版と`run.sh`が起動する +開発証明書版が別アプリとして扱われ、片方にしかSpotify/Music操作の許可が付与されていなかった。 +詳細は`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B9セクション参照。 + +- [x] B9-1: `perch/Resources/Info.plist`に`CFBundleIdentifier` + (`$(PRODUCT_BUNDLE_IDENTIFIER)`)を追加——起動方法によるアートワーク/コントロール + 不整合の根治。実機で`Identifier=com.tukuyomi032.perch`への反映を確認済み。 + コミット`9163764`。 +- [x] B9-2: trackInfoの垂直位置を10pt下げる(Phase B8で上に寄せすぎた)。コミット`ec71b55`。 +- [x] B9-3: 歌詞のアクティブ行切り替え・ウィンドウ遷移アニメーションを緩やかにする + (spring response 0.40→0.55、easeInOut duration 0.4→0.55)。コミット`ea8afe3`。 + +Phase B9は3タスク全完了。B9-1は過去に断片化したTCC Automation権限までは自動修復しないため、 +実機検証時に再度の許可ダイアログが出る可能性がある旨をユーザーに案内する必要あり。 + +### Phase B10: Automation権限プロンプト欠落修正 + AppleScript失敗の診断強化 + 歌詞左寄せ(2026-07-31〜) + +Phase B9でCFBundleIdentifierを修正した後、`just run`・直接起動どちらでもアートワーク取得・ +再生コントロールが全く動作しなくなった。調査の結果、`perch/Resources/Info.plist`に +`NSAppleEventsUsageDescription`が無く、macOSがAutomation権限プロンプト自体を表示できず +AppleScript呼び出しが即座に権限拒否(-1743)で失敗する状態だったことが判明。加えて +AppleScript失敗時のログが一切無く原因切り分けができなかったため診断ログを追加する。 +歌詞テキストの水平配置も中央揃え→左寄せへの変更依頼があった。詳細は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B10セクション参照。 + +- [x] B10-1: `NSAppleEventsUsageDescription`をInfo.plistに追加 + (Automation権限プロンプトが表示されない根本原因の修正)。実機ビルドで反映確認済み。 + コミット`155b74b`。 +- [x] B10-2: `NowPlayingManager.runAppleScript`にエラー診断ログを追加。コミット`d8f1b21`。 +- [x] B10-3: `ArtworkFetcher`に`Logger`を追加し各AppleScript失敗パスにログを追加。 + コミット`3f9efe8`。 +- [x] B10-4: 歌詞テキストを中央揃え→左寄せに変更(`LyricsView.swift`)。コミット`a78aabd`。 + +Phase B10は4タスク全完了。B10-1は実機で許可ダイアログが表示され、許可後にアートワーク取得・ +コントロールボタンが動作することの確認が必要。 + +### Phase B11: 設定画面「権限」タブ新設(2026-07-31〜) + +Phase B10の`NSAppleEventsUsageDescription`追加後も実機でAutomation権限エラー(-1743)が +継続。TCCの既知の挙動(コード署名が変わると過去の許可/拒否記録が新しいビルドに引き継がれず、 +かつOSが自動で再プロンプトを出さない)が原因と判明。AskUserQuestionで相談の結果、設定画面に +「権限」タブを新設し、Calendar/Screen Recording/Automation(Spotify・Apple Music)の状態確認・ +付与を行えるようにする方針で合意。詳細は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B11セクション参照。 + +**OSレベルの制約(コードでは解決不可)**: 既に拒否済みの権限はアプリから再プロンプトを強制 +できない。ユーザーには本タスク実装を待たずにシステム設定でMusicトグルをオフ→オン、または +`tccutil reset AppleEvents com.tukuyomi032.perch`実行後の再起動を案内済み。 + +- [x] B11-1: `PermissionsStore`新規実装(Calendar/Screen Recording/Automation×2の状態確認・ + 付与ロジック)。コミット`a039945`。 +- [x] B11-2: `SettingsView`に`PermissionsTab`追加。コミット`364997c`。 +- [x] B11-3: ローカライズ文字列追加(項目名・ボタン文言)。コミット`475e133`。 + +Phase B11は3タスク全完了。実機検証時、Automation - Apple Musicが「拒否済み」表示になっている +はずなので、「システム設定を開く」ボタンでAutomationペインが実際に開くこと、システム設定側で +トグルをオフ→オンにした後「権限」タブを開き直すと状態が更新されることを確認する。 + +### Phase B12: 設定ウィンドウ拡幅 + 歌詞を中央揃えに戻してフォント拡大(2026-07-31〜) + +Phase B11でタブが7つに増えたが`SettingsView.swift`のウィンドウ幅(420pt)が据え置きのままで、 +「権限」「アップデート」タブが`>>`オーバーフローに追いやられ開けない状態になっていた。あわせて +Phase B10で左寄せにした歌詞テキストを中央揃えに戻し、フォントサイズも拡大する。詳細は +`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`のPhase B12セクション参照。 + +- [x] B12-1: 設定ウィンドウの幅を拡大しタブ折り返しを解消(420pt→600pt)。コミット`43cbe3c`。 +- [x] B12-2: 歌詞テキストを左寄せ→中央揃えに戻す。コミット`516ee86`。 +- [x] B12-3: 歌詞フォントサイズを13pt→15ptに拡大。コミット`3007938`。 + +Phase B12は3タスク全完了。実機検証時、設定画面7タブ全てがオーバーフロー無しで開けること、 +歌詞が中央揃え・大きめフォントで表示され`lyricsColumnHeight`(130pt)枠からはみ出していないことを +確認する。 + +### Phase B13: Now Playingソース排他選択化 + 起動時アクティブ検知 + PR作成・マージ(2026-07-31〜) + +設定画面のNow Playingソーストグル(Spotify/Apple Music/YouTube Music)が実際の動作に反映 +されていない、Atoll型の排他Picker(自動含む4択)にしたい、Perch起動時に既に再生中の音楽が +検知されない(一度停止するまで検知されない)、の3件を修正。加えてPhase B12のフォントサイズ +拡大で歌詞の長い行が省略記号で切り詰められる問題(`lineLimit`緩和で対応)も修正。全完了後PRを +作成しmainへマージする。詳細は`~/.claude/plans/phasec-phaseb-b-docs-playful-cocoa.md`の +Phase B13セクション参照。 + +- [x] B13-1: 歌詞`lineLimit`緩和(省略記号切り詰めの解消)。コミット`e036b21`。 +- [x] B13-2〜7: `NowPlayingSourcePreference`新規enum + `Defaults.Key`追加 + 旧3Bool削除、 + `SettingsView`のNowPlayingTabを排他Pickerに置き換え、`applyState`の排他ゲート化、 + 設定変更の即時反映(Defaults監視+currentStateクリア)、起動時・切替時のアクティブ検知 + (Spotify/Apple Music/YouTube Music)、ローカライズ文字列更新。一体の変更のため + 1コミットにまとめた。コミット`982809c`。 +- [x] B13-3(移行処理+テスト、上記とは別コミット): 旧3Boolトグルから + `preferredNowPlayingSource`への移行処理を追加、`PreferencesMigrationTests`に + 純粋関数マッピング・移行実行・冪等性のテストを追加。コミット`3d2e8b3`。 +- [ ] B13-9: PR作成 → レビュー登録 → マージ + +Phase B4〜B13の一連の実機フィードバック対応はこれでB13-9(PR作成・マージ)を残すのみ。 +次フェーズ候補の棚卸し: Phase D(バッテリー監視)・Phase E(WiFi拡張)・Phase F(メディア +プレイヤー)はいずれも「未着手・タスク分割のみ」で緊急性なし。**Phase C(波形の実音キャプチャ +修理)はC1・C2のみ完了・C0とC3〜C9が未着手**——ユーザー希望のオーディオスペクトラム完成と +一致する、優先度上も次に着手して差し支えない状態。 + ### Phase D: バッテリー監視・アニメーション本格実装(未着手・タスク分割のみ) **参照**: `docs/macOS-Battery-Monitoring-Animation-Handbook-ja.md`(全39章、 diff --git a/perch/Core/NowPlayingSourcePreference.swift b/perch/Core/NowPlayingSourcePreference.swift new file mode 100644 index 0000000..5112165 --- /dev/null +++ b/perch/Core/NowPlayingSourcePreference.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Which Now Playing source(s) Perch pays attention to, set from Settings' Now Playing tab. +/// +/// `.auto` keeps the original behavior — whichever source is actually playing wins, via +/// `NowPlayingManager`'s existing priority arbitration (`sourcePriority(_:)`). Picking a +/// specific source makes it exclusive: activity from every other source is ignored +/// entirely, matching the single-select "Music Source" picker in comparable apps. +enum NowPlayingSourcePreference: String, CaseIterable, Sendable, Identifiable { + case auto + case spotify + case appleMusic + case youTubeMusic + + nonisolated var id: Self { self } + + nonisolated var displayNameKey: String { + switch self { + case .auto: "settings.nowplaying_source.auto" + case .spotify: "settings.spotify" + case .appleMusic: "settings.apple_music" + case .youTubeMusic: "settings.youtube_music" + } + } + + /// The `MusicSource` this preference restricts activity to, or `nil` for `.auto` + /// (no restriction — every source is allowed through). + nonisolated var exclusiveSource: MusicSource? { + switch self { + case .auto: nil + case .spotify: .spotify + case .appleMusic: .appleMusic + case .youTubeMusic: .youTubeMusic + } + } +} diff --git a/perch/Core/Preferences.swift b/perch/Core/Preferences.swift index c42bb77..77b09fb 100644 --- a/perch/Core/Preferences.swift +++ b/perch/Core/Preferences.swift @@ -6,15 +6,18 @@ import Foundation /// in that file, so the vendored-adjacent source stays free of Perch's persistence choice. extension ScreenPreference: Defaults.Serializable {} extension UpdateChannel: Defaults.Serializable {} +extension NowPlayingSourcePreference: Defaults.Serializable {} extension Defaults.Keys { static let launchAtLogin = Key("launchAtLogin", default: false) static let showInAllSpaces = Key("showInAllSpaces", default: true) static let autoCollapseDelay = Key("autoCollapseDelay", default: 3.0) static let showNowPlayingSource = Key("showNowPlayingSource", default: false) - static let enableSpotify = Key("enableSpotify", default: true) - static let enableAppleMusic = Key("enableAppleMusic", default: true) - static let enableYouTubeMusic = Key("enableYouTubeMusic", default: true) + /// Replaces the old `enableSpotify`/`enableAppleMusic`/`enableYouTubeMusic` triple of + /// independent toggles; existing installs are migrated once at launch by + /// `PreferencesMigration`. + static let preferredNowPlayingSource = Key( + "preferredNowPlayingSource", default: .auto) static let languageCode = Key("languageCode", default: "en") static let updateChannel = Key("updateChannel", default: .stable) static let aiRefreshInterval = Key("aiRefreshInterval", default: .fiveMinutes) @@ -22,7 +25,7 @@ extension Defaults.Keys { /// installs are migrated once at launch by `PreferencesMigration`. static let islandChromeStyle = Key("islandChromeStyle", default: .notch) - /// Rich (Atoll-inspired, default) vs Minimal (the original preset-driven widget list). + /// Rich (the fuller default layout) vs Minimal (the original preset-driven widget list). static let uiMode = Key("uiMode", default: .rich) /// Which display the island lives on. Resolved through `ScreenLocator` and handed to diff --git a/perch/Core/PreferencesMigration.swift b/perch/Core/PreferencesMigration.swift index 195efb7..c46313e 100644 --- a/perch/Core/PreferencesMigration.swift +++ b/perch/Core/PreferencesMigration.swift @@ -27,15 +27,66 @@ enum PreferencesMigration { /// tinted the capsule `CompactPillView` drew, and the backdrop is now the vendored /// surface's, chosen by `NookBridge.makeBackdrop(reduceTransparency:)`. static let pillBackgroundStyle = "pillBackgroundStyle" + /// The three independent `Bool` toggles (each defaulted `true`), replaced by the + /// single `preferredNowPlayingSource` picker. + static let enableSpotify = "enableSpotify" + static let enableAppleMusic = "enableAppleMusic" + static let enableYouTubeMusic = "enableYouTubeMusic" } /// Runs every pending migration. Call once, early, before any `Defaults[...]` read that /// a migration is meant to populate. static func runAll(in store: UserDefaults = .standard) { migrateIslandChromeStyle(in: store) + migrateNowPlayingSourceToggles(in: store) removeRetiredKeys(in: store) } + /// Carries the three legacy source toggles over to `preferredNowPlayingSource`, then + /// drops them. + /// + /// Gated on "at least one of the three was ever explicitly written," the same shape as + /// ``migrateIslandChromeStyle(in:)`` — the toggles were removed from `Defaults.Keys` in + /// this release, so `register(defaults:)` no longer runs for them and `object(forKey:)` + /// is `nil` unless the user actually flipped one in a previous build. A user who never + /// touched any of them had the old all-enabled default, which is exactly what `.auto` + /// means now, so there is nothing to migrate. + static func migrateNowPlayingSourceToggles(in store: UserDefaults = .standard) { + guard + store.object(forKey: LegacyKey.enableSpotify) != nil + || store.object(forKey: LegacyKey.enableAppleMusic) != nil + || store.object(forKey: LegacyKey.enableYouTubeMusic) != nil + else { return } + + let migrated = Self.migrateSourcePreference( + spotifyEnabled: store.object(forKey: LegacyKey.enableSpotify) as? Bool ?? true, + appleMusicEnabled: store.object(forKey: LegacyKey.enableAppleMusic) as? Bool ?? true, + youTubeMusicEnabled: store.object(forKey: LegacyKey.enableYouTubeMusic) as? Bool ?? true + ) + store.set(migrated.rawValue, forKey: Defaults.Keys.preferredNowPlayingSource.name) + + store.removeObject(forKey: LegacyKey.enableSpotify) + store.removeObject(forKey: LegacyKey.enableAppleMusic) + store.removeObject(forKey: LegacyKey.enableYouTubeMusic) + } + + /// Pure, so the mapping is unit-testable without touching `UserDefaults`. Exactly one + /// enabled source carries over as that explicit choice; the old default (all three + /// enabled) and any other combination (two enabled, none enabled — states the toggle UI + /// could reach but that have no equivalent in the new four-way picker) fall back to + /// `.auto`, which is strictly more permissive than any of those combinations and never + /// drops activity the user was seeing. + nonisolated static func migrateSourcePreference( + spotifyEnabled: Bool, appleMusicEnabled: Bool, youTubeMusicEnabled: Bool + ) -> NowPlayingSourcePreference { + switch (spotifyEnabled, appleMusicEnabled, youTubeMusicEnabled) { + case (true, false, false): return .spotify + case (false, true, false): return .appleMusic + case (false, false, true): return .youTubeMusic + default: return .auto + } + } + /// Carries a persisted `notchSimulationMode` over to `islandChromeStyle`, then drops /// the legacy key. /// diff --git a/perch/Core/UIMode.swift b/perch/Core/UIMode.swift index 89276c9..c2a89fa 100644 --- a/perch/Core/UIMode.swift +++ b/perch/Core/UIMode.swift @@ -1,11 +1,11 @@ import Defaults import Foundation -/// Whether the expanded island shows the Atoll-inspired rich layout or the existing +/// Whether the expanded island shows the fuller rich layout or the existing /// preset-driven widget list. /// /// `.rich` is the default: a fixed layout (now-playing activity + calendar, see -/// `AtollStyleExpandedView`) that does not expose preset switching — there is nothing to +/// `RichHomeView`) that does not expose preset switching — there is nothing to /// switch between, since the layout is not preset-driven. `.minimal` keeps the original /// `PresetTabBar` + `WidgetRegistry`-driven experience for users who prefer it. enum UIMode: String, Codable, CaseIterable, Sendable, Defaults.Serializable, Defaults.PreferRawRepresentable { diff --git a/perch/Features/Calendar/CalendarStandaloneView.swift b/perch/Features/Calendar/CalendarStandaloneView.swift index 175e5d6..e139990 100644 --- a/perch/Features/Calendar/CalendarStandaloneView.swift +++ b/perch/Features/Calendar/CalendarStandaloneView.swift @@ -5,7 +5,7 @@ import SwiftUI /// docs/macOS-Expanded-Surface-Layout-Handbook-ja.md §13 ("Standalone Calendar"): a /// month grid (with day tap-to-select) on the left, the selected day's events on the /// right. Display-only — `store`'s fetch lifecycle belongs to the parent -/// (`AtollStyleExpandedView`), same pattern as `NowPlayingLyricsColumn`. +/// (`RichHomeView`), same pattern as `NowPlayingLyricsColumn`. /// /// Supersedes the old `CalendarMonthColumn` (hover-to-reveal grid, today-only /// highlight, no selection) — that shape only made sense as a narrow column sharing diff --git a/perch/Features/Calendar/TodayEventsColumn.swift b/perch/Features/Calendar/TodayEventsColumn.swift index da4c9ba..e7c26c4 100644 --- a/perch/Features/Calendar/TodayEventsColumn.swift +++ b/perch/Features/Calendar/TodayEventsColumn.swift @@ -3,7 +3,7 @@ import SwiftUI /// Rich mode's center column when there's no now-playing content to show (no music, or /// music with no lyrics): today's calendar events, or the calendar permission state. /// Display-only — `store`'s fetch lifecycle belongs to the parent -/// (`AtollStyleExpandedView`), same pattern as `NowPlayingLyricsColumn`. +/// (`RichHomeView`), same pattern as `NowPlayingLyricsColumn`. struct TodayEventsColumn: View { let store: CalendarStore diff --git a/perch/Features/NowPlaying/ArtworkFetcher.swift b/perch/Features/NowPlaying/ArtworkFetcher.swift index 8dcb37b..7b8e719 100644 --- a/perch/Features/NowPlaying/ArtworkFetcher.swift +++ b/perch/Features/NowPlaying/ArtworkFetcher.swift @@ -1,6 +1,7 @@ // perch/Features/NowPlaying/ArtworkFetcher.swift @preconcurrency import AppKit import Foundation +import Logging /// Fetches album artwork via AppleScript for Spotify and Apple Music. /// `actor` isolation serializes fetches so NSAppleScript is never called concurrently. @@ -9,6 +10,11 @@ actor ArtworkFetcher { private init() {} private var lastSpotifyURL: String = "" + private let logger: Logger = { + var logger = Logger(label: "com.tukuyomi032.perch.ArtworkFetcher") + logger.logLevel = .debug + return logger + }() // MARK: - Spotify @@ -25,9 +31,12 @@ actor ArtworkFetcher { urlString != lastSpotifyURL, let url = URL(string: urlString) else { return nil } - lastSpotifyURL = urlString do { let (data, _) = try await URLSession.shared.data(from: url) + // Only recorded once the download actually succeeds — recording it earlier + // meant a single transient failure (e.g. startup network flakiness) marked the + // track as "tried" forever, since this same URL would never be attempted again. + lastSpotifyURL = urlString return data } catch { return nil @@ -38,7 +47,8 @@ actor ArtworkFetcher { /// Returns artwork data by reading binary image data directly from the AppleScript descriptor. func fetchAppleMusicArtworkData() async -> Data? { - await Task.detached { + let logger = logger + return await Task.detached { var error: NSDictionary? let script = NSAppleScript( source: """ @@ -49,9 +59,21 @@ actor ArtworkFetcher { return art end tell """) - guard let result = script?.executeAndReturnError(&error), error == nil else { return nil } - let data = result.data - guard !data.isEmpty else { return nil } + let result = script?.executeAndReturnError(&error) + if let error { + // -1743 (errAEEventNotPermitted) means Automation permission for Music.app + // hasn't been granted — logged distinctly so a track that simply has no + // artwork isn't confused with a permission outage affecting every track. + let code = error[NSAppleScript.errorNumber] as? Int ?? 0 + logger.debug( + "fetchAppleMusicArtworkData failed [\(code)]: \(error[NSAppleScript.errorMessage] as? String ?? "unknown")" + ) + return nil + } + guard let data = result?.data, !data.isEmpty else { + logger.debug("fetchAppleMusicArtworkData: no artwork data on current track") + return nil + } return data }.value } @@ -99,11 +121,18 @@ actor ArtworkFetcher { // MARK: - Private private func runAppleScript(_ source: String) async -> String? { - await Task.detached { + let logger = logger + return await Task.detached { var error: NSDictionary? let script = NSAppleScript(source: source) let result = script?.executeAndReturnError(&error) - guard error == nil else { return nil } + if let error { + let code = error[NSAppleScript.errorNumber] as? Int ?? 0 + logger.debug( + "runAppleScript failed [\(code)]: \(error[NSAppleScript.errorMessage] as? String ?? "unknown")" + ) + return nil + } return result?.stringValue }.value } diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 69ecc7d..69e1287 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -4,7 +4,9 @@ import SwiftUI struct LyricsView: View { let lines: [LyricsLine] let elapsedTime: TimeInterval - var fontSize: CGFloat = 13 + var fontSize: CGFloat = 15 + + private static let visibleLineCount = 4 private var activeIndex: Int? { guard !lines.isEmpty else { return nil } @@ -14,62 +16,48 @@ struct LyricsView: View { return nil } + /// Start of the fixed 4-line window shown on screen. A window, not a scroll + /// position, so the column only ever renders whole lines — no partial fade-in/out + /// line peeking past its edges the way a continuously-scrolled view would. Biases + /// the active line to the window's second slot (one line of context above, two + /// lines of what's coming below), clamping at both ends of `lines`. + private var windowStart: Int { + guard !lines.isEmpty else { return 0 } + let count = min(Self.visibleLineCount, lines.count) + let active = activeIndex ?? 0 + let desired = active - 1 + return max(0, min(desired, lines.count - count)) + } + + private var window: [(offset: Int, element: LyricsLine)] { + let count = min(Self.visibleLineCount, lines.count) + let start = windowStart + return Array(Array(lines.enumerated())[start..<(start + count)]) + } + var body: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(alignment: .center, spacing: 10) { - Color.clear.frame(height: 6) - ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in - Text(line.text) - .font(.system(size: fontSize, weight: .regular)) - .foregroundStyle(.white.opacity(lineOpacity(idx))) - .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) - .multilineTextAlignment(.center) - .lineLimit(2) - .frame(maxWidth: .infinity) - .animation(.spring(response: 0.40, dampingFraction: 0.82), value: activeIndex) - .id(line.id) - } - Color.clear.frame(height: 6) - } - .padding(.horizontal, 16) - .frame(maxWidth: .infinity) - } - .frame(maxWidth: .infinity) - .onChange(of: activeIndex) { _, newIdx in - guard let newIdx else { return } - withAnimation(.easeInOut(duration: 0.4)) { - proxy.scrollTo(lines[newIdx].id, anchor: UnitPoint(x: 0.5, y: 0.35)) - } - } - .onAppear { - guard let idx = activeIndex else { return } - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - proxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.35)) - } + VStack(alignment: .center, spacing: 10) { + ForEach(window, id: \.element.id) { idx, line in + Text(line.text) + .font(.system(size: fontSize, weight: .regular)) + .foregroundStyle(.white.opacity(lineOpacity(idx))) + .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) + .multilineTextAlignment(.center) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .center) + .animation(.spring(response: 0.55, dampingFraction: 0.82), value: activeIndex) } } - .mask( - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black, location: 0.05), - .init(color: .black, location: 0.92), - .init(color: .clear, location: 1), - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .id(windowStart) + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut(duration: 0.55), value: windowStart) } private func lineOpacity(_ idx: Int) -> Double { guard let active = activeIndex else { return 0.40 } if idx == active { return 1.0 } - let distance = abs(idx - active) - if distance == 1 { return 0.65 } - if distance == 2 { return 0.45 } - return max(0.20, 0.32 - Double(distance - 3) * 0.05) + return abs(idx - active) == 1 ? 0.65 : 0.40 } } diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index 440166a..656750e 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -122,39 +122,36 @@ struct NowPlayingCard: View { /// card appears (handbook §11.2) so it doesn't compete with the artwork itself for /// attention the instant the card mounts. private var sourceBadge: some View { - Circle() - .fill(.black.opacity(0.7)) - .frame(width: 36, height: 36) - .overlay { - if let assetName = sourceLogoAssetName(state.source) { - Image(assetName) - .resizable() - .renderingMode(.original) - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - } else { - Image(systemName: state.source.symbolName) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white) - } + Group { + if let assetName = sourceLogoAssetName(state.source) { + Image(assetName) + .resizable() + .renderingMode(.original) + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + } else { + Image(systemName: state.source.symbolName) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) } - .scaleEffect(sourceBadgeVisible ? 1 : 0.6) - .opacity(sourceBadgeVisible ? 1 : 0) - .accessibilityLabel(state.source.displayName) - .task(id: state.artworkID) { - sourceBadgeVisible = false - try? await Task.sleep(for: .milliseconds(300)) - withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) { - sourceBadgeVisible = true - } + } + .scaleEffect(sourceBadgeVisible ? 1 : 0.6) + .opacity(sourceBadgeVisible ? 1 : 0) + .accessibilityLabel(state.source.displayName) + .task(id: state.artworkID) { + sourceBadgeVisible = false + try? await Task.sleep(for: .milliseconds(300)) + withAnimation(.spring(response: 0.35, dampingFraction: 0.65)) { + sourceBadgeVisible = true } + } } // MARK: - Track Info private var trackInfo: some View { VStack(alignment: .leading, spacing: 4) { - Spacer(minLength: 0) Text(state.title) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) @@ -167,10 +164,10 @@ struct NowPlayingCard: View { Text(album) .font(.system(size: 11, weight: .regular)) .foregroundStyle(.white.opacity(0.4)) - .lineLimit(2) } Spacer(minLength: 0) } + .padding(.top, 10) } // MARK: - Progress @@ -233,6 +230,7 @@ struct NowPlayingCard: View { } } } + .padding(.vertical, 8) } private var scrubElapsedLabel: String { diff --git a/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift b/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift index 94b3f97..df2767c 100644 --- a/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift +++ b/perch/Features/NowPlaying/NowPlayingLyricsColumn.swift @@ -2,10 +2,10 @@ import SwiftUI /// Rich mode's center column: complex multi-line lyrics, kept alongside the artwork/ /// controls column rather than the single-line afterthought other Dynamic Island style -/// apps show. Display-only — the caller (`AtollStyleExpandedView`) owns the fetch +/// apps show. Display-only — the caller (`RichHomeView`) owns the fetch /// lifecycle since this column's data outlives any one `NowPlayingCard` render. /// -/// Only ever shown while lyrics exist or are loading — `AtollStyleExpandedView` falls +/// Only ever shown while lyrics exist or are loading — `RichHomeView` falls /// back to `TodayEventsColumn` once loading finishes with nothing found, so there's no /// "no lyrics" state to render here. struct NowPlayingLyricsColumn: View { @@ -20,7 +20,7 @@ struct NowPlayingLyricsColumn: View { LyricsView( lines: lyrics, elapsedTime: state.liveElapsed(at: ctx.date) ?? 0, - fontSize: 13 + fontSize: 15 ) } } else { diff --git a/perch/Features/NowPlaying/NowPlayingManager.swift b/perch/Features/NowPlaying/NowPlayingManager.swift index 2a9c1c7..6beeefb 100644 --- a/perch/Features/NowPlaying/NowPlayingManager.swift +++ b/perch/Features/NowPlaying/NowPlayingManager.swift @@ -45,8 +45,18 @@ final class NowPlayingManager { private nonisolated(unsafe) var amPositionTask: Task? private nonisolated(unsafe) var lyricsPrefetchTask: Task? private nonisolated(unsafe) var mediaRemoteStateTask: Task? + // Same non-Sendability shape as `IslandHost`'s preference observations — see its + // `chromeStyleObservation` comment. + private nonisolated(unsafe) var preferredSourceObservation: (any Defaults.Observation)? private var isYTMPolling: Bool = false private var wasYTMPolling: Bool = false + // Bounded retry for Apple Music artwork: `com.apple.Music.playerInfo` only fires on + // Play/Pause toggle or track change (unlike Spotify's frequent position notifications), + // so a single failed AppleScript fetch right after a track change (e.g. Music.app hasn't + // cached a streaming track's artwork locally yet) had no further chance to retry. These + // piggyback on the existing 1.5s position poll instead of a bespoke timer. + private var appleMusicArtworkRetryTrack: String = "" + private var appleMusicArtworkRetryCount: Int = 0 private let logger: Logger = { var logger = Logger(label: "com.tukuyomi032.perch.NowPlayingManager") logger.logLevel = .debug @@ -97,6 +107,27 @@ final class NowPlayingManager { } } } + + // Every observer above is purely reactive — Spotify/Music's DistributedNotification + // only fires on a state *transition*, so a track already playing when Perch launches + // produces no event until the user pauses/resumes it. Seed currentState with an + // active query instead of waiting for that. + Task { @MainActor [weak self] in await self?.refreshActiveState() } + + // Switching the preferred source in Settings should take effect immediately: drop + // whatever's showing if it's no longer the selected source, then actively re-detect + // the new selection rather than waiting for its next notification. + preferredSourceObservation = Defaults.observe(.preferredNowPlayingSource) { [weak self] change in + Task { @MainActor [weak self] in + guard let self else { return } + if let exclusiveSource = change.newValue.exclusiveSource, + self.currentState?.source != exclusiveSource + { + self.currentState = nil + } + await self.refreshActiveState() + } + } } deinit { @@ -107,9 +138,99 @@ final class NowPlayingManager { amPositionTask?.cancel() lyricsPrefetchTask?.cancel() mediaRemoteStateTask?.cancel() + preferredSourceObservation?.invalidate() Task { @MainActor in MediaRemoteBridge.shared.stop() } } + // MARK: - Active State Detection + + /// Actively queries whichever source(s) the current preference allows, so a track + /// already playing before Perch started (or before a source switch) is picked up + /// immediately instead of waiting for the next play/pause/track-change notification. + private func refreshActiveState() async { + let preference = Defaults[.preferredNowPlayingSource] + if let exclusiveSource = preference.exclusiveSource { + if let state = await detectActiveState(for: exclusiveSource) { + applyState(state, source: state.source.rawValue) + } + return + } + // `.auto`: try every source; applyState's existing priority arbitration + // (`sourcePriority(_:)`) resolves which one wins if more than one is playing. + for source in [MusicSource.spotify, .appleMusic, .youTubeMusic] { + if let state = await detectActiveState(for: source) { + applyState(state, source: state.source.rawValue) + } + } + } + + private func detectActiveState(for source: MusicSource) async -> NowPlayingState? { + switch source { + case .spotify: return await detectActiveSpotifyState() + case .appleMusic: return await detectActiveAppleMusicState() + case .youTubeMusic: return detectActiveYouTubeMusicState() + case .mrMediaRemote: return nil + } + } + + private func detectActiveSpotifyState() async -> NowPlayingState? { + let script = """ + tell application "Spotify" + if not running then return "NOTRUNNING" + if player state is stopped then return "STOPPED" + set st to player state as string + set n to name of current track + set a to artist of current track + set al to album of current track + set d to duration of current track + set p to player position + return st & "\\n" & n & "\\n" & a & "\\n" & al & "\\n" & (d as string) & "\\n" & (p as string) + end tell + """ + guard let result = await runAppleScript(script) else { return nil } + let lines = result.components(separatedBy: "\n") + guard lines.count >= 6, lines[0] != "NOTRUNNING", lines[0] != "STOPPED" else { return nil } + return NowPlayingState( + spotifyPlayerState: lines[0], title: lines[1], artist: lines[2], + album: lines[3].isEmpty ? nil : lines[3], + durationMs: Double(lines[4]), position: Double(lines[5]) + ) + } + + private func detectActiveAppleMusicState() async -> NowPlayingState? { + let script = """ + tell application "Music" + if not running then return "NOTRUNNING" + if player state is stopped then return "STOPPED" + set st to player state as string + set n to name of current track + set a to artist of current track + set al to album of current track + set d to duration of current track + return st & "\\n" & n & "\\n" & a & "\\n" & al & "\\n" & (d as string) + end tell + """ + guard let result = await runAppleScript(script) else { return nil } + let lines = result.components(separatedBy: "\n") + guard lines.count >= 5, lines[0] != "NOTRUNNING", lines[0] != "STOPPED" else { return nil } + // Music.app's AppleScript `duration` property is in seconds; the initializer expects + // the same millisecond convention as the "Total Time" notification field. + let totalTimeMs = Double(lines[4]).map { $0 * 1000 } + return NowPlayingState( + appleMusicPlayerState: lines[0], title: lines[1], artist: lines[2], + album: lines[3].isEmpty ? nil : lines[3], totalTime: totalTimeMs + ) + } + + /// `MediaRemoteBridge` already tracks the live state reactively (it's the source that + /// feeds `stateUpdates`), so this is a synchronous read rather than a new query — gated + /// on `isYTMPolling` the same way the stream consumer above is, so a stale state isn't + /// applied when no YTM-capable app is actually running. + private func detectActiveYouTubeMusicState() -> NowPlayingState? { + guard isYTMPolling else { return nil } + return MediaRemoteBridge.shared.currentState + } + // MARK: - Playback Controls func togglePlayPause() { @@ -418,11 +539,22 @@ final class NowPlayingManager { } private func runAppleScript(_ source: String) async -> String? { - await Task.detached { + let logger = logger + return await Task.detached { var error: NSDictionary? let script = NSAppleScript(source: source) let result = script?.executeAndReturnError(&error) - guard error == nil else { return nil } + if let error { + // -1743 (errAEEventNotPermitted) means Automation permission for this + // target app hasn't been granted — logged distinctly from other failures + // so a silent playback-control/artwork outage is diagnosable from Console + // instead of just "nothing happens". + let code = error[NSAppleScript.errorNumber] as? Int ?? 0 + logger.debug( + "runAppleScript failed [\(code)]: \(error[NSAppleScript.errorMessage] as? String ?? "unknown")" + ) + return nil + } return result?.stringValue }.value } @@ -473,6 +605,17 @@ final class NowPlayingManager { isPlaying: current.isPlaying, duration: current.duration, elapsedTime: position, timestamp: Date(), source: current.source ) + + guard current.artwork == nil else { return } + if appleMusicArtworkRetryTrack != current.title { + appleMusicArtworkRetryTrack = current.title + appleMusicArtworkRetryCount = 0 + } + guard appleMusicArtworkRetryCount < 3 else { return } + appleMusicArtworkRetryCount += 1 + Task { [weak self] in + await self?.fetchAndApplyArtwork(for: current) + } } // MARK: - MRMediaRemote fallback @@ -516,14 +659,13 @@ final class NowPlayingManager { // MARK: - State Application private func applyState(_ newState: NowPlayingState?, source: String) { - // Respect per-source enable settings - if let incoming = newState { - switch incoming.source { - case .spotify where !Defaults[.enableSpotify]: return - case .appleMusic where !Defaults[.enableAppleMusic]: return - case .youTubeMusic where !Defaults[.enableYouTubeMusic]: return - default: break - } + // `.auto` accepts every source (existing priority arbitration below decides which + // wins); a specific preference ignores every source but that one entirely. + if let incoming = newState, + let exclusiveSource = Defaults[.preferredNowPlayingSource].exclusiveSource, + incoming.source != exclusiveSource + { + return } // Prevent lower-priority source from clearing higher-priority active state. // MRMediaRemote returning nil (blocked on macOS 16) must not override Spotify. diff --git a/perch/Features/Permissions/PermissionsStore.swift b/perch/Features/Permissions/PermissionsStore.swift new file mode 100644 index 0000000..649522c --- /dev/null +++ b/perch/Features/Permissions/PermissionsStore.swift @@ -0,0 +1,139 @@ +import AppKit +import EventKit +import ScreenCaptureKit + +/// Holds and refreshes the state of every permission Perch's features depend on, for the +/// Settings "Permissions" tab. Each check is preflight-only (no OS prompt) so opening the +/// tab never itself triggers a dialog — `request(_:)` is the only thing that can prompt, +/// and only when the OS is actually able to (see `PermissionStatus.denied`'s doc comment). +@MainActor +@Observable +final class PermissionsStore { + enum PermissionStatus: Equatable { + /// Granted. + case authorized + /// Never asked yet — `request(_:)` can trigger a real OS prompt. + case notDetermined + /// Already answered "no" once. macOS will not show another system prompt for + /// this identity no matter how many times the app asks — the only way forward + /// is System Settings, which `request(_:)` opens directly to the right pane. + case denied + /// Automation only: the target app isn't running, so `AEDeterminePermissionToAutomateTarget` + /// can't determine a real status without launching it first. + case unknown + } + + enum Kind: String, CaseIterable, Identifiable { + case calendar + case screenRecording + case automationSpotify + case automationAppleMusic + + var id: String { rawValue } + + var displayName: String { + switch self { + case .calendar: L10n.string("settings.permissions.calendar") + case .screenRecording: L10n.string("settings.permissions.screen_recording") + case .automationSpotify: L10n.string("settings.permissions.automation_spotify") + case .automationAppleMusic: L10n.string("settings.permissions.automation_apple_music") + } + } + } + + private(set) var statuses: [Kind: PermissionStatus] = [ + .calendar: .notDetermined, + .screenRecording: .notDetermined, + .automationSpotify: .notDetermined, + .automationAppleMusic: .notDetermined, + ] + + private static let spotifyBundleID = "com.spotify.client" + private static let appleMusicBundleID = "com.apple.Music" + + /// Preflight-checks every permission without prompting for any of them. Safe to call + /// whenever the Permissions tab appears — e.g. `.onAppear` — to pick up changes made + /// in System Settings while Perch was already running. + func refreshAll() { + statuses[.calendar] = Self.mapCalendarStatus(EKEventStore.authorizationStatus(for: .event)) + statuses[.screenRecording] = CGPreflightScreenCaptureAccess() ? .authorized : .denied + statuses[.automationSpotify] = Self.automationStatus(for: Self.spotifyBundleID) + statuses[.automationAppleMusic] = Self.automationStatus(for: Self.appleMusicBundleID) + } + + /// Requests the given permission. If it's already `.denied`, no OS prompt is possible + /// any more — opens the relevant System Settings pane instead. Otherwise triggers the + /// real permission flow and updates `statuses` with the result. + func request(_ kind: Kind) async { + switch kind { + case .calendar: + guard statuses[.calendar] != .denied else { + Self.openSystemSettings(pane: "Privacy_Calendars") + return + } + let granted = (try? await EKEventStore().requestFullAccessToEvents()) ?? false + statuses[.calendar] = granted ? .authorized : .denied + + case .screenRecording: + guard statuses[.screenRecording] != .denied else { + Self.openSystemSettings(pane: "Privacy_ScreenCapture") + return + } + statuses[.screenRecording] = CGRequestScreenCaptureAccess() ? .authorized : .denied + + case .automationSpotify: + await requestAutomation(bundleID: Self.spotifyBundleID, kind: .automationSpotify) + + case .automationAppleMusic: + await requestAutomation(bundleID: Self.appleMusicBundleID, kind: .automationAppleMusic) + } + } + + private func requestAutomation(bundleID: String, kind: Kind) async { + guard statuses[kind] != .denied else { + Self.openSystemSettings(pane: "Privacy_Automation") + return + } + // Off the main actor: AEDeterminePermissionToAutomateTarget with askUserIfNeeded + // blocks on the system prompt. + let status = await Task.detached { + Self.automationStatus(for: bundleID, askUserIfNeeded: true) + }.value + statuses[kind] = status + } + + private static func mapCalendarStatus(_ status: EKAuthorizationStatus) -> PermissionStatus { + switch status { + case .notDetermined: .notDetermined + case .fullAccess: .authorized + case .restricted, .denied, .writeOnly: .denied + @unknown default: .denied + } + } + + /// `askUserIfNeeded: false` (the default, used by `refreshAll`) never shows a system + /// prompt — it only reads the current TCC record. Passing `true` (used by `request(_:)`) + /// lets the OS show its permission dialog when the status is genuinely undetermined. + nonisolated private static func automationStatus( + for bundleID: String, askUserIfNeeded: Bool = false + ) -> PermissionStatus { + guard var target = NSAppleEventDescriptor(bundleIdentifier: bundleID).aeDesc?.pointee else { + return .unknown + } + let result = AEDeterminePermissionToAutomateTarget( + &target, typeWildCard, typeWildCard, askUserIfNeeded) + switch result { + case noErr: return .authorized + case -1743: return .denied // errAEEventNotPermitted + case -1744: return .notDetermined // errAEEventWouldRequireUserConsent + case -600: return .unknown // procNotFound — target app isn't running + default: return .unknown + } + } + + private static func openSystemSettings(pane: String) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") + else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/perch/Island/IslandChromeStyle.swift b/perch/Island/IslandChromeStyle.swift index 5d0fb50..dfa4b33 100644 --- a/perch/Island/IslandChromeStyle.swift +++ b/perch/Island/IslandChromeStyle.swift @@ -1,8 +1,8 @@ import Defaults import Foundation -/// How the Perch island chrome presents itself: notch-attached (the default, "Atoll -/// 風" pseudo-notch regardless of whether the Mac has a physical notch) or a floating +/// How the Perch island chrome presents itself: notch-attached (the default, a +/// pseudo-notch pill regardless of whether the Mac has a physical notch) or a floating /// pill below the menu bar. /// /// Deliberately two-valued, not three. The vendored `NookPresentation` offers an @@ -19,8 +19,8 @@ import Foundation enum IslandChromeStyle: String, Codable, CaseIterable, Sendable, Defaults.Serializable, Defaults.PreferRawRepresentable { - /// Pseudo-notch chrome (Atoll-style), used whether or not the display has a - /// physical notch. The default. + /// Pseudo-notch chrome (a floating pill styled to sit flush with the display top), + /// used whether or not the display has a physical notch. The default. case notch /// A free-floating rounded pill just below the menu bar. case floating diff --git a/perch/Island/IslandHost.swift b/perch/Island/IslandHost.swift index 448651d..1269f63 100644 --- a/perch/Island/IslandHost.swift +++ b/perch/Island/IslandHost.swift @@ -58,7 +58,7 @@ final class IslandHost { // on it. // // `compactTopCornerRadius`/`compactBottomCornerRadius` round the compact pill - // further than the vendored default (6, 14) — an Atoll-inspired look. A `/vfr` + // further than the vendored default (6, 14) — a rounder pill look. A `/vfr` // pass over the user's reference recording didn't yield frame-accurate spring // constants (the capture's resolution/compression obscured the shell's exact // timing), so these — and `DesignSystem.shellOpen`/`shellClose` below — are the diff --git a/perch/Resources/Info.plist b/perch/Resources/Info.plist index fb34f94..e3a9220 100644 --- a/perch/Resources/Info.plist +++ b/perch/Resources/Info.plist @@ -2,6 +2,8 @@ + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleShortVersionString $(MARKETING_VERSION) CFBundleVersion @@ -18,6 +20,8 @@ l6IhQF6lojaWbwcObjcaHKIHC9LMuEvWDkh5dTLbmz4= NSCalendarsFullAccessUsageDescription Perch shows today's events in its expanded calendar widget. + NSAppleEventsUsageDescription + Perch controls Spotify and Music to show playback status and artwork, and to respond to its own play/pause and skip buttons. NSAppTransportSecurity NSExceptionDomains diff --git a/perch/Resources/en.lproj/Localizable.strings b/perch/Resources/en.lproj/Localizable.strings index f12e6ca..48669bd 100644 --- a/perch/Resources/en.lproj/Localizable.strings +++ b/perch/Resources/en.lproj/Localizable.strings @@ -10,6 +10,7 @@ "settings.nowplaying" = "Now Playing"; "settings.language" = "Language"; "settings.ai_usage" = "AI Usage"; +"settings.permissions" = "Permissions"; "settings.debug" = "Debug"; "settings.updates" = "Updates"; @@ -22,9 +23,24 @@ /* Settings — Now Playing */ "settings.sources" = "Sources"; +"settings.nowplaying_source" = "Music Source"; +"settings.nowplaying_source.auto" = "Automatic (any source)"; "settings.spotify" = "Spotify"; "settings.apple_music" = "Apple Music"; "settings.youtube_music" = "YouTube Music"; + +/* Settings — Permissions */ +"settings.permissions.section" = "Required Permissions"; +"settings.permissions.calendar" = "Calendar"; +"settings.permissions.screen_recording" = "Screen Recording"; +"settings.permissions.automation_spotify" = "Automation — Spotify"; +"settings.permissions.automation_apple_music" = "Automation — Apple Music"; +"settings.permissions.status.authorized" = "Granted"; +"settings.permissions.status.not_determined" = "Not requested yet"; +"settings.permissions.status.denied" = "Denied"; +"settings.permissions.status.unknown" = "Unknown (app not running)"; +"settings.permissions.request" = "Request"; +"settings.permissions.open_system_settings" = "Open System Settings"; "settings.show_source" = "Show music source in pill"; /* Settings — Language */ diff --git a/perch/Resources/ja.lproj/Localizable.strings b/perch/Resources/ja.lproj/Localizable.strings index 055fd72..8d2ce32 100644 --- a/perch/Resources/ja.lproj/Localizable.strings +++ b/perch/Resources/ja.lproj/Localizable.strings @@ -10,6 +10,7 @@ "settings.nowplaying" = "Now Playing"; "settings.language" = "言語"; "settings.ai_usage" = "AI使用状況"; +"settings.permissions" = "権限"; "settings.debug" = "デバッグ"; "settings.updates" = "アップデート"; @@ -22,9 +23,24 @@ /* Settings — Now Playing */ "settings.sources" = "ソース"; +"settings.nowplaying_source" = "音楽ソース"; +"settings.nowplaying_source.auto" = "自動(どのソースでも検知)"; "settings.spotify" = "Spotify"; "settings.apple_music" = "Apple Music"; "settings.youtube_music" = "YouTube Music"; + +/* Settings — Permissions */ +"settings.permissions.section" = "必要な権限"; +"settings.permissions.calendar" = "カレンダー"; +"settings.permissions.screen_recording" = "画面収録"; +"settings.permissions.automation_spotify" = "オートメーション — Spotify"; +"settings.permissions.automation_apple_music" = "オートメーション — Apple Music"; +"settings.permissions.status.authorized" = "許可済み"; +"settings.permissions.status.not_determined" = "未確認"; +"settings.permissions.status.denied" = "拒否済み"; +"settings.permissions.status.unknown" = "不明(アプリ未起動)"; +"settings.permissions.request" = "許可を求める"; +"settings.permissions.open_system_settings" = "システム設定を開く"; "settings.show_source" = "ピルに音楽ソースを表示"; /* Settings — Language */ diff --git a/perch/UI/ExpandedIslandView.swift b/perch/UI/ExpandedIslandView.swift index 81b78a0..b42cebd 100644 --- a/perch/UI/ExpandedIslandView.swift +++ b/perch/UI/ExpandedIslandView.swift @@ -17,7 +17,7 @@ struct ExpandedIslandView: View { VStack(spacing: 0) { IslandTopBar() // Preset switching is a Minimal-mode-only concept — Rich mode's Home module is - // a fixed layout (`AtollStyleExpandedView`), not preset-driven, so there is + // a fixed layout (`RichHomeView`), not preset-driven, so there is // nothing for a preset tab bar to switch between. if uiMode == .minimal, IslandModuleContent.showsPresetTabBar(for: appState.activeCard) { HStack { @@ -40,7 +40,7 @@ struct ExpandedIslandView: View { /// Rich mode's Home layout wants more room than Minimal mode's single-column widget /// stack or the AI Usage full-screen view, so only widen the floor when - /// `AtollStyleExpandedView` is actually what's rendering. + /// `RichHomeView` is actually what's rendering. private var minExpandedWidth: CGFloat { guard uiMode == .rich, IslandModuleContent.content(for: appState.activeCard) == .presetDriven else { return 420 } @@ -53,7 +53,7 @@ struct ExpandedIslandView: View { // MARK: - Module Content /// Routes to the active module's content. `.nowPlaying` (Home) renders - /// `AtollStyleExpandedView` in Rich mode or the active preset's widgets in Minimal + /// `RichHomeView` in Rich mode or the active preset's widgets in Minimal /// mode; `.aiUsage` bypasses both entirely and shows the full AI usage screen /// regardless of `uiMode` — it was never preset-driven to begin with. See /// `IslandModuleContent` for the pure mapping this switches on. @@ -62,7 +62,7 @@ struct ExpandedIslandView: View { switch IslandModuleContent.content(for: appState.activeCard) { case .presetDriven: if uiMode == .rich { - AtollStyleExpandedView() + RichHomeView() } else { presetContent } diff --git a/perch/UI/IslandTopBar.swift b/perch/UI/IslandTopBar.swift index 4327298..4713f5e 100644 --- a/perch/UI/IslandTopBar.swift +++ b/perch/UI/IslandTopBar.swift @@ -2,9 +2,9 @@ import SwiftUI /// The expanded island's top row: three zones per /// docs/macOS-Expanded-Surface-Layout-Handbook-ja.md §8 ("ヘッダーの3領域設計") — left -/// (close button + module switcher, `maxWidth: .infinity` leading), center (a reserved -/// gap the width of the physical/synthetic notch, so nothing renders under it), right -/// (`SystemStatusCluster`, `maxWidth: .infinity` trailing). +/// (module switcher, `maxWidth: .infinity` leading), center (a reserved gap the width of +/// the physical/synthetic notch, so nothing renders under it), right (`SystemStatusCluster`, +/// `maxWidth: .infinity` trailing). /// /// Modules reuse the existing `IslandCard` enum rather than a new type — `activeCard` /// was kept through Phase A specifically for this (see `AppState.activeCard`'s doc @@ -12,8 +12,6 @@ import SwiftUI /// are the only two buttons; `.fileShelf` / `.devStatus` / `.hud` stay unreachable until /// their features exist. struct IslandTopBar: View { - @Environment(AppState.self) private var appState - private static let modules: [IslandCard] = [.nowPlaying, .aiUsage] /// The vendored surface has no environment-exposed path to `Nook.notchSize` (only @@ -27,19 +25,11 @@ struct IslandTopBar: View { var body: some View { HStack(spacing: 0) { - HStack(spacing: 12) { - Button { - appState.collapse() - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - .imageScale(.medium) - } - .buttonStyle(.plain) - - ModuleSwitcher(modules: Self.modules) - } - .frame(maxWidth: .infinity, alignment: .leading) + // No explicit close button — moving the cursor off the expanded surface + // already collapses it (see `NookBridge`'s hover-exit handling), so a + // dedicated affordance here was redundant chrome. + ModuleSwitcher(modules: Self.modules) + .frame(maxWidth: .infinity, alignment: .leading) Color.clear .frame(width: notchReservationWidth) @@ -61,7 +51,7 @@ private struct ModuleSwitcher: View { @Namespace private var selectionNamespace var body: some View { - HStack(spacing: 24) { + HStack(spacing: 8) { ForEach(modules) { module in Button { withAnimation(DesignSystem.springAnimation) { diff --git a/perch/UI/AtollStyleExpandedView.swift b/perch/UI/RichHomeView.swift similarity index 68% rename from perch/UI/AtollStyleExpandedView.swift rename to perch/UI/RichHomeView.swift index b4702bd..8971bcc 100644 --- a/perch/UI/AtollStyleExpandedView.swift +++ b/perch/UI/RichHomeView.swift @@ -10,7 +10,7 @@ import SwiftUI /// /// Not preset-driven like Minimal mode's `presetContent` — this is a fixed layout, so /// there's nothing here for `PresetTabBar` to switch between (see `ExpandedIslandView`). -struct AtollStyleExpandedView: View { +struct RichHomeView: View { @Environment(AppState.self) private var appState @State private var contentVisible = false @State private var lyrics: [LyricsLine] = [] @@ -24,7 +24,12 @@ struct AtollStyleExpandedView: View { var body: some View { Group { if let state = currentState { - HStack(alignment: .top, spacing: 0) { + // `alignment: .center` (not `.top`) — centerColumn is height-locked to + // SurfaceMetrics.lyricsColumnHeight (130pt) while NowPlayingCard's natural + // height runs 220-260pt; top-aligning left the lyrics box pinned to the row's + // top edge with dead space below it instead of sitting centered against the + // taller card next to it. + HStack(alignment: .center, spacing: 0) { NowPlayingCard(state: state, manager: appState.nowPlayingManager) .frame(minWidth: 260, idealWidth: 300, alignment: .leading) @@ -39,7 +44,25 @@ struct AtollStyleExpandedView: View { centerColumn .frame(maxWidth: .infinity, alignment: .center) .frame(height: SurfaceMetrics.lyricsColumnHeight) + // Centering above is against NowPlayingCard's own height, but + // IslandTopBar sits above this whole row — the header's height + // visibly pulls the true center of the visible pill higher than + // NowPlayingCard's center alone. Nudge up by half the header's + // height to compensate. + .offset(y: -SurfaceMetrics.headerHeight / 2) } + // The vendored NookView sizes the whole expanded surface via `.fixedSize()`, + // which lays out this subtree against an *unconstrained* (nil) proposal — + // not just a one-time size query. Without an explicit idealWidth here, this + // HStack's own ideal width under that nil proposal is just NowPlayingCard's + // ideal (300pt) plus the lyrics text's natural width, typically well under + // SurfaceMetrics.baseContentWidth (640pt); ExpandedIslandView's outer + // `.frame(minWidth: 640)` then centers that narrower HStack as a whole block + // inside the 640pt window, leaving centerColumn's `.frame(maxWidth: .infinity)` + // with nothing to actually expand into. Proposing 640 explicitly here makes + // the HStack lay out normally against a concrete width, so centerColumn + // receives the real remaining space instead of just its content's natural size. + .frame(idealWidth: SurfaceMetrics.baseContentWidth) } else { CalendarStandaloneView(store: calendarStore) } diff --git a/perch/UI/SettingsView.swift b/perch/UI/SettingsView.swift index b66c42f..5cc73f3 100644 --- a/perch/UI/SettingsView.swift +++ b/perch/UI/SettingsView.swift @@ -19,10 +19,12 @@ struct SettingsView: View { .tabItem { Label(L10n.string("settings.language"), systemImage: "globe") } AIUsageTab() .tabItem { Label(L10n.string("settings.ai_usage"), systemImage: "cpu") } + PermissionsTab() + .tabItem { Label(L10n.string("settings.permissions"), systemImage: "checkmark.shield") } SettingsUpdateTab() .tabItem { Label(L10n.string("settings.updates"), systemImage: "arrow.down.circle") } } - .frame(width: 420, height: 360) + .frame(width: 600, height: 360) .id(languageCode) } } @@ -88,20 +90,81 @@ private struct IslandTab: View { } private struct NowPlayingTab: View { - @Default(.enableSpotify) private var enableSpotify - @Default(.enableAppleMusic) private var enableAppleMusic - @Default(.enableYouTubeMusic) private var enableYouTubeMusic + @Default(.preferredNowPlayingSource) private var preferredNowPlayingSource var body: some View { Form { Section(L10n.string("settings.sources")) { - Toggle(L10n.string("settings.spotify"), isOn: $enableSpotify) - Toggle(L10n.string("settings.apple_music"), isOn: $enableAppleMusic) - Toggle(L10n.string("settings.youtube_music"), isOn: $enableYouTubeMusic) + Picker(L10n.string("settings.nowplaying_source"), selection: $preferredNowPlayingSource) { + ForEach(NowPlayingSourcePreference.allCases) { source in + Text(L10n.string(source.displayNameKey)).tag(source) + } + } + .pickerStyle(.menu) + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct PermissionsTab: View { + @State private var store = PermissionsStore() + + var body: some View { + Form { + Section(L10n.string("settings.permissions.section")) { + ForEach(PermissionsStore.Kind.allCases) { kind in + row(for: kind) + } } } .formStyle(.grouped) .padding() + .onAppear { store.refreshAll() } + } + + @ViewBuilder + private func row(for kind: PermissionsStore.Kind) -> some View { + let status = store.statuses[kind] ?? .notDetermined + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(kind.displayName) + Text(statusLabel(status)) + .font(.caption) + .foregroundStyle(statusColor(status)) + } + Spacer() + if status != .authorized { + Button(actionLabel(status)) { + Task { await store.request(kind) } + } + } + } + } + + private func statusLabel(_ status: PermissionsStore.PermissionStatus) -> String { + switch status { + case .authorized: L10n.string("settings.permissions.status.authorized") + case .notDetermined: L10n.string("settings.permissions.status.not_determined") + case .denied: L10n.string("settings.permissions.status.denied") + case .unknown: L10n.string("settings.permissions.status.unknown") + } + } + + private func statusColor(_ status: PermissionsStore.PermissionStatus) -> Color { + switch status { + case .authorized: .green + case .notDetermined: .yellow + case .denied: .red + case .unknown: .secondary + } + } + + private func actionLabel(_ status: PermissionsStore.PermissionStatus) -> String { + status == .denied + ? L10n.string("settings.permissions.open_system_settings") + : L10n.string("settings.permissions.request") } } diff --git a/perch/UI/SurfaceMetrics.swift b/perch/UI/SurfaceMetrics.swift index 6f4384b..7ea1491 100644 --- a/perch/UI/SurfaceMetrics.swift +++ b/perch/UI/SurfaceMetrics.swift @@ -5,9 +5,9 @@ import CoreGraphics /// numbers scattered across views are how a 540pt width and a 260/300pt width end up /// disagreeing with each other. nonisolated enum SurfaceMetrics { - /// The handbook's own basis (§0.1) is 640pt, sized for a fuller Atoll layout + /// The handbook's own basis (§0.1) is 640pt, sized for a fuller reference layout /// (Mirror included). Perch has no Mirror; 640 is kept anyway as the width that - /// visibly closes the gap the user flagged against Atoll's real screenshot. + /// visibly closes the gap the user flagged against the reference mockup. static let baseContentWidth: CGFloat = 640 static let minContentWidth: CGFloat = 480 static let maxContentWidthFloor: CGFloat = 400 @@ -23,8 +23,16 @@ nonisolated enum SurfaceMetrics { /// Rich mode's center column when it's showing lyrics (`NowPlayingLyricsColumn`) or /// today's events (`TodayEventsColumn`) — independent of whatever height the left - /// column happens to measure at. ~4 lines of `LyricsView` text (13pt font, 10pt - /// line spacing): `(13 * 1.3 + 10) * 4 ≈ 130pt`, where 13*1.3 approximates a single - /// line's rendered height (font size × typical line-height multiplier). + /// column happens to measure at. ~4 lines of `LyricsView` text (15pt font, 10pt + /// line spacing): `(15 * 1.3 + 10) * 4 ≈ 118pt`, where 15*1.3 approximates a single + /// line's rendered height (font size × typical line-height multiplier); 130 keeps a + /// little slack over that estimate. static let lyricsColumnHeight: CGFloat = 130 + + /// `IslandTopBar`'s own rendered height (10pt top padding + 26pt tallest control + + /// 8pt bottom padding = 44pt) plus the hairline `Divider` `ExpandedIslandView` draws + /// below it. An approximation from the source, not a measurement — used only to + /// compensate `RichHomeView`'s lyrics/events column centering for the header sitting + /// above the row it's actually centered against (see `RichHomeView`). + static let headerHeight: CGFloat = 45 } diff --git a/perch/UI/SurfaceSizeResolver.swift b/perch/UI/SurfaceSizeResolver.swift index f58ce52..9714b9f 100644 --- a/perch/UI/SurfaceSizeResolver.swift +++ b/perch/UI/SurfaceSizeResolver.swift @@ -3,7 +3,7 @@ import CoreGraphics /// Resolves Rich mode's Home content target size from screen width, per /// docs/macOS-Expanded-Surface-Layout-Handbook-ja.md §4 ("Size Resolver") — one pure /// function both the window floor (`ExpandedIslandView`) and the content layout -/// (`AtollStyleExpandedView`) read, instead of each carrying its own hardcoded number. +/// (`RichHomeView`) read, instead of each carrying its own hardcoded number. /// /// `PageKind` has one case today because Rich mode has one page shape (Home). The /// handbook's Resolver switches on several page kinds (Timer, Notes, Terminal, ...); diff --git a/perch/Vendor/NookSurface/NookStyle.swift b/perch/Vendor/NookSurface/NookStyle.swift index ee6dbee..19c06b5 100644 --- a/perch/Vendor/NookSurface/NookStyle.swift +++ b/perch/Vendor/NookSurface/NookStyle.swift @@ -38,8 +38,8 @@ public struct NookEdgeInsets: Equatable, Sendable { /// bottom is the larger rounding where the panel meets the wallpaper. // Modified for Perch: added `compactTopCornerRadius`/`compactBottomCornerRadius` so a host // can tune the compact pill's roundedness. `NookView.compactCornerRadii` previously ignored -// this struct entirely and hardcoded (6, 14) — Perch's Atoll-inspired look needs a rounder -// pill than that default. +// this struct entirely and hardcoded (6, 14) — Perch's rounder pill look needs more +// rounding than that default. public struct NookStyle: Equatable, Sendable { public var topCornerRadius: CGFloat public var bottomCornerRadius: CGFloat diff --git a/perchTests/Core/PreferencesMigrationTests.swift b/perchTests/Core/PreferencesMigrationTests.swift index d1db913..92a3f54 100644 --- a/perchTests/Core/PreferencesMigrationTests.swift +++ b/perchTests/Core/PreferencesMigrationTests.swift @@ -26,11 +26,16 @@ import Testing struct PreferencesMigrationTests { private static let chromeKey = Defaults.Keys.islandChromeStyle.name private static let legacyChromeKey = PreferencesMigration.LegacyKey.notchSimulationMode + private static let sourceKey = Defaults.Keys.preferredNowPlayingSource.name private static let touchedKeys = [ chromeKey, legacyChromeKey, PreferencesMigration.LegacyKey.pillSize, PreferencesMigration.LegacyKey.pillBackgroundStyle, + sourceKey, + PreferencesMigration.LegacyKey.enableSpotify, + PreferencesMigration.LegacyKey.enableAppleMusic, + PreferencesMigration.LegacyKey.enableYouTubeMusic, ] /// Snapshots every key the migration touches, hands the caller a clean slate, and @@ -136,6 +141,56 @@ struct PreferencesMigrationTests { } } + @Test( + "migrateSourcePreference maps exactly-one-enabled to that source, everything else to .auto", + arguments: [ + (true, false, false, NowPlayingSourcePreference.spotify), + (false, true, false, NowPlayingSourcePreference.appleMusic), + (false, false, true, NowPlayingSourcePreference.youTubeMusic), + (true, true, true, NowPlayingSourcePreference.auto), + (false, false, false, NowPlayingSourcePreference.auto), + (true, true, false, NowPlayingSourcePreference.auto), + ] + ) + func migrateSourcePreferenceMapping( + spotify: Bool, appleMusic: Bool, youTubeMusic: Bool, expected: NowPlayingSourcePreference + ) { + let result = PreferencesMigration.migrateSourcePreference( + spotifyEnabled: spotify, appleMusicEnabled: appleMusic, youTubeMusicEnabled: youTubeMusic) + #expect(result == expected) + } + + @Test("a legacy source toggle set is migrated to the matching exclusive preference") + func migratesLegacySourceToggle() { + withCleanStore { store in + store.set(false, forKey: PreferencesMigration.LegacyKey.enableSpotify) + store.set(true, forKey: PreferencesMigration.LegacyKey.enableAppleMusic) + store.set(false, forKey: PreferencesMigration.LegacyKey.enableYouTubeMusic) + PreferencesMigration.migrateNowPlayingSourceToggles(in: store) + #expect(store.string(forKey: Self.sourceKey) == NowPlayingSourcePreference.appleMusic.rawValue) + } + } + + @Test("consuming the legacy source toggles makes the migration one-shot") + func consumesLegacySourceToggles() { + withCleanStore { store in + store.set(false, forKey: PreferencesMigration.LegacyKey.enableSpotify) + PreferencesMigration.migrateNowPlayingSourceToggles(in: store) + #expect(store.object(forKey: PreferencesMigration.LegacyKey.enableSpotify) == nil) + #expect(store.object(forKey: PreferencesMigration.LegacyKey.enableAppleMusic) == nil) + #expect(store.object(forKey: PreferencesMigration.LegacyKey.enableYouTubeMusic) == nil) + } + } + + @Test("a fresh install with no legacy source toggles is left alone") + func freshInstallSourceTogglesUntouched() { + withCleanStore { store in + store.set(NowPlayingSourcePreference.spotify.rawValue, forKey: Self.sourceKey) + PreferencesMigration.migrateNowPlayingSourceToggles(in: store) + #expect(store.string(forKey: Self.sourceKey) == NowPlayingSourcePreference.spotify.rawValue) + } + } + /// Pins the assumption the migration's raw-value write depends on: `Defaults` persists /// a `String`-backed `RawRepresentable` as the bare raw string. If a `Defaults` upgrade /// ever wrapped it (JSON, a plist dict), the migration would still write a plain string