From 02588fd0f3d3da873abaff8fc163c86515ba9b52 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 08:36:25 +0900 Subject: [PATCH 01/26] fix: skip sentinel cache on Task cancellation to prevent false 'no lyrics' cache Combining lyricsPrefetchTask?.cancel() with cache[key] = [] sentinel caused CancellationError to be treated as 'no lyrics found', poisoning the cache. Cancelled fetches must be retried on next play. Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/LyricsStore.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/perch/Features/NowPlaying/LyricsStore.swift b/perch/Features/NowPlaying/LyricsStore.swift index f7554a3..72040fc 100644 --- a/perch/Features/NowPlaying/LyricsStore.swift +++ b/perch/Features/NowPlaying/LyricsStore.swift @@ -54,7 +54,11 @@ actor LyricsStore { cache[key] = lines return lines } - cache[key] = [] // sentinel: no lyrics — skip network on repeat plays + // Only cache as "no lyrics" when the failure was not due to task cancellation. + // A cancelled fetch should be retried on next play. + if !Task.isCancelled { + cache[key] = [] + } return nil } From eb559157431ccb2842395f420d2497aa2664a1ab Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 08:36:38 +0900 Subject: [PATCH 02/26] fix: center-align lyrics text by expanding Text to full container width Without frame(maxWidth: .infinity), Text uses intrinsic width. Short lines appeared left-aligned because LazyVStack centers relative to the widest child, not the container. Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/LyricsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 38c978f..a05598b 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -29,6 +29,7 @@ struct LyricsView: View { .foregroundStyle(.white.opacity(lineOpacity(idx))) .scaleEffect(idx == activeIndex ? 1.06 : 1.0, anchor: .center) .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) .animation(.spring(response: 0.35, dampingFraction: 0.82), value: activeIndex) .id(line.id) } From 13128e6cd9070f5dab82ec78fd3a2bc26200815d Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 09:36:17 +0900 Subject: [PATCH 03/26] fix: remove sentinel cache and album_name from search to restore lyrics display sentinel was set on any non-cancelled failure (album_name mismatch, timeout, network error); once set, lyrics would never load for that song until app restart Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/LyricsStore.swift | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsStore.swift b/perch/Features/NowPlaying/LyricsStore.swift index 72040fc..2441bcd 100644 --- a/perch/Features/NowPlaying/LyricsStore.swift +++ b/perch/Features/NowPlaying/LyricsStore.swift @@ -44,21 +44,16 @@ actor LyricsStore { func fetchLyrics(title: String, artist: String, album: String?) async -> [LyricsLine]? { let key = "\(title)|\(artist)" - if let cached = cache[key] { return cached.isEmpty ? nil : cached } + if let cached = cache[key] { return cached } if let lines = await fetchGet(title: title, artist: artist) { cache[key] = lines return lines } - if let lines = await fetchSearch(title: title, artist: artist, album: album) { + if let lines = await fetchSearch(title: title, artist: artist) { cache[key] = lines return lines } - // Only cache as "no lyrics" when the failure was not due to task cancellation. - // A cancelled fetch should be retried on next play. - if !Task.isCancelled { - cache[key] = [] - } return nil } @@ -83,14 +78,12 @@ actor LyricsStore { } } - private func fetchSearch(title: String, artist: String, album: String?) async -> [LyricsLine]? { + private func fetchSearch(title: String, artist: String) async -> [LyricsLine]? { var components = URLComponents(string: "https://lrclib.net/api/search")! - var queryItems = [ + components.queryItems = [ URLQueryItem(name: "track_name", value: title), URLQueryItem(name: "artist_name", value: artist), ] - if let album { queryItems.append(URLQueryItem(name: "album_name", value: album)) } - components.queryItems = queryItems guard let url = components.url else { return nil } do { let (data, response) = try await session.data(from: url) From 3c50e412041f0977d33d0de19db19a8258154d64 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 11:13:03 +0900 Subject: [PATCH 04/26] feat: add ATS exception for music.163.com (NetEase HTTP endpoint) LyricsKit NetEase provider uses http://music.163.com/api/search/pc. Without this exception, ATS blocks the request at runtime on macOS. Co-Authored-By: Claude Sonnet 4.6 --- perch/Resources/Info.plist | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/perch/Resources/Info.plist b/perch/Resources/Info.plist index 3442253..403b267 100644 --- a/perch/Resources/Info.plist +++ b/perch/Resources/Info.plist @@ -4,5 +4,18 @@ LSUIElement + NSAppTransportSecurity + + NSExceptionDomains + + music.163.com + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + From 474e98ee9beb3e8d78d1f4f0817f74c779656028 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 11:13:10 +0900 Subject: [PATCH 05/26] fix: use empty Name field as Spotify ad indicator trackNumber/popularity checks were unreliable (fields may be absent in newer Spotify). Ads always deliver an empty Name field, so name.isEmpty && playerState == Playing is a more reliable signal. Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/NowPlayingManager.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/perch/Features/NowPlaying/NowPlayingManager.swift b/perch/Features/NowPlaying/NowPlayingManager.swift index fd0b080..8b69ea6 100644 --- a/perch/Features/NowPlaying/NowPlayingManager.swift +++ b/perch/Features/NowPlaying/NowPlayingManager.swift @@ -127,19 +127,15 @@ final class NowPlayingManager { let album = info["Album"] as? String let durationMs = info["Duration"] as? Double let position = info["Playback Position"] as? Double - let trackNumber = info["Track Number"] as? Int - let popularity = info["Popularity"] as? Int MainActor.assumeIsolated { [weak self] in if playerState == "Stopped" { self?.applyState(nil, source: "Spotify") return } - // Primary: Track ID prefix. Fallback: Track Number=0 + Popularity=0 - // (confirmed by Spotifree, citruspi/Spotify-Notifications via reverse-engineering - // of com.spotify.client.PlaybackStateChanged payload) + // Primary: Track ID prefix. Fallback: empty Name field (ads always omit Name). + // trackNumber/Popularity are unreliable — newer Spotify may omit them. let isAdByTrackId = trackId?.hasPrefix("spotify:ad:") == true - let isAdByFields = - trackNumber == 0 && (popularity == nil || popularity == 0) && playerState == "Playing" + let isAdByFields = name.isEmpty && playerState == "Playing" if isAdByTrackId || isAdByFields { let adState = NowPlayingState( title: "Spotify Ad", artist: "", album: nil, artwork: nil, @@ -152,7 +148,7 @@ final class NowPlayingManager { self?.applyState(adState, source: "Spotify") return } - guard let playerState, !name.isEmpty else { return } + guard let playerState else { return } let state = NowPlayingState( spotifyPlayerState: playerState, title: name, artist: artist, album: album, durationMs: durationMs, position: position From de731a6580e0c4ed7d23415fa6b35966953bc928 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 11:13:19 +0900 Subject: [PATCH 06/26] docs: add THIRD_PARTY_NOTICES.md for LyricsKit MPL-2.0 LyricsKit is MPL-2.0 licensed. Attribution required; Perch source need not be opened as long as LyricsKit files are unmodified. Co-Authored-By: Claude Sonnet 4.6 --- THIRD_PARTY_NOTICES.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..338719f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,8 @@ +# Third-Party Notices + +## LyricsKit + +- Source: https://github.com/MxIris-LyricsX-Project/LyricsKit +- License: Mozilla Public License 2.0 (MPL-2.0) +- Copyright: ddddxxx and LyricsX project contributors +- License text: https://www.mozilla.org/en-US/MPL/2.0/ From ef5d85c7849ed51ddbd4ee8170f0ec9af5a7d525 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 11:13:27 +0900 Subject: [PATCH 07/26] feat: improve lyrics UI layout (Pattern D) Remove fontSize+1 on active line to prevent unstable word-wrap. Use scaleEffect(1.10) + .bold for visual emphasis instead. Add lineLimit(2) for natural 2-line wrap. Extend maxHeight to 200pt and ease spring to response:0.40. Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/LyricsView.swift | 9 +++++---- perch/Features/NowPlaying/NowPlayingCard.swift | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index a05598b..12bfbf1 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -23,14 +23,15 @@ struct LyricsView: View { Text(line.text) .font( .system( - size: idx == activeIndex ? fontSize + 1 : fontSize, - weight: idx == activeIndex ? .semibold : .regular) + size: fontSize, + weight: idx == activeIndex ? .bold : .regular) ) .foregroundStyle(.white.opacity(lineOpacity(idx))) - .scaleEffect(idx == activeIndex ? 1.06 : 1.0, anchor: .center) + .scaleEffect(idx == activeIndex ? 1.10 : 1.0, anchor: .center) .multilineTextAlignment(.center) + .lineLimit(2) .frame(maxWidth: .infinity) - .animation(.spring(response: 0.35, dampingFraction: 0.82), value: activeIndex) + .animation(.spring(response: 0.40, dampingFraction: 0.82), value: activeIndex) .id(line.id) } Color.clear.frame(height: 6) diff --git a/perch/Features/NowPlaying/NowPlayingCard.swift b/perch/Features/NowPlaying/NowPlayingCard.swift index cdbb699..314c6f2 100644 --- a/perch/Features/NowPlaying/NowPlayingCard.swift +++ b/perch/Features/NowPlaying/NowPlayingCard.swift @@ -138,7 +138,7 @@ struct NowPlayingCard: View { fontSize: 14 ) } - .frame(maxHeight: 160) + .frame(maxHeight: 200) Divider().background(.white.opacity(0.15)) progressSection } From 7add16ba663a7e751244e4a55afe4abe5e87c264 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 11:13:38 +0900 Subject: [PATCH 08/26] feat: add LyricsKit fallback for lyrics not in LRCLIB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate LyricsKit SPM (MxIris-LyricsX-Project/LyricsKit v1.9.0, MPL-2.0). New LyricsKitFetcher actor tries NetEase → QQ → Kugou after LRCLIB misses. LyricsLine name collision avoided: Perch type uses timestamp:text: init which is distinct from LyricsCore.LyricsLine. Co-Authored-By: Claude Sonnet 4.6 --- perch.xcodeproj/project.pbxproj | 17 +++++ .../xcshareddata/swiftpm/Package.resolved | 74 ++++++++++++++++++- .../NowPlaying/LyricsKitFetcher.swift | 49 ++++++++++++ perch/Features/NowPlaying/LyricsStore.swift | 4 + 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 perch/Features/NowPlaying/LyricsKitFetcher.swift diff --git a/perch.xcodeproj/project.pbxproj b/perch.xcodeproj/project.pbxproj index 83de3e9..d275be9 100644 --- a/perch.xcodeproj/project.pbxproj +++ b/perch.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ BA0A72422FCEBA4F00F08C06 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72412FCEBA4F00F08C06 /* InMemoryLogging */; }; BA0A72442FCEBA4F00F08C06 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72432FCEBA4F00F08C06 /* Logging */; }; BA0A72472FCEBA7600F08C06 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72462FCEBA7600F08C06 /* KeyboardShortcuts */; }; + BA0A724A2FCEBABC00F08C06 /* LyricsKit in Frameworks */ = {isa = PBXBuildFile; productRef = BA0A72492FCEBABC00F08C06 /* LyricsKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,6 +63,7 @@ BA0A723D2FCEBA1A00F08C06 /* Defaults in Frameworks */, BA0A72422FCEBA4F00F08C06 /* InMemoryLogging in Frameworks */, BA0A72472FCEBA7600F08C06 /* KeyboardShortcuts in Frameworks */, + BA0A724A2FCEBABC00F08C06 /* LyricsKit in Frameworks */, BA0A72442FCEBA4F00F08C06 /* Logging in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -127,6 +129,7 @@ BA0A72412FCEBA4F00F08C06 /* InMemoryLogging */, BA0A72432FCEBA4F00F08C06 /* Logging */, BA0A72462FCEBA7600F08C06 /* KeyboardShortcuts */, + BA0A72492FCEBABC00F08C06 /* LyricsKit */, ); productName = perch; productReference = BA0A707E2FCD694B00F08C06 /* perch.app */; @@ -215,6 +218,7 @@ BA0A723B2FCEBA1A00F08C06 /* XCRemoteSwiftPackageReference "Defaults" */, BA0A72402FCEBA4F00F08C06 /* XCRemoteSwiftPackageReference "swift-log" */, BA0A72452FCEBA7600F08C06 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, + BA0A72482FCEBABC00F08C06 /* XCRemoteSwiftPackageReference "LyricsKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = BA0A707F2FCD694B00F08C06 /* Products */; @@ -618,6 +622,14 @@ minimumVersion = 2.4.0; }; }; + BA0A72482FCEBABC00F08C06 /* XCRemoteSwiftPackageReference "LyricsKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MxIris-LyricsX-Project/LyricsKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.9.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -641,6 +653,11 @@ package = BA0A72452FCEBA7600F08C06 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */; productName = KeyboardShortcuts; }; + BA0A72492FCEBABC00F08C06 /* LyricsKit */ = { + isa = XCSwiftPackageProductDependency; + package = BA0A72482FCEBABC00F08C06 /* XCRemoteSwiftPackageReference "LyricsKit" */; + productName = LyricsKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = BA0A70762FCD694B00F08C06 /* Project object */; diff --git a/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 62b6ef0..4a8f82c 100644 --- a/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "9b119c3e8d1af759685a5c11b25453b4daa6ff2d08b2a384ae63b84b616a0232", + "originHash" : "317d68ae871be5e6fb9d9da880fd72139c5507dfce692f0d806c63d5069bede1", "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift", + "state" : { + "revision" : "f2a627b84c1ff96f21ac2fcb623ab36142dd5512", + "version" : "1.10.0" + } + }, { "identity" : "defaults", "kind" : "remoteSourceControl", @@ -10,6 +28,15 @@ "version" : "9.0.6" } }, + { + "identity" : "frameworktoolbox", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Mx-Iris/FrameworkToolbox", + "state" : { + "revision" : "b82281eb8a6ffcb312941c3d06584182837f4ca9", + "version" : "0.7.1" + } + }, { "identity" : "keyboardshortcuts", "kind" : "remoteSourceControl", @@ -19,6 +46,42 @@ "version" : "2.4.0" } }, + { + "identity" : "lyricskit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-LyricsX-Project/LyricsKit", + "state" : { + "revision" : "e739415b0a2e978b474112f352b3fcd24bb6deb4", + "version" : "1.9.0" + } + }, + { + "identity" : "regex", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ddddxxx/Regex", + "state" : { + "revision" : "c0ad0a7e9a48989d9688c2b524a1e69b6293b733", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -36,6 +99,15 @@ "revision" : "4799286537280063c85a32f09884cfbca301b1a1", "version" : "602.0.0" } + }, + { + "identity" : "swiftcf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/MxIris-Library-Forks/SwiftCF", + "state" : { + "revision" : "46f9d814b5197d33286235036d2e3c982e885fad", + "version" : "0.2.2" + } } ], "version" : 3 diff --git a/perch/Features/NowPlaying/LyricsKitFetcher.swift b/perch/Features/NowPlaying/LyricsKitFetcher.swift new file mode 100644 index 0000000..1f785ac --- /dev/null +++ b/perch/Features/NowPlaying/LyricsKitFetcher.swift @@ -0,0 +1,49 @@ +// perch/Features/NowPlaying/LyricsKitFetcher.swift +import Foundation +import Logging +import LyricsKit + +actor LyricsKitFetcher { + static let shared = LyricsKitFetcher() + private let logger = Logger(label: "com.tukuyomi032.perch.LyricsKitFetcher") + + func fetch(title: String, artist: String) async -> [LyricsLine]? { + // duration: 0 = no duration filter; match quality is slightly lower but still usable. + // Future: pass NowPlayingState.duration here for better accuracy. + let request = LyricsSearchRequest( + searchTerm: .info(title: title, artist: artist), + duration: 0 + ) + let providers: [any LyricsProvider] = [ + LyricsProviders.Service.netease.create(), + LyricsProviders.Service.qq.create(), + LyricsProviders.Service.kugou.create(), + ] + for provider in providers { + if let lines = await fetchFromProvider(provider, request: request) { + return lines + } + } + return nil + } + + private func fetchFromProvider( + _ provider: any LyricsProvider, + request: LyricsSearchRequest + ) async -> [LyricsLine]? { + do { + for try await kitLyrics in provider.lyrics(for: request) { + let lines = kitLyrics.lines + .filter { $0.enabled && !$0.content.isEmpty } + .map { LyricsLine(timestamp: $0.position, text: $0.content) } + if !lines.isEmpty { + logger.debug("LyricsKitFetcher: found \(lines.count) lines via \(type(of: provider))") + return lines + } + } + } catch { + logger.debug("LyricsKitFetcher: \(type(of: provider)) failed: \(error)") + } + return nil + } +} diff --git a/perch/Features/NowPlaying/LyricsStore.swift b/perch/Features/NowPlaying/LyricsStore.swift index 2441bcd..0ff53f6 100644 --- a/perch/Features/NowPlaying/LyricsStore.swift +++ b/perch/Features/NowPlaying/LyricsStore.swift @@ -54,6 +54,10 @@ actor LyricsStore { cache[key] = lines return lines } + if let lines = await LyricsKitFetcher.shared.fetch(title: title, artist: artist) { + cache[key] = lines + return lines + } return nil } From 900a50aba658a842b021020c17af17424ab19e6a Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 12:10:59 +0900 Subject: [PATCH 09/26] fix: increase lyrics horizontal padding to prevent scaleEffect clipping scaleEffect(1.10) overflows layout frame by ~5% on each side; 4pt padding was too small. Increase to 16pt to absorb the overflow. Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/LyricsKitFetcher.swift | 15 +++++++++++++-- perch/Features/NowPlaying/LyricsView.swift | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsKitFetcher.swift b/perch/Features/NowPlaying/LyricsKitFetcher.swift index 1f785ac..e9bdf54 100644 --- a/perch/Features/NowPlaying/LyricsKitFetcher.swift +++ b/perch/Features/NowPlaying/LyricsKitFetcher.swift @@ -20,7 +20,7 @@ actor LyricsKitFetcher { LyricsProviders.Service.kugou.create(), ] for provider in providers { - if let lines = await fetchFromProvider(provider, request: request) { + if let lines = await fetchFromProvider(provider, request: request, expectedTitle: title) { return lines } } @@ -29,10 +29,21 @@ actor LyricsKitFetcher { private func fetchFromProvider( _ provider: any LyricsProvider, - request: LyricsSearchRequest + request: LyricsSearchRequest, + expectedTitle: String ) async -> [LyricsLine]? { do { for try await kitLyrics in provider.lyrics(for: request) { + if let returnedTitle = kitLyrics.idTags[.title], !returnedTitle.isEmpty { + let norm1 = returnedTitle.lowercased() + let norm2 = expectedTitle.lowercased() + guard norm1.contains(norm2) || norm2.contains(norm1) else { + logger.debug( + "LyricsKitFetcher: skipping mismatched title '\(returnedTitle)' (expected '\(expectedTitle)')" + ) + continue + } + } let lines = kitLyrics.lines .filter { $0.enabled && !$0.content.isEmpty } .map { LyricsLine(timestamp: $0.position, text: $0.content) } diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index 12bfbf1..e3ed55f 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -36,7 +36,7 @@ struct LyricsView: View { } Color.clear.frame(height: 6) } - .padding(.horizontal, 4) + .padding(.horizontal, 16) } .onChange(of: activeIndex) { _, newIdx in guard let newIdx else { return } From 6225d242b50b4416d61beb57e0f7d14157a639e2 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 12:23:51 +0900 Subject: [PATCH 10/26] fix: reject LyricsKit results with no Japanese characters for Japanese titles NetEase returns Chinese placeholder text for songs without synced lyrics. Detect Japanese titles (hiragana/katakana) and skip results where no lyrics line contains Japanese script. Co-Authored-By: Claude Sonnet 4.6 --- .../Features/NowPlaying/LyricsKitFetcher.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/perch/Features/NowPlaying/LyricsKitFetcher.swift b/perch/Features/NowPlaying/LyricsKitFetcher.swift index e9bdf54..095d07a 100644 --- a/perch/Features/NowPlaying/LyricsKitFetcher.swift +++ b/perch/Features/NowPlaying/LyricsKitFetcher.swift @@ -48,6 +48,10 @@ actor LyricsKitFetcher { .filter { $0.enabled && !$0.content.isEmpty } .map { LyricsLine(timestamp: $0.position, text: $0.content) } if !lines.isEmpty { + if titleIsJapanese(expectedTitle) && !lyricsContainJapanese(lines) { + logger.debug("LyricsKitFetcher: skipping non-Japanese lyrics for '\(expectedTitle)'") + continue + } logger.debug("LyricsKitFetcher: found \(lines.count) lines via \(type(of: provider))") return lines } @@ -57,4 +61,18 @@ actor LyricsKitFetcher { } return nil } + + private func titleIsJapanese(_ title: String) -> Bool { + title.unicodeScalars.contains { + ($0.value >= 0x3040 && $0.value <= 0x309F) || ($0.value >= 0x30A0 && $0.value <= 0x30FF) + } + } + + private func lyricsContainJapanese(_ lines: [LyricsLine]) -> Bool { + lines.contains { line in + line.text.unicodeScalars.contains { + ($0.value >= 0x3040 && $0.value <= 0x309F) || ($0.value >= 0x30A0 && $0.value <= 0x30FF) + } + } + } } From ee14423828e55d3ddb2017ddaf692eb5d6a48168 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 12:49:46 +0900 Subject: [PATCH 11/26] fix: use uniform font weight to prevent line-wrap reflow on active state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing .regular→.bold on active line caused text to re-wrap differently, making words abruptly jump to line 2 without animation. Use .regular for all lines and increase scaleEffect to 1.13 to compensate. Co-Authored-By: Claude Sonnet 4.6 --- perch/Features/NowPlaying/LyricsView.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/perch/Features/NowPlaying/LyricsView.swift b/perch/Features/NowPlaying/LyricsView.swift index e3ed55f..1f48dde 100644 --- a/perch/Features/NowPlaying/LyricsView.swift +++ b/perch/Features/NowPlaying/LyricsView.swift @@ -21,13 +21,9 @@ struct LyricsView: View { Color.clear.frame(height: 6) ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in Text(line.text) - .font( - .system( - size: fontSize, - weight: idx == activeIndex ? .bold : .regular) - ) + .font(.system(size: fontSize, weight: .regular)) .foregroundStyle(.white.opacity(lineOpacity(idx))) - .scaleEffect(idx == activeIndex ? 1.10 : 1.0, anchor: .center) + .scaleEffect(idx == activeIndex ? 1.13 : 1.0, anchor: .center) .multilineTextAlignment(.center) .lineLimit(2) .frame(maxWidth: .infinity) From 5ce99fa99b3eddf1132d2a50b8b69193a339f6b8 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 13:30:17 +0900 Subject: [PATCH 12/26] chore: bump MARKETING_VERSION to 0.3.0 Prepare for v0.3.0-beta.1 release Co-Authored-By: Claude Sonnet 4.6 --- perch.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/perch.xcodeproj/project.pbxproj b/perch.xcodeproj/project.pbxproj index d275be9..5a5a852 100644 --- a/perch.xcodeproj/project.pbxproj +++ b/perch.xcodeproj/project.pbxproj @@ -433,7 +433,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -464,7 +464,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -485,7 +485,7 @@ DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -506,7 +506,7 @@ DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -525,7 +525,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -544,7 +544,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = Q9CTDZWWR9; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.2.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = com.tukuyomi032.perchUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; From 7d87363a08fdc0b5b9a3c009a05aef6c3045c49a Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 13:30:26 +0900 Subject: [PATCH 13/26] feat: add beta/stable detection to release workflow - Detect beta tags via regex (-[a-zA-Z]) and set IS_BETA flag - Pass MARKETING_VERSION (base version) to all xcodebuild invocations - Dynamic prerelease flag in GitHub Release creation - Compose dynamic release body with correct brew cask name (perch-beta vs perch) - Add Homebrew tap auto-update step using HOMEBREW_TAP_TOKEN secret - Compute SHA256 of universal DMG for Homebrew cask formula Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 112 ++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a596a7..961214f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,21 @@ jobs: - name: Resolve version id: version run: | - TAG="${GITHUB_REF_NAME:-v0.2.0}" + TAG="${GITHUB_REF_NAME:-v0.3.0}" VERSION="${TAG#v}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" + BASE_VERSION="${VERSION%%-*}" + if [[ "$VERSION" =~ -[a-zA-Z] ]]; then + IS_BETA=true + FORMULA_NAME="perch-beta" + else + IS_BETA=false + FORMULA_NAME="perch" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT" + echo "formula_name=$FORMULA_NAME" >> "$GITHUB_OUTPUT" - name: Build arm64 archive run: | @@ -40,6 +51,7 @@ jobs: CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ SKIP_INSTALL=NO 2>&1 | tail -5 - name: Build x86_64 archive @@ -55,6 +67,7 @@ jobs: CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ SKIP_INSTALL=NO 2>&1 | tail -5 - name: Build universal archive @@ -70,6 +83,7 @@ jobs: CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ SKIP_INSTALL=NO 2>&1 | tail -5 - name: Create DMGs @@ -99,28 +113,88 @@ jobs: echo "Created: $APP_NAME-${VERSION}-${ARCH}.dmg" done + - name: Compute SHA256 + id: sha256 + run: | + VERSION="${{ steps.version.outputs.version }}" + SHA=$(shasum -a 256 "build/dmg/$APP_NAME-${VERSION}-universal.dmg" | awk '{print $1}') + echo "universal=$SHA" >> "$GITHUB_OUTPUT" + + - name: Compose release body + id: body + run: | + VERSION="${{ steps.version.outputs.version }}" + IS_BETA="${{ steps.version.outputs.is_beta }}" + FORMULA_NAME="${{ steps.version.outputs.formula_name }}" + + if [ "$IS_BETA" = "true" ]; then + LABEL=" (beta)" + else + LABEL="" + fi + + { + echo "body<<__BODY__" + echo "## Perch ${VERSION}${LABEL}" + echo "" + echo "### Install via Homebrew" + echo '```bash' + echo "brew tap tukuyomil032/tap" + echo "brew install --cask ${FORMULA_NAME}" + echo '```' + echo "" + echo "### Direct Download" + echo "- **universal.dmg** (recommended) — Apple Silicon + Intel" + echo "- **arm64.dmg** — Apple Silicon only" + echo "- **x86_64.dmg** — Intel only" + echo "" + echo "> ⚠️ Not notarized by Apple. If Gatekeeper blocks it: right-click > Open > Open." + echo "__BODY__" + } >> "$GITHUB_OUTPUT" + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: name: "Perch ${{ steps.version.outputs.version }}" - body: | - ## Perch ${{ steps.version.outputs.version }} (beta) - - ### Install via Homebrew - ```bash - brew tap tukuyomil032/tap - brew install --cask perch - ``` - - ### Direct Download - - **universal.dmg** (recommended) — Apple Silicon + Intel - - **arm64.dmg** — Apple Silicon only - - **x86_64.dmg** — Intel only - - > ⚠️ Not notarized by Apple. If Gatekeeper blocks it: right-click > Open > Open. + body: ${{ steps.body.outputs.body }} files: | build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-arm64.dmg build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-x86_64.dmg build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-universal.dmg draft: false - prerelease: false + prerelease: ${{ steps.version.outputs.is_beta == 'true' }} + + - name: Update Homebrew tap + if: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }} + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + BASE_VERSION="${{ steps.version.outputs.base_version }}" + TAG="${{ steps.version.outputs.tag }}" + FORMULA_NAME="${{ steps.version.outputs.formula_name }}" + SHA="${{ steps.sha256.outputs.universal }}" + + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/tukuyomil032/homebrew-tap.git" tap-repo + cd tap-repo + mkdir -p Casks + + cat > "Casks/${FORMULA_NAME}.rb" << FORMULA + cask "${FORMULA_NAME}" do + version "${VERSION}" + sha256 "${SHA}" + + url "https://github.com/tukuyomil032/perch/releases/download/${TAG}/perch-${VERSION}-universal.dmg" + name "Perch" + desc "macOS Dynamic Island-style live hub" + homepage "https://github.com/tukuyomil032/perch" + + app "perch.app" + end + FORMULA + + git config user.email "actions@github.com" + git config user.name "GitHub Actions" + git add "Casks/${FORMULA_NAME}.rb" + git commit -m "chore: update ${FORMULA_NAME} to ${VERSION}" + git push From 1b4b27ad92567731a7b79fc7049bc3b213a7f5ed Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 13:44:51 +0900 Subject: [PATCH 14/26] feat: support perch-beta.app build via PRODUCT_NAME for beta tags - Add bundle_name output (perch-beta or perch) based on tag format - Pass PRODUCT_NAME to all xcodebuild archive steps - Fix DMG staging to use bundle_name for .app path - Remove Compute SHA256 and Update Homebrew tap steps (tap handles itself) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 72 +++++++++-------------------------- 1 file changed, 17 insertions(+), 55 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 961214f..359e7a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,16 +27,16 @@ jobs: BASE_VERSION="${VERSION%%-*}" if [[ "$VERSION" =~ -[a-zA-Z] ]]; then IS_BETA=true - FORMULA_NAME="perch-beta" + BUNDLE_NAME="perch-beta" else IS_BETA=false - FORMULA_NAME="perch" + BUNDLE_NAME="perch" fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT" - echo "formula_name=$FORMULA_NAME" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT" + echo "bundle_name=$BUNDLE_NAME" >> "$GITHUB_OUTPUT" - name: Build arm64 archive run: | @@ -52,6 +52,7 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ + PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ SKIP_INSTALL=NO 2>&1 | tail -5 - name: Build x86_64 archive @@ -68,6 +69,7 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ + PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ SKIP_INSTALL=NO 2>&1 | tail -5 - name: Build universal archive @@ -84,23 +86,25 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ + PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ SKIP_INSTALL=NO 2>&1 | tail -5 - name: Create DMGs run: | VERSION="${{ steps.version.outputs.version }}" + BUNDLE_NAME="${{ steps.version.outputs.bundle_name }}" mkdir -p build/dmg for ARCH in arm64 x86_64 universal; do - APP="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/$APP_NAME.app" + APP="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/${BUNDLE_NAME}.app" STAGING="build/dmg-staging-${ARCH}" rm -rf "$STAGING" mkdir -p "$STAGING" - cp -R "$APP" "$STAGING/$APP_NAME.app" + cp -R "$APP" "$STAGING/${BUNDLE_NAME}.app" - codesign --force --deep --sign - --timestamp=none "$STAGING/$APP_NAME.app" - codesign --verify --deep --strict --verbose=4 "$STAGING/$APP_NAME.app" - if ! spctl --assess --type execute --verbose=4 "$STAGING/$APP_NAME.app"; then + codesign --force --deep --sign - --timestamp=none "$STAGING/${BUNDLE_NAME}.app" + codesign --verify --deep --strict --verbose=4 "$STAGING/${BUNDLE_NAME}.app" + if ! spctl --assess --type execute --verbose=4 "$STAGING/${BUNDLE_NAME}.app"; then echo "::warning::spctl rejected ad-hoc signed app for ${ARCH}; continuing." fi @@ -113,19 +117,12 @@ jobs: echo "Created: $APP_NAME-${VERSION}-${ARCH}.dmg" done - - name: Compute SHA256 - id: sha256 - run: | - VERSION="${{ steps.version.outputs.version }}" - SHA=$(shasum -a 256 "build/dmg/$APP_NAME-${VERSION}-universal.dmg" | awk '{print $1}') - echo "universal=$SHA" >> "$GITHUB_OUTPUT" - - name: Compose release body id: body run: | VERSION="${{ steps.version.outputs.version }}" IS_BETA="${{ steps.version.outputs.is_beta }}" - FORMULA_NAME="${{ steps.version.outputs.formula_name }}" + BUNDLE_NAME="${{ steps.version.outputs.bundle_name }}" if [ "$IS_BETA" = "true" ]; then LABEL=" (beta)" @@ -140,7 +137,7 @@ jobs: echo "### Install via Homebrew" echo '```bash' echo "brew tap tukuyomil032/tap" - echo "brew install --cask ${FORMULA_NAME}" + echo "brew install --cask ${BUNDLE_NAME}" echo '```' echo "" echo "### Direct Download" @@ -163,38 +160,3 @@ jobs: build/dmg/${{ env.APP_NAME }}-${{ steps.version.outputs.version }}-universal.dmg draft: false prerelease: ${{ steps.version.outputs.is_beta == 'true' }} - - - name: Update Homebrew tap - if: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }} - env: - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - BASE_VERSION="${{ steps.version.outputs.base_version }}" - TAG="${{ steps.version.outputs.tag }}" - FORMULA_NAME="${{ steps.version.outputs.formula_name }}" - SHA="${{ steps.sha256.outputs.universal }}" - - git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/tukuyomil032/homebrew-tap.git" tap-repo - cd tap-repo - mkdir -p Casks - - cat > "Casks/${FORMULA_NAME}.rb" << FORMULA - cask "${FORMULA_NAME}" do - version "${VERSION}" - sha256 "${SHA}" - - url "https://github.com/tukuyomil032/perch/releases/download/${TAG}/perch-${VERSION}-universal.dmg" - name "Perch" - desc "macOS Dynamic Island-style live hub" - homepage "https://github.com/tukuyomil032/perch" - - app "perch.app" - end - FORMULA - - git config user.email "actions@github.com" - git config user.name "GitHub Actions" - git add "Casks/${FORMULA_NAME}.rb" - git commit -m "chore: update ${FORMULA_NAME} to ${VERSION}" - git push From c7c5674d3e9aa3b8add6832e99cfa517d6b19fcb Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 13:51:37 +0900 Subject: [PATCH 15/26] fix: switch CI runner to macos-26 for Swift 6.2 support LyricsKit 1.9.0 requires swift-tools-version 6.2.0; macos-15 ships with Swift 6.1.0 which fails to resolve the dependency Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 359e7a7..6102a24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: release: - runs-on: macos-15 + runs-on: macos-26 env: SCHEME: perch APP_NAME: perch From 4f09f6919c2dafbc2db5bdf58befc1cdc91fd817 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 13:59:39 +0900 Subject: [PATCH 16/26] fix: use macos-26 runner and support workflow_dispatch version input - ci.yml build job: macos-latest -> macos-26 (Swift 6.2 for LyricsKit 1.9.0) - release.yml: add version input to workflow_dispatch so manual runs work correctly from any branch (uses input instead of GITHUB_REF_NAME) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08cc3b1..e057970 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ permissions: jobs: build: name: Build - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 30 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6102a24..4ff8578 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,11 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + version: + description: 'Release tag (e.g. v0.3.0-beta-1)' + required: true + type: string permissions: contents: write @@ -22,7 +27,11 @@ jobs: - name: Resolve version id: version run: | - TAG="${GITHUB_REF_NAME:-v0.3.0}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.version }}" + else + TAG="${GITHUB_REF_NAME}" + fi VERSION="${TAG#v}" BASE_VERSION="${VERSION%%-*}" if [[ "$VERSION" =~ -[a-zA-Z] ]]; then From 0a956b9c26a02cd8185de8ab787e28255e2d4f44 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:05:27 +0900 Subject: [PATCH 17/26] fix: switch test.yml runner to macos-26 for Swift 6.2 support Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e88c241..63fc17e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ permissions: jobs: test: name: Unit Tests - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 30 steps: From 1c32a54f32816f7661b7b9b6269fc17deda55ee8 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:14:16 +0900 Subject: [PATCH 18/26] fix: expose xcodebuild errors in CI logs Remove --renderer github-actions from xcbeautify so Swift compile errors appear in raw step logs instead of being swallowed as annotations. Change release.yml tail -5 to tail -100 for same reason. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/test.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e057970..72a6d73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: -destination 'platform=macOS' \ CODE_SIGNING_ALLOWED=NO \ build \ - 2>&1 | xcbeautify --renderer github-actions + 2>&1 | xcbeautify lint: name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ff8578..983a353 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ - SKIP_INSTALL=NO 2>&1 | tail -5 + SKIP_INSTALL=NO 2>&1 | tail -100 - name: Build x86_64 archive run: | @@ -79,7 +79,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ - SKIP_INSTALL=NO 2>&1 | tail -5 + SKIP_INSTALL=NO 2>&1 | tail -100 - name: Build universal archive run: | @@ -96,7 +96,7 @@ jobs: CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ - SKIP_INSTALL=NO 2>&1 | tail -5 + SKIP_INSTALL=NO 2>&1 | tail -100 - name: Create DMGs run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63fc17e..e7efc46 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,4 +51,4 @@ jobs: -skip-testing:perchUITests \ CODE_SIGNING_ALLOWED=NO \ test \ - 2>&1 | xcbeautify --renderer github-actions + 2>&1 | xcbeautify From 5cc40ecea439e7f7378b130156b38b5f76adb9ad Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:16:23 +0900 Subject: [PATCH 19/26] fix: remove xcbeautify pipe to expose raw xcodebuild errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build fails instantly after package resolution (< 2ms) — not a compile error. xcbeautify filters the actual error. Raw xcodebuild output needed to see what fails. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72a6d73..2b74163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,7 @@ jobs: -configuration Debug \ -destination 'platform=macOS' \ CODE_SIGNING_ALLOWED=NO \ - build \ - 2>&1 | xcbeautify + build lint: name: Lint From bad1eae07cdfdfd948a3dc73686a94f0adf79010 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:19:14 +0900 Subject: [PATCH 20/26] fix: trust Swift macros (FrameworkToolbox) for Xcode 26 CI Xcode 26 CI runner (26.4.1) requires macros from FrameworkToolbox to be explicitly trusted. Add defaults write IDESkipPackagePluginFingerprintValidatation to all three workflows (ci, test, release). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 +++++- .github/workflows/release.yml | 3 +++ .github/workflows/test.yml | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b74163..65e10ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: restore-keys: | ${{ runner.os }}-derived-data- + - name: Trust Swift macros + run: defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES + - name: Build run: | set -euo pipefail @@ -49,7 +52,8 @@ jobs: -configuration Debug \ -destination 'platform=macOS' \ CODE_SIGNING_ALLOWED=NO \ - build + build \ + 2>&1 | xcbeautify lint: name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 983a353..85281a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,9 @@ jobs: echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT" echo "bundle_name=$BUNDLE_NAME" >> "$GITHUB_OUTPUT" + - name: Trust Swift macros + run: defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES + - name: Build arm64 archive run: | set -o pipefail diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7efc46..a7c1b28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,9 @@ jobs: restore-keys: | ${{ runner.os }}-derived-data-test- + - name: Trust Swift macros + run: defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES + - name: Run Tests run: | set -euo pipefail From e64120cd78401ac1e3f7f626bcb1ad3489bcafd2 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:22:59 +0900 Subject: [PATCH 21/26] fix: skip package plugin/macro validation in Xcode 26 CI Replace defaults write workaround with -skipPackagePluginValidation xcodebuild flag. Xcode 16+ extends this flag to also skip Swift macro trust validation, which blocks FrameworkToolbox macros on CI runner. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 4 +--- .github/workflows/release.yml | 6 +++--- .github/workflows/test.yml | 4 +--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65e10ef..36e86b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,6 @@ jobs: restore-keys: | ${{ runner.os }}-derived-data- - - name: Trust Swift macros - run: defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES - - name: Build run: | set -euo pipefail @@ -51,6 +48,7 @@ jobs: -scheme perch \ -configuration Debug \ -destination 'platform=macOS' \ + -skipPackagePluginValidation \ CODE_SIGNING_ALLOWED=NO \ build \ 2>&1 | xcbeautify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85281a8..61ae713 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,9 +47,6 @@ jobs: echo "is_beta=$IS_BETA" >> "$GITHUB_OUTPUT" echo "bundle_name=$BUNDLE_NAME" >> "$GITHUB_OUTPUT" - - name: Trust Swift macros - run: defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES - - name: Build arm64 archive run: | set -o pipefail @@ -58,6 +55,7 @@ jobs: -configuration Release \ -archivePath build/$APP_NAME-arm64.xcarchive \ -destination 'generic/platform=macOS' \ + -skipPackagePluginValidation \ ARCHS=arm64 \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ @@ -75,6 +73,7 @@ jobs: -configuration Release \ -archivePath build/$APP_NAME-x86_64.xcarchive \ -destination 'generic/platform=macOS' \ + -skipPackagePluginValidation \ ARCHS=x86_64 \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ @@ -92,6 +91,7 @@ jobs: -configuration Release \ -archivePath build/$APP_NAME-universal.xcarchive \ -destination 'generic/platform=macOS' \ + -skipPackagePluginValidation \ "ARCHS=arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7c1b28..272a82b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,9 +41,6 @@ jobs: restore-keys: | ${{ runner.os }}-derived-data-test- - - name: Trust Swift macros - run: defaults write com.apple.dt.Xcode IDESkipPackagePluginFingerprintValidatation -bool YES - - name: Run Tests run: | set -euo pipefail @@ -52,6 +49,7 @@ jobs: -configuration Debug \ -destination 'platform=macOS' \ -skip-testing:perchUITests \ + -skipPackagePluginValidation \ CODE_SIGNING_ALLOWED=NO \ test \ 2>&1 | xcbeautify From 0f9e0a6a143b489138bd81348387cb70d3ca8263 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:27:25 +0900 Subject: [PATCH 22/26] chore: diagnose - raw xcodebuild output with -skipPackagePluginValidation Check if macro error persists when xcbeautify is removed from ci.yml only. Verifying if -skipPackagePluginValidation is effective on Xcode 26.4.1 CI runner. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36e86b1..2046dfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,7 @@ jobs: -destination 'platform=macOS' \ -skipPackagePluginValidation \ CODE_SIGNING_ALLOWED=NO \ - build \ - 2>&1 | xcbeautify + build lint: name: Lint From 4f47ec93cf7258f2a63277a306cf20d2073307b7 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:30:17 +0900 Subject: [PATCH 23/26] fix: add -skipMacroValidation for Xcode 26 Swift macro trust in CI Xcode 26 added a dedicated -skipMacroValidation flag separate from -skipPackagePluginValidation. The latter only skips plugin validation; macros (FrameworkToolboxMacros etc from FrameworkToolbox via LyricsKit) need the former. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 4 +++- .github/workflows/release.yml | 3 +++ .github/workflows/test.yml | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2046dfc..1c315d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,10 @@ jobs: -configuration Debug \ -destination 'platform=macOS' \ -skipPackagePluginValidation \ + -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO \ - build + build \ + 2>&1 | xcbeautify lint: name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61ae713..1888ba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,7 @@ jobs: -archivePath build/$APP_NAME-arm64.xcarchive \ -destination 'generic/platform=macOS' \ -skipPackagePluginValidation \ + -skipMacroValidation \ ARCHS=arm64 \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ @@ -74,6 +75,7 @@ jobs: -archivePath build/$APP_NAME-x86_64.xcarchive \ -destination 'generic/platform=macOS' \ -skipPackagePluginValidation \ + -skipMacroValidation \ ARCHS=x86_64 \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ @@ -92,6 +94,7 @@ jobs: -archivePath build/$APP_NAME-universal.xcarchive \ -destination 'generic/platform=macOS' \ -skipPackagePluginValidation \ + -skipMacroValidation \ "ARCHS=arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_IDENTITY="-" \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 272a82b..8e378f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,7 @@ jobs: -destination 'platform=macOS' \ -skip-testing:perchUITests \ -skipPackagePluginValidation \ + -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO \ test \ 2>&1 | xcbeautify From 623e51f7ea46ddbe192b473b14d058b39c5859b9 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:38:41 +0900 Subject: [PATCH 24/26] fix: rename perch.app to perch-beta.app post-archive instead of PRODUCT_NAME override PRODUCT_NAME=perch-beta passed via CLI propagates to ALL SPM targets, causing 'Multiple commands produce perch-beta.bundle' from CryptoSwift/Defaults/KeyboardShortcuts resource bundles. Solution: build with default PRODUCT_NAME (perch), then rename app bundle post-archive for beta. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1888ba5..10390c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,6 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ - PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ SKIP_INSTALL=NO 2>&1 | tail -100 - name: Build x86_64 archive @@ -82,7 +81,6 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ - PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ SKIP_INSTALL=NO 2>&1 | tail -100 - name: Build universal archive @@ -101,9 +99,18 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ - PRODUCT_NAME="${{ steps.version.outputs.bundle_name }}" \ SKIP_INSTALL=NO 2>&1 | tail -100 + - name: Rename app bundle for beta + if: steps.version.outputs.is_beta == 'true' + run: | + for ARCH in arm64 x86_64 universal; do + SRC="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/perch.app" + DST="build/$APP_NAME-${ARCH}.xcarchive/Products/Applications/perch-beta.app" + mv "$SRC" "$DST" + echo "Renamed: perch.app → perch-beta.app (${ARCH})" + done + - name: Create DMGs run: | VERSION="${{ steps.version.outputs.version }}" From fb255e24cb935fb040cfe9682e0c6ca614b4ed7f Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 14:53:26 +0900 Subject: [PATCH 25/26] fix: derive x86_64 archive from universal via lipo (Swift Macro cross-build broken in Xcode 26) x86_64-only xcodebuild archive fails in Xcode 26.4.1 because FrameworkToolbox macro plugin binaries are host (arm64) only and not found at the expected x86_64 install path. Remove separate x86_64 archive step and instead copy the universal archive and use lipo -thin x86_64 to extract the x86_64 slice. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10390c2..ebd5d6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,24 +65,6 @@ jobs: MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ SKIP_INSTALL=NO 2>&1 | tail -100 - - name: Build x86_64 archive - run: | - set -o pipefail - xcodebuild archive \ - -scheme "$SCHEME" \ - -configuration Release \ - -archivePath build/$APP_NAME-x86_64.xcarchive \ - -destination 'generic/platform=macOS' \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - ARCHS=x86_64 \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGN_IDENTITY="-" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ - SKIP_INSTALL=NO 2>&1 | tail -100 - - name: Build universal archive run: | set -o pipefail @@ -101,6 +83,16 @@ jobs: MARKETING_VERSION="${{ steps.version.outputs.base_version }}" \ SKIP_INSTALL=NO 2>&1 | tail -100 + - name: Create x86_64 archive from universal (lipo thin) + run: | + cp -Rp "build/$APP_NAME-universal.xcarchive" "build/$APP_NAME-x86_64.xcarchive" + find "build/$APP_NAME-x86_64.xcarchive/Products" -type f | while read f; do + if file "$f" | grep -q "Mach-O universal binary"; then + lipo "$f" -thin x86_64 -output "$f" + fi + done + echo "Created x86_64 archive from universal via lipo" + - name: Rename app bundle for beta if: steps.version.outputs.is_beta == 'true' run: | From 18a0fd1f54279a20b16abb3a77c227383022bb51 Mon Sep 17 00:00:00 2001 From: tukuyomil032 Date: Fri, 5 Jun 2026 15:06:36 +0900 Subject: [PATCH 26/26] fix: add tag_name to softprops/action-gh-release for workflow_dispatch workflow_dispatch does not create a git tag automatically. Explicitly pass tag_name so the action can create/find the tag when triggered manually. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebd5d6e..d7d8932 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,6 +166,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.version.outputs.tag }} name: "Perch ${{ steps.version.outputs.version }}" body: ${{ steps.body.outputs.body }} files: |