diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 02e0162..3d4e01b 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -454,12 +454,12 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = 9Y64TRM77N; + DEVELOPMENT_TEAM = RPC964BP8A; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -526,13 +526,13 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + "DEVELOPMENT_TEAM[sdk=macosx*]" = RPC964BP8A; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 03c4a70..2273b82 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -2314,7 +2314,6 @@ }, "±10 Seconds" : { "comment" : "Display name for the \"±10 Seconds\" music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -2438,6 +2437,10 @@ } } }, + "1. Open Spotify in a browser and log in" : { + "comment" : "A step in the process of getting a Spotify cookie.", + "isCommentAutoGenerated" : true + }, "1m" : { "localizations" : { "ko" : { @@ -2511,6 +2514,10 @@ } } }, + "2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com" : { + "comment" : "A step to copy the Spotify cookie from the developer tools.", + "isCommentAutoGenerated" : true + }, "3 items" : { "comment" : "merge:ko:alt='항목 3개'", "localizations" : { @@ -2546,6 +2553,10 @@ } } }, + "3. Copy the value of `sp_dc` and paste it here" : { + "comment" : "A step to paste the Spotify cookie into the app.", + "isCommentAutoGenerated" : true + }, "5 colors" : { "comment" : "merge:ko:alt='5가지 색상'", "localizations" : { @@ -3138,7 +3149,6 @@ }, "Accent" : { "comment" : "Display name for the caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -3696,7 +3706,6 @@ }, "Adaptive gradient" : { "comment" : "Display name for the timer icon color mode: adaptive gradient.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -5623,6 +5632,10 @@ } } }, + "Amazon Music" : { + "comment" : "Spotify music service.", + "isCommentAutoGenerated" : true + }, "Animation" : { "extractionState" : "stale", "localizations" : { @@ -6016,7 +6029,6 @@ }, "Apple Music" : { "comment" : "Media controller type for Apple Music", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -6386,6 +6398,10 @@ } } }, + "Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track." : { + "comment" : "A description of how Spotify cookies are used by Atoll.", + "isCommentAutoGenerated" : true + }, "Atoll's built-in volume key interception is disabled while external volume listening is on. Volume HUD/OSD will follow %@ payloads." : { "comment" : "A description below the toggle that explains the effect of enabling or disabling the feature.", "isCommentAutoGenerated" : true, @@ -6920,7 +6936,6 @@ }, "Bar" : { "comment" : "Name of the timer progress style that shows a horizontal bar.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -9952,7 +9967,6 @@ } }, "Clear" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -14521,7 +14535,6 @@ }, "Digital" : { "comment" : "The display name for the \"Digital\" reminder presentation style.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -20829,7 +20842,6 @@ }, "Green" : { "comment" : "Display name for the green caps lock indicator tint mode.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -21574,6 +21586,10 @@ } } }, + "How to get it:" : { + "comment" : "A heading for the instructions on how to get the Spotify cookie.", + "isCommentAutoGenerated" : true + }, "https://example.com/animation.json" : { "comment" : "A placeholder text for a Lottie JSON URL input field.", "isCommentAutoGenerated" : true, @@ -26270,6 +26286,10 @@ } } }, + "Method source" : { + "comment" : "A link to the source code of Spotify's method for requesting a Spotify web player token.", + "isCommentAutoGenerated" : true + }, "Mic" : { "comment" : "Name of a sneak action type that controls the microphone.", "isCommentAutoGenerated" : true, @@ -29944,7 +29964,6 @@ }, "Now Playing" : { "comment" : "Name of the media controller type for the \"Now Playing\" option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30436,6 +30455,10 @@ } } }, + "Open Spotify Web Player" : { + "comment" : "A link to open Spotify.", + "isCommentAutoGenerated" : true + }, "Open System Settings" : { "localizations" : { "ko" : { @@ -30690,7 +30713,6 @@ }, "Panel" : { "comment" : "Display name for the clipboard display mode \"Panel\".", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -30791,6 +30813,14 @@ } } }, + "Paste from Clipboard" : { + "comment" : "A button that copies the `sp_dc` cookie from the clipboard.", + "isCommentAutoGenerated" : true + }, + "Paste the `sp_dc` cookie from your logged-in Spotify web session." : { + "comment" : "A description of how to paste the Spotify cookie.", + "isCommentAutoGenerated" : true + }, "Pause" : { "localizations" : { "ru" : { @@ -34233,7 +34263,6 @@ }, "Ring" : { "comment" : "The name of the timer progress style that shows a ring shape.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -35755,7 +35784,6 @@ }, "Separate Tab" : { "comment" : "Display name for the clipboard display mode option to open the clipboard in a separate tab.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38188,7 +38216,6 @@ }, "Shows screen assistant as a dropdown attached to the AI button" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant as a dropdown attached to the AI button.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38213,7 +38240,6 @@ }, "Shows screen assistant in a floating panel near the notch" : { "comment" : "Description of the screen assistant display mode option that shows the screen assistant in a floating panel near the notch.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -38441,7 +38467,6 @@ }, "Skip forward or backward by ten seconds" : { "comment" : "Description of a music skip behavior option.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39034,6 +39059,10 @@ } } }, + "sp_dc cookie" : { + "comment" : "A label for the Spotify `sp_dc` cookie.", + "isCommentAutoGenerated" : true + }, "Speed" : { "localizations" : { "ar" : { @@ -39165,7 +39194,6 @@ }, "Spotify" : { "comment" : "Spotify media controller type.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -39188,6 +39216,10 @@ } } }, + "Spotify Canvas Session" : { + "comment" : "A section header for the Spotify Canvas session settings.", + "isCommentAutoGenerated" : true + }, "Square" : { "localizations" : { "ar" : { @@ -39402,7 +39434,6 @@ }, "Standard previous/next track controls" : { "comment" : "Description of the music skip behavior option to use standard previous/next track controls.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -42230,7 +42261,6 @@ }, "Track Skip" : { "comment" : "Display name for the \"Track Skip\" music skip behavior.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { @@ -43892,6 +43922,14 @@ }, "shouldTranslate" : false }, + "Validate Cookie" : { + "comment" : "A button that validates a Spotify session cookie.", + "isCommentAutoGenerated" : true + }, + "Validating..." : { + "comment" : "The text of a button that is shown while Spotify's session is being validated.", + "isCommentAutoGenerated" : true + }, "Variant %lld" : { "comment" : "A label for a specific variant of the Liquid Glass.", "isCommentAutoGenerated" : true, @@ -45595,7 +45633,6 @@ }, "Youtube Music" : { "comment" : "Spotify music service.", - "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "ko" : { diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 0380d96..83d09e1 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -20,8 +20,8 @@ * along with this program. If not, see . */ -import Foundation import Combine +import Foundation import SwiftUI class SpotifyController: MediaControllerProtocol { @@ -31,91 +31,117 @@ class SpotifyController: MediaControllerProtocol { @Published private var playbackState: PlaybackState = PlaybackState( bundleIdentifier: SpotifyController.bundleIdentifier ) - + var playbackStatePublisher: AnyPublisher { $playbackState.eraseToAnyPublisher() } - + var isWorking: Bool { - return true // Spotify controller always works + true } - + private var notificationTask: Task? - + private var sessionChangeCancellable: AnyCancellable? + // Constant for time between command and update private let commandUpdateDelay: Duration = .milliseconds(25) private var lastArtworkURL: String? private var artworkFetchTask: Task? - + private var canvasFetchTask: Task? + private var currentCanvasTrackURI: String? + private var lastCanvasRequestTrackURI: String? + private var lastCanvasRequestDate: Date = .distantPast + private let canvasResolver = SpotifyCanvasResolver() + init() { setupPlaybackStateChangeObserver() + setupSessionChangeObserver() Task { if isActive() { await updatePlaybackInfo() } } } - + private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in let notifications = DistributedNotificationCenter.default().notifications( named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") ) - + for await _ in notifications { await self?.updatePlaybackInfo() } } } - + + private func setupSessionChangeObserver() { + sessionChangeCancellable = NotificationCenter.default.publisher(for: .spotifyCanvasSessionDidChange) + .sink { [weak self] _ in + self?.lastCanvasRequestTrackURI = nil + self?.lastCanvasRequestDate = .distantPast + if let self { + var updatedState = self.playbackState + updatedState.liveArtworkURL = nil + self.playbackState = updatedState + } + Task { + await self?.updatePlaybackInfo() + } + } + } + deinit { notificationTask?.cancel() artworkFetchTask?.cancel() + canvasFetchTask?.cancel() + sessionChangeCancellable?.cancel() } - + // MARK: - Protocol Implementation func play() async { await executeCommand("play") } func pause() async { await executeCommand("pause") } func togglePlay() async { await executeCommand("playpause") } func nextTrack() async { await executeCommand("next track") } - + func previousTrack() async { await executeAndRefresh("previous track") } - + func seek(to time: Double) async { await executeAndRefresh("set player position to \(time)") } - + func toggleShuffle() async { await executeAndRefresh("set shuffling to not shuffling") } - + func toggleRepeat() async { await executeAndRefresh("set repeating to not repeating") } - + func isActive() -> Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } } - + func updatePlaybackInfo() async { guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard descriptor.numberOfItems >= 11 else { return } - + let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 - let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000 + let duration = (descriptor.atIndex(6)?.doubleValue ?? 0) / 1000 let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" - let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - + let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? "" + let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? "" + let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString) + var state = PlaybackState( bundleIdentifier: Self.bundleIdentifier, isPlaying: isPlaying, @@ -129,14 +155,30 @@ class SpotifyController: MediaControllerProtocol { repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) - state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier - state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL + state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + state.contentURL = spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines) if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { state.artwork = existingArtwork } - playbackState = state + if trackURI == currentCanvasTrackURI { + state.liveArtworkURL = playbackState.liveArtworkURL + } else { + currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI + canvasFetchTask?.cancel() + canvasFetchTask = nil + } + + playbackState = state + + if !trackURI.isEmpty { + scheduleCanvasFetchIfNeeded(for: trackURI) + } if !artworkURL.isEmpty, let url = URL(string: artworkURL) { guard artworkURL != lastArtworkURL || state.artwork == nil else { return } @@ -152,6 +194,7 @@ class SpotifyController: MediaControllerProtocol { guard let self = self else { return } var updatedState = currentState updatedState.artwork = data + updatedState.liveArtworkURL = self.playbackState.liveArtworkURL self.playbackState = updatedState self.lastArtworkURL = artworkURL self.artworkFetchTask = nil @@ -164,9 +207,43 @@ class SpotifyController: MediaControllerProtocol { } } } - -// MARK: - Private Methods - + + // MARK: - Private Methods + + private func scheduleCanvasFetchIfNeeded(for trackURI: String) { + guard !trackURI.isEmpty else { return } + guard playbackState.liveArtworkURL == nil else { return } + guard canvasFetchTask == nil else { return } + + if lastCanvasRequestTrackURI == trackURI, + Date().timeIntervalSince(lastCanvasRequestDate) < 5 + { + return + } + + lastCanvasRequestTrackURI = trackURI + lastCanvasRequestDate = Date() + + canvasFetchTask = Task { [weak self] in + guard let self else { return } + + let canvasURL = await self.canvasResolver.canvasURL(for: trackURI) + guard !Task.isCancelled else { return } + + await MainActor.run { + guard self.currentCanvasTrackURI == trackURI else { + self.canvasFetchTask = nil + return + } + + var updatedState = self.playbackState + updatedState.liveArtworkURL = canvasURL + self.playbackState = updatedState + self.canvasFetchTask = nil + } + } + } + private func executeCommand(_ command: String) async { let script = "tell application \"Spotify\" to \(command)" try? await AppleScriptHelper.executeVoid(script) @@ -177,7 +254,7 @@ class SpotifyController: MediaControllerProtocol { try? await Task.sleep(for: commandUpdateDelay) await updatePlaybackInfo() } - + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Spotify" @@ -192,21 +269,294 @@ class SpotifyController: MediaControllerProtocol { set shuffleState to shuffling set repeatState to repeating set artworkURL to artwork url of current track - set trackIdentifier to "" - try - set trackIdentifier to id of current track - end try - set trackSpotifyURL to "" - try - set trackSpotifyURL to spotify url of current track - end try - return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL} + set trackID to id of current track + set spotifyURL to spotify url of current track + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL} on error return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} end try end tell """ - + return try await AppleScriptHelper.execute(script) } + + private static func canonicalTrackURI(from trackIdentifier: String, spotifyURLString: String) -> String { + let trimmedIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedIdentifier.hasPrefix("spotify:track:") { + return trimmedIdentifier + } + + if isLikelySpotifyTrackID(trimmedIdentifier) { + return "spotify:track:\(trimmedIdentifier)" + } + + if let trackID = extractTrackID(from: spotifyURLString) { + return "spotify:track:\(trackID)" + } + + return "" + } + + private static func extractTrackID(from spotifyURLString: String) -> String? { + guard let url = URL(string: spotifyURLString), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + + let pathComponents = components.path.split(separator: "/") + guard let trackIndex = pathComponents.firstIndex(of: "track"), + trackIndex + 1 < pathComponents.count + else { return nil } + + let identifier = String(pathComponents[trackIndex + 1]) + return isLikelySpotifyTrackID(identifier) ? identifier : nil + } + + private static func isLikelySpotifyTrackID(_ value: String) -> Bool { + value.count == 22 && value.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) + } + } +} + +private actor SpotifyCanvasResolver { + private struct CacheEntry { + let url: URL? + let expiresAt: Date + } + + private var cache: [String: CacheEntry] = [:] + + func canvasURL(for trackURI: String) async -> URL? { + let now = Date() + if let entry = cache[trackURI], entry.expiresAt > now { + return entry.url + } + + let resolvedURL = await fetchCanvasURL(for: trackURI, retryOnUnauthorized: true) + let cacheLifetime: TimeInterval = resolvedURL == nil ? 300 : 3600 + cache[trackURI] = CacheEntry(url: resolvedURL, expiresAt: now.addingTimeInterval(cacheLifetime)) + return resolvedURL + } + + private func fetchCanvasURL(for trackURI: String, retryOnUnauthorized: Bool) async -> URL? { + guard let accessToken = await SpotifyAuthManager.shared.validAccessToken() else { + return nil + } + + var request = URLRequest(url: URL(string: "https://spclient.wg.spotify.com/canvaz-cache/v0/canvases")!) + request.httpMethod = "POST" + request.httpBody = SpotifyCanvasProtobuf.makeCanvasRequest(trackURI: trackURI) + request.setValue("application/protobuf", forHTTPHeaderField: "Accept") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("en", forHTTPHeaderField: "Accept-Language") + request.setValue("Spotify/9.0.34.593 iOS/18.4 (iPhone15,3)", forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + + if httpResponse.statusCode == 401, retryOnUnauthorized { + await SpotifyAuthManager.shared.invalidateAccessToken() + return await fetchCanvasURL(for: trackURI, retryOnUnauthorized: false) + } + + guard (200..<300).contains(httpResponse.statusCode) else { + return nil + } + + return SpotifyCanvasProtobuf.parseCanvasURL(from: data, matching: trackURI) + } catch { + return nil + } + } +} + +private enum SpotifyCanvasProtobuf { + static func makeCanvasRequest(trackURI: String) -> Data { + let uriData = Data(trackURI.utf8) + + var trackMessage = Data() + trackMessage.append(protobufKey(fieldNumber: 1, wireType: 2)) + trackMessage.append(varint(for: uriData.count)) + trackMessage.append(uriData) + + var request = Data() + request.append(protobufKey(fieldNumber: 1, wireType: 2)) + request.append(varint(for: trackMessage.count)) + request.append(trackMessage) + return request + } + + static func parseCanvasURL(from data: Data, matching trackURI: String) -> URL? { + do { + var reader = ProtobufReader(data: data) + var matchingURLs: [URL] = [] + + while !reader.isAtEnd { + let key = try reader.readVarint() + let fieldNumber = Int(key >> 3) + let wireType = Int(key & 0x7) + + switch (fieldNumber, wireType) { + case (1, 2): + let canvasData = try reader.readLengthDelimited() + if let record = try parseCanvasRecord(from: canvasData), + record.trackURI == trackURI, + let canvasURL = record.canvasURL + { + matchingURLs.append(canvasURL) + } + default: + try reader.skipField(wireType: wireType) + } + } + + return matchingURLs.first { $0.absoluteString.lowercased().hasSuffix(".mp4") } ?? matchingURLs.first + } catch { + return nil + } + } + + private static func parseCanvasRecord(from data: Data) throws -> SpotifyCanvasRecord? { + var reader = ProtobufReader(data: data) + var trackURI = "" + var canvasURL: URL? + + while !reader.isAtEnd { + let key = try reader.readVarint() + let fieldNumber = Int(key >> 3) + let wireType = Int(key & 0x7) + + switch (fieldNumber, wireType) { + case (2, 2): + let urlString = try reader.readString() + canvasURL = URL(string: urlString) + case (5, 2): + trackURI = try reader.readString() + default: + try reader.skipField(wireType: wireType) + } + } + + guard !trackURI.isEmpty else { return nil } + return SpotifyCanvasRecord(trackURI: trackURI, canvasURL: canvasURL) + } + + private static func protobufKey(fieldNumber: Int, wireType: Int) -> Data { + var key = Data() + key.append(varint(for: (fieldNumber << 3) | wireType)) + return key + } + + private static func varint(for value: Int) -> Data { + var remaining = UInt64(value) + var buffer = Data() + + repeat { + var nextByte = UInt8(remaining & 0x7F) + remaining >>= 7 + if remaining != 0 { + nextByte |= 0x80 + } + buffer.append(nextByte) + } while remaining != 0 + + return buffer + } +} + +private struct SpotifyCanvasRecord { + let trackURI: String + let canvasURL: URL? +} + +private struct ProtobufReader { + enum ReaderError: Error { + case malformedVarint + case unexpectedEnd + case unsupportedWireType + } + + private let data: Data + private var index: Data.Index + + init(data: Data) { + self.data = data + self.index = data.startIndex + } + + var isAtEnd: Bool { + index >= data.endIndex + } + + mutating func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + + while shift < 64 { + guard index < data.endIndex else { + throw ReaderError.unexpectedEnd + } + + let byte = data[index] + index = data.index(after: index) + + result |= UInt64(byte & 0x7F) << shift + if (byte & 0x80) == 0 { + return result + } + + shift += 7 + } + + throw ReaderError.malformedVarint + } + + mutating func readLengthDelimited() throws -> Data { + let length = Int(try readVarint()) + guard length >= 0 else { + throw ReaderError.unexpectedEnd + } + + let endIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex) + guard let endIndex else { + throw ReaderError.unexpectedEnd + } + + let slice = data[index.. String { + let bytes = try readLengthDelimited() + return String(decoding: bytes, as: UTF8.self) + } + + mutating func skipField(wireType: Int) throws { + switch wireType { + case 0: + _ = try readVarint() + case 1: + try advance(by: 8) + case 2: + _ = try readLengthDelimited() + case 5: + try advance(by: 4) + default: + throw ReaderError.unsupportedWireType + } + } + + private mutating func advance(by count: Int) throws { + let endIndex = data.index(index, offsetBy: count, limitedBy: data.endIndex) + guard let endIndex else { + throw ReaderError.unexpectedEnd + } + index = endIndex + } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 17716a4..4d6fbfd 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -46,6 +46,7 @@ struct LockScreenMusicPanel: View { @State private var isExpanded = false @State private var isVolumeSliderVisible = false @State private var isAirPlayPopoverPresented = false + @State private var isArtworkFullscreen = false @State private var collapseWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem? @@ -66,6 +67,8 @@ struct LockScreenMusicPanel: View { @Default(.enableLyrics) private var enableLyrics @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled + @Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork init(animator: LockScreenPanelAnimator) { _animator = ObservedObject(wrappedValue: animator) @@ -163,6 +166,11 @@ struct LockScreenMusicPanel: View { parallaxResumeWorkItem = nil isParallaxSuspended = false } + .onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + } .onChange(of: isExpanded) { _, expanded in updatePanelSize() } @@ -329,30 +337,43 @@ struct LockScreenMusicPanel: View { } private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { - Button(action: toggleExpanded) { - ZStack(alignment: .bottomTrailing) { - albumArtImage(size: size, cornerRadius: cornerRadius) - if showAppIcon, let icon = lockScreenAppIcon { - icon - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: appIconSize, height: appIconSize) - - .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) - .offset(x: appIconOffset, y: appIconOffset) - .transition(.scale.combined(with: .opacity)) - } + ZStack(alignment: .bottomTrailing) { + if isArtworkFullscreen { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.white.opacity(0.06)) + .overlay( + Image(systemName: "photo.fill") + .font(.system(size: size * 0.3, weight: .light)) + .foregroundColor(.white.opacity(0.2)) + ) + } else { + albumArtImage(size: size, cornerRadius: cornerRadius) + if showAppIcon, let icon = lockScreenAppIcon { + icon + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: appIconSize, height: appIconSize) + .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) + .offset(x: appIconOffset, y: appIconOffset) + .transition(.scale.combined(with: .opacity)) } - .albumArtFlip(angle: musicManager.flipAngle) - .parallax3D(enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended) - .frame(width: size) - .background(albumArtBackground(cornerRadius: cornerRadius)) - .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous)) + } } - .buttonStyle(PlainButtonStyle()) + .albumArtFlip(angle: isArtworkFullscreen ? 0 : musicManager.flipAngle) + .parallax3D(enableOverride: lockScreenParallaxEnabled && !isArtworkFullscreen, suspended: isParallaxSuspended) + .frame(width: size) + .background(albumArtBackground(cornerRadius: cornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous)) .opacity(musicManager.isPlaying ? 1 : 0.4) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) + .animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen) + .onTapGesture { + toggleExpanded() + } + .onRightClick { + expandArtworkToFullscreen() + } } @ViewBuilder @@ -384,6 +405,41 @@ struct LockScreenMusicPanel: View { } } + private func expandArtworkToFullscreen() { + guard fullscreenArtworkEnabled else { return } + + if FullScreenArtworkWindowManager.shared.isShowing { + FullScreenArtworkWindowManager.shared.hide() + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + return + } + + guard musicManager.hasActiveSession else { return } + + let artwork = musicManager.albumArt + let videoURL = musicManager.videoArtworkURL + + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = true + } + + FullScreenArtworkWindowManager.shared.onDismiss = { + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.28)) { + NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil) + } + } + } + + FullScreenArtworkWindowManager.shared.show( + artwork: artwork, + videoURL: fullscreenVideoArtwork ? videoURL : nil, + allowLiveWallpaper: fullscreenVideoArtwork + ) + } + private func registerInteraction() { cancelCollapseTimer() guard isExpanded else { return } @@ -1384,3 +1440,48 @@ private struct GlassTextBackdrop: View { .accessibilityHidden(true) } } + +// MARK: - Right Click Gesture + +private struct RightClickOverlay: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { + content.overlay( + RightClickReceiverView(action: action) + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + } +} + +private struct RightClickReceiverView: NSViewRepresentable { + let action: () -> Void + + func makeNSView(context: Context) -> RightClickNSView { + let view = RightClickNSView() + view.onRightClick = action + return view + } + + func updateNSView(_ nsView: RightClickNSView, context: Context) { + nsView.onRightClick = action + } +} + +private extension View { + func onRightClick(perform action: @escaping () -> Void) -> some View { + modifier(RightClickOverlay(action: action)) + } +} + +final class RightClickNSView: NSView { + var onRightClick: (() -> Void)? + + override func rightMouseDown(with event: NSEvent) { + onRightClick?() + } +} + +extension Notification.Name { + static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed") +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 38302ff..b44c3d2 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2505,6 +2505,11 @@ struct Media: View { .font(.caption) } } + + if mediaController == .spotify { + SpotifyAuthSettingsSection() + } + Section { Defaults.Toggle(key: .showStandardMediaControls) { Text("Show media controls in Dynamic Island") diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift new file mode 100644 index 0000000..28e7b55 --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -0,0 +1,112 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import Defaults +import SwiftUI + +struct SpotifyAuthSettingsSection: View { + @Default(.spotifySPDCCookie) private var spotifySPDCCookie + @ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared + + private var hasCookie: Bool { + !SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty + } + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Paste the `sp_dc` cookie from your logged-in Spotify web session.") + .font(.caption) + .foregroundStyle(.secondary) + + TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.caption.monospaced()) + .lineLimit(2...4) + .textSelection(.enabled) + } + + HStack(spacing: 10) { + Circle() + .fill(spotifyAuthManager.isAuthenticated ? Color.green : (hasCookie ? Color.orange : Color.secondary)) + .frame(width: 8, height: 8) + + Text(spotifyAuthManager.sessionStatusText) + .foregroundStyle(.secondary) + } + + if let authErrorMessage = spotifyAuthManager.authErrorMessage, !authErrorMessage.isEmpty { + Text(authErrorMessage) + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button("Paste from Clipboard") { + pasteCookieFromClipboard() + } + + Button(spotifyAuthManager.isAuthorizing ? "Validating..." : "Validate Cookie") { + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie) + Task { + await spotifyAuthManager.validateSession() + } + } + .disabled(!hasCookie || spotifyAuthManager.isAuthorizing) + + Button("Clear") { + spotifySPDCCookie = "" + spotifyAuthManager.clearSession() + } + .disabled(!hasCookie && !spotifyAuthManager.isAuthenticated) + } + + VStack(alignment: .leading, spacing: 6) { + Text("How to get it:") + .font(.caption) + .foregroundStyle(.secondary) + + Text("1. Open Spotify in a browser and log in") + .font(.caption) + Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com") + .font(.caption) + Text("3. Copy the value of `sp_dc` and paste it here") + .font(.caption) + + HStack(spacing: 12) { + Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!) + Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!) + } + .font(.caption) + } + .padding(.top, 2) + } header: { + Text("Spotify Canvas Session") + } footer: { + Text("Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track.") + .foregroundStyle(.secondary) + .font(.caption) + } + } + + private func pasteCookieFromClipboard() { + guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return } + spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText) + } +} diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift new file mode 100644 index 0000000..c94a924 --- /dev/null +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -0,0 +1,824 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import AVFoundation +import Combine +import SkyLightWindow +import SwiftUI + +// MARK: - Click Receiver Window + +private class ClickReceiverWindow: NSWindow { + var onClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + onClick?() + } + + override func rightMouseDown(with event: NSEvent) { + onClick?() + } +} + +private final class LoopingVideoView: NSView { + private var playerLayer: AVPlayerLayer? + private var queuePlayer: AVQueuePlayer? + private var playerLooper: AVPlayerLooper? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + override func layout() { + super.layout() + playerLayer?.frame = bounds + } + + func play(url: URL) { + stop() + + let playerItem = AVPlayerItem(url: url) + let player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + + let layer = AVPlayerLayer(player: player) + layer.videoGravity = .resizeAspectFill + layer.frame = bounds + layer.backgroundColor = NSColor.clear.cgColor + + self.layer?.sublayers?.forEach { $0.removeFromSuperlayer() } + self.layer?.addSublayer(layer) + + playerLayer = layer + queuePlayer = player + playerLooper = AVPlayerLooper(player: player, templateItem: playerItem) + player.play() + } + + func stop() { + queuePlayer?.pause() + playerLooper = nil + queuePlayer = nil + playerLayer?.removeFromSuperlayer() + playerLayer = nil + } +} + +// MARK: - Window Manager + +@MainActor +final class FullScreenArtworkWindowManager { + static let shared = FullScreenArtworkWindowManager() + + private(set) var isShowing = false + var onDismiss: (() -> Void)? + + private let wallpaperPlistURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist") + }() + private let backupPlistURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_wallpaper_backup.plist") + }() + private let aerialManifestURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/manifest/entries.json") + }() + private let aerialVideosDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/videos", isDirectory: true) + }() + private let aerialThumbnailsDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/thumbnails", isDirectory: true) + }() + private let customLiveWallpaperAssetID = "6D6834A4-2F0F-479A-B053-7D4DC5CB8EB7" + private let liveWallpaperManifestBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_manifest_backup.json") + }() + private let liveWallpaperVideoBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_video_backup.mov") + }() + private let liveWallpaperThumbnailBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_thumbnail_backup.png") + }() + private var artworkFileURL: URL? + private var cachedArtworkPNG: URL? + private var cachedArtworkIdentifier: String? + private var activeSongTitle: String? + private var activeArtist: String? + private var trackChangeCancellable: AnyCancellable? + private var artworkCacheCancellable: AnyCancellable? + private var videoArtworkCancellable: AnyCancellable? + private var clickWindow: ClickReceiverWindow? + private var clickWindowDelegated = false + private var videoWindow: NSWindow? + private var videoView: LoopingVideoView? + private var liveWallpaperTask: Task? + private var isLiveWallpaperAllowed = false + private var activeLiveWallpaperFingerprint: String? + + private init() { + observeArtworkChanges() + observeVideoArtworkChanges() + } + + func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) { + guard !isShowing else { return } + guard let screen = NSScreen.main else { return } + + let fileURL: URL + let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + + if let cached = cachedArtworkPNG, + cachedArtworkIdentifier == artworkID, + FileManager.default.fileExists(atPath: cached.path) { + fileURL = cached + } else { + guard let encoded = encodeToPNG(artwork) else { return } + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("atoll_artwork_wallpaper.png") + do { + try encoded.write(to: tempURL, options: .atomic) + } catch { + return + } + fileURL = tempURL + cachedArtworkPNG = tempURL + cachedArtworkIdentifier = artworkID + } + + artworkFileURL = fileURL + isLiveWallpaperAllowed = allowLiveWallpaper + activeLiveWallpaperFingerprint = nil + backupWallpaperConfig() + + guard applyArtworkToPlist(imageURL: fileURL) else { + print("[FullScreenArtworkWindowManager] Failed to patch plist") + return + } + + restartWallpaperAgent() + hideVideoWindow() + showClickReceiver(on: screen) + + isShowing = true + activeSongTitle = MusicManager.shared.songTitle + activeArtist = MusicManager.shared.artistName + + observeTrackChanges() + scheduleLiveWallpaperPreparation(for: videoURL, artwork: artwork, identifier: artworkID) + + print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper") + } + + func hide() { + guard isShowing else { return } + isShowing = false + isLiveWallpaperAllowed = false + activeLiveWallpaperFingerprint = nil + trackChangeCancellable?.cancel() + trackChangeCancellable = nil + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + + hideVideoWindow() + hideClickReceiver() + restoreWallpaper() + + activeSongTitle = nil + activeArtist = nil + + let callback = onDismiss + onDismiss = nil + callback?() + + print("[FullScreenArtworkWindowManager] Original wallpaper restored") + } + + // MARK: - Artwork Pre-Cache + + private func observeArtworkChanges() { + artworkCacheCancellable = MusicManager.shared.$albumArt + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] newArt in + guard let self else { return } + let artID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + guard artID != self.cachedArtworkIdentifier else { return } + Task.detached(priority: .utility) { + guard let pngData = self.encodeToPNG(newArt) else { return } + let url = FileManager.default.temporaryDirectory.appendingPathComponent("atoll_artwork_wallpaper.png") + try? pngData.write(to: url, options: .atomic) + await MainActor.run { + self.cachedArtworkPNG = url + self.cachedArtworkIdentifier = artID + } + } + } + } + + private func observeVideoArtworkChanges() { + videoArtworkCancellable = MusicManager.shared.$videoArtworkURL + .receive(on: DispatchQueue.main) + .sink { [weak self] videoURL in + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + self.scheduleLiveWallpaperPreparation( + for: videoURL, + artwork: MusicManager.shared.albumArt, + identifier: artworkID + ) + } + } + + private nonisolated func encodeToPNG(_ image: NSImage) -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) + else { return nil } + return bitmap.representation(using: .png, properties: [:]) + } + + private func scheduleLiveWallpaperPreparation(for videoURL: URL?, artwork: NSImage, identifier: String) { + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + + guard isLiveWallpaperAllowed else { return } + guard let artworkFileURL else { return } + + let nextFingerprint = videoURL?.absoluteString + if activeLiveWallpaperFingerprint != nil, activeLiveWallpaperFingerprint != nextFingerprint { + activeLiveWallpaperFingerprint = nil + if applyArtworkToPlist(imageURL: artworkFileURL) { + restartWallpaperAgent() + } + } + + guard let videoURL else { return } + if activeLiveWallpaperFingerprint == nextFingerprint { return } + if let screen = NSScreen.main { + showVideoWindow(on: screen, videoURL: videoURL) + } + + let assetID = customLiveWallpaperAssetID + let manifestURL = aerialManifestURL + let videosDirectoryURL = aerialVideosDirectoryURL + let thumbnailsDirectoryURL = aerialThumbnailsDirectoryURL + let manifestBackupURL = liveWallpaperManifestBackupURL + let videoBackupURL = liveWallpaperVideoBackupURL + let thumbnailBackupURL = liveWallpaperThumbnailBackupURL + let thumbnailData = encodeToPNG(artwork) + let title = activeSongTitle ?? MusicManager.shared.songTitle + let artist = activeArtist ?? MusicManager.shared.artistName + let displayName = [title, artist] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " - ") + + liveWallpaperTask = Task(priority: .utility) { [weak self] in + let prepared = await Self.prepareCustomLiveWallpaperAsset( + from: videoURL, + assetID: assetID, + displayName: displayName.isEmpty ? "Atoll Canvas" : displayName, + manifestURL: manifestURL, + videosDirectoryURL: videosDirectoryURL, + thumbnailsDirectoryURL: thumbnailsDirectoryURL, + thumbnailData: thumbnailData, + manifestBackupURL: manifestBackupURL, + videoBackupURL: videoBackupURL, + thumbnailBackupURL: thumbnailBackupURL + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + let currentIdentifier = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + guard currentIdentifier == identifier else { return } + guard prepared else { return } + guard self.applyAerialToPlist(assetID: assetID) else { return } + + self.activeLiveWallpaperFingerprint = nextFingerprint + self.hideVideoWindow() + self.restartWallpaperAgent() + print("[FullScreenArtworkWindowManager] Live wallpaper applied") + } + } + } + + private nonisolated static func prepareCustomLiveWallpaperAsset( + from sourceURL: URL, + assetID: String, + displayName: String, + manifestURL: URL, + videosDirectoryURL: URL, + thumbnailsDirectoryURL: URL, + thumbnailData: Data?, + manifestBackupURL: URL, + videoBackupURL: URL, + thumbnailBackupURL: URL + ) async -> Bool { + let fm = FileManager.default + let videoDestinationURL = videosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = thumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + do { + try fm.createDirectory(at: videosDirectoryURL, withIntermediateDirectories: true) + try fm.createDirectory(at: thumbnailsDirectoryURL, withIntermediateDirectories: true) + } catch { + return false + } + + backupItemIfNeeded(at: manifestURL, backupURL: manifestBackupURL) + backupItemIfNeeded(at: videoDestinationURL, backupURL: videoBackupURL) + backupItemIfNeeded(at: thumbnailDestinationURL, backupURL: thumbnailBackupURL) + + guard await materializeVideo(from: sourceURL, to: videoDestinationURL) else { + return false + } + + if let thumbnailData { + try? thumbnailData.write(to: thumbnailDestinationURL, options: .atomic) + } + + return updateAerialManifest( + manifestURL: manifestURL, + assetID: assetID, + videoURL: videoDestinationURL, + thumbnailURL: fm.fileExists(atPath: thumbnailDestinationURL.path) ? thumbnailDestinationURL : nil, + displayName: displayName + ) + } + + private nonisolated static func materializeVideo(from sourceURL: URL, to destinationURL: URL) async -> Bool { + let fm = FileManager.default + try? fm.removeItem(at: destinationURL) + + if !sourceURL.isFileURL { + guard let downloadedURL = await downloadRemoteVideo(from: sourceURL) else { + return false + } + defer { try? fm.removeItem(at: downloadedURL) } + return await materializeVideo(from: downloadedURL, to: destinationURL) + } + + if sourceURL.isFileURL, sourceURL.pathExtension.lowercased() == "mov" { + do { + try fm.copyItem(at: sourceURL, to: destinationURL) + return validateVideo(at: destinationURL) + } catch { + return false + } + } + + let asset = AVURLAsset(url: sourceURL) + let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality] + for preset in presets { + try? fm.removeItem(at: destinationURL) + guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else { + continue + } + + exportSession.outputURL = destinationURL + exportSession.outputFileType = .mov + exportSession.shouldOptimizeForNetworkUse = true + + let exported = await export(exportSession) + if exported, validateVideo(at: destinationURL) { + return true + } + } + + return false + } + + private nonisolated static func downloadRemoteVideo(from sourceURL: URL) async -> URL? { + do { + let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + return nil + } + + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension) + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + return destinationURL + } catch { + return nil + } + } + + private nonisolated static func export(_ exportSession: AVAssetExportSession) async -> Bool { + await withCheckedContinuation { continuation in + exportSession.exportAsynchronously { + continuation.resume(returning: exportSession.status == .completed) + } + } + } + + private nonisolated static func validateVideo(at url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + let asset = AVURLAsset(url: url) + return !asset.tracks(withMediaType: .video).isEmpty + } + + private nonisolated static func updateAerialManifest( + manifestURL: URL, + assetID: String, + videoURL: URL, + thumbnailURL: URL?, + displayName: String + ) -> Bool { + guard let manifestData = try? Data(contentsOf: manifestURL), + var root = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + var assets = root["assets"] as? [[String: Any]] + else { + return false + } + + guard let existingIndex = assets.firstIndex(where: { ($0["id"] as? String) == assetID }) else { + return false + } + + var customAsset = assets[existingIndex] + customAsset["accessibilityLabel"] = displayName + customAsset["previewImage"] = thumbnailURL?.absoluteString ?? "" + customAsset["url-4K-SDR-240FPS"] = videoURL.absoluteString + customAsset["pointsOfInterest"] = [:] + assets[existingIndex] = customAsset + + root["assets"] = assets + + guard let encoded = try? JSONSerialization.data(withJSONObject: root, options: []) else { + return false + } + + do { + try encoded.write(to: manifestURL, options: .atomic) + return true + } catch { + return false + } + } + + private nonisolated static func backupItemIfNeeded(at sourceURL: URL, backupURL: URL) { + let fm = FileManager.default + guard fm.fileExists(atPath: sourceURL.path) else { return } + guard !fm.fileExists(atPath: backupURL.path) else { return } + try? fm.copyItem(at: sourceURL, to: backupURL) + } + + private func restoreLiveWallpaperResources() { + let fm = FileManager.default + let assetID = customLiveWallpaperAssetID + let videoDestinationURL = aerialVideosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = aerialThumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + if fm.fileExists(atPath: liveWallpaperManifestBackupURL.path) { + try? fm.removeItem(at: aerialManifestURL) + try? fm.copyItem(at: liveWallpaperManifestBackupURL, to: aerialManifestURL) + try? fm.removeItem(at: liveWallpaperManifestBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperVideoBackupURL.path) { + try? fm.removeItem(at: videoDestinationURL) + try? fm.copyItem(at: liveWallpaperVideoBackupURL, to: videoDestinationURL) + try? fm.removeItem(at: liveWallpaperVideoBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperThumbnailBackupURL.path) { + try? fm.removeItem(at: thumbnailDestinationURL) + try? fm.copyItem(at: liveWallpaperThumbnailBackupURL, to: thumbnailDestinationURL) + try? fm.removeItem(at: liveWallpaperThumbnailBackupURL) + } + } + + // MARK: - Click Receiver + + private func showClickReceiver(on screen: NSScreen) { + let screenFrame = screen.frame + + let window: ClickReceiverWindow + if let existing = clickWindow { + window = existing + } else { + let newWindow = ClickReceiverWindow( + contentRect: screenFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + newWindow.isMovable = false + newWindow.hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + clickWindow = newWindow + window = newWindow + clickWindowDelegated = false + } + + window.setFrame(screenFrame, display: true) + window.onClick = { [weak self] in + self?.hide() + } + + if !clickWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + clickWindowDelegated = true + } + + window.orderFrontRegardless() + + for other in NSApp.windows where other !== window && other.level.rawValue >= Int(CGShieldingWindowLevel()) && other.isVisible { + window.order(.below, relativeTo: other.windowNumber) + } + } + + private func hideClickReceiver() { + clickWindow?.orderOut(nil) + clickWindow?.onClick = nil + } + + private func showVideoWindow(on screen: NSScreen, videoURL: URL) { + let screenFrame = screen.frame + + let window: NSWindow + let view: LoopingVideoView + + if let existingWindow = videoWindow, let existingView = videoView { + window = existingWindow + view = existingView + } else { + let newWindow = NSWindow( + contentRect: screenFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newView = LoopingVideoView(frame: screenFrame) + newWindow.contentView = newView + + videoWindow = newWindow + videoView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + view.play(url: videoURL) + window.orderFrontRegardless() + } + + private func hideVideoWindow() { + videoView?.stop() + videoWindow?.orderOut(nil) + } + + // MARK: - Backup / Restore + + private var hasValidBackup: Bool { + FileManager.default.fileExists(atPath: backupPlistURL.path) + } + + private func backupWallpaperConfig() { + guard !hasValidBackup else { return } + try? FileManager.default.copyItem(at: wallpaperPlistURL, to: backupPlistURL) + } + + private func applyArtworkToPlist(imageURL: URL) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopBlock = makeImageWallpaperBlock(imageURL: imageURL) + let idleBlock = makeImageWallpaperBlock(imageURL: imageURL) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func applyAerialToPlist(assetID: String) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopTemplate = wallpaperBlock( + named: "Desktop", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + let idleTemplate = wallpaperBlock( + named: "Idle", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + + let desktopBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: desktopTemplate) + let idleBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: idleTemplate) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func wallpaperBlock(named name: String, in primary: [String: Any]?, fallback: [String: Any]?) -> [String: Any]? { + if let primaryBlock = primary?[name] as? [String: Any] { + return primaryBlock + } + return fallback?[name] as? [String: Any] + } + + private func makeImageWallpaperBlock(imageURL: URL) -> [String: Any] { + let config: [String: Any] = [ + "type": "imageFile", + "url": ["relative": imageURL.absoluteString] + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let imageChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.image", + "Files": [] as [Any], + "Configuration": configData + ] + + let contentBlock: [String: Any] = [ + "Choices": [imageChoice], + "Shuffle": "$null" + ] + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func makeAerialWallpaperBlock(assetID: String, existingBlock: [String: Any]?) -> [String: Any] { + let config: [String: Any] = [ + "assetID": assetID + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let aerialChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.aerials", + "Files": [] as [Any], + "Configuration": configData + ] + + var contentBlock: [String: Any] = [ + "Choices": [aerialChoice], + "Shuffle": "$null" + ] + + if let existingContent = existingBlock?["Content"] as? [String: Any], + let encodedOptionValues = existingContent["EncodedOptionValues"] { + contentBlock["EncodedOptionValues"] = encodedOptionValues + } + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func patchWallpaperEntries(_ plist: inout [String: Any], desktopBlock: [String: Any], idleBlock: [String: Any]) { + var allSpaces = (plist["AllSpacesAndDisplays"] as? [String: Any]) ?? [:] + allSpaces["Desktop"] = desktopBlock + allSpaces["Idle"] = idleBlock + allSpaces["Type"] = allSpaces["Type"] ?? "individual" + plist["AllSpacesAndDisplays"] = allSpaces + + var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:] + systemDefault["Desktop"] = desktopBlock + systemDefault["Idle"] = idleBlock + systemDefault["Type"] = systemDefault["Type"] ?? "individual" + plist["SystemDefault"] = systemDefault + + if var displays = plist["Displays"] as? [String: Any] { + for key in displays.keys { + if var display = displays[key] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + displays[key] = display + } + } + plist["Displays"] = displays + } + + if var spaces = plist["Spaces"] as? [String: Any] { + for spaceKey in spaces.keys { + if var space = spaces[spaceKey] as? [String: Any] { + if var defaultEntry = space["Default"] as? [String: Any] { + defaultEntry["Desktop"] = desktopBlock + defaultEntry["Idle"] = idleBlock + space["Default"] = defaultEntry + } + if var spaceDisplays = space["Displays"] as? [String: Any] { + for displayKey in spaceDisplays.keys { + if var display = spaceDisplays[displayKey] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + spaceDisplays[displayKey] = display + } + } + space["Displays"] = spaceDisplays + } + spaces[spaceKey] = space + } + } + plist["Spaces"] = spaces + } + } + + private func restartWallpaperAgent() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = ["WallpaperAgent"] + try? task.run() + } + + private func restoreWallpaper() { + let fm = FileManager.default + guard hasValidBackup else { return } + + do { + try fm.removeItem(at: wallpaperPlistURL) + try fm.copyItem(at: backupPlistURL, to: wallpaperPlistURL) + try fm.removeItem(at: backupPlistURL) + } catch { + print("[FullScreenArtworkWindowManager] Failed to restore plist: \(error)") + } + + restoreLiveWallpaperResources() + restartWallpaperAgent() + } + + // MARK: - Track Change Observer + + private func observeTrackChanges() { + trackChangeCancellable?.cancel() + trackChangeCancellable = MusicManager.shared.$songTitle + .combineLatest(MusicManager.shared.$artistName) + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] title, artist in + guard let self, self.isShowing else { return } + if title != self.activeSongTitle || artist != self.activeArtist { + self.hide() + } + } + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 6e98688..6807f02 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -187,6 +187,7 @@ class LockScreenManager: ObservableObject { // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() + FullScreenArtworkWindowManager.shared.hide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index d3f723d..9588d1d 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -478,7 +478,9 @@ class MusicManager: ObservableObject { private var explicitLookupTask: Task? private var explicitLookupKey: String? - private var artworkData: Data? = nil + private(set) var artworkData: Data? = nil + + @Published var videoArtworkURL: URL? = nil private var liveStreamUnknownDurationCount: Int = 0 private var liveStreamEdgeObservationCount: Int = 0 @@ -709,6 +711,11 @@ class MusicManager: ObservableObject { || bundleChanged || contentIdentifierChanged || contentURLChanged + let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL + + if liveArtworkChanged { + self.videoArtworkURL = state.liveArtworkURL + } // Handle artwork and visual transitions for changed content let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] @@ -735,8 +742,13 @@ class MusicManager: ObservableObject { self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentURL = state.contentURL - // Fetch lyrics for new track whenever content changes self.fetchLyrics() + if let liveArtworkURL = state.liveArtworkURL { + self.videoArtworkURL = liveArtworkURL + } else { + self.fetchVideoArtwork() + } + self.refreshExplicitFlag(for: state) // Only update sneak peek if there's actual content and something changed @@ -1424,6 +1436,76 @@ class MusicManager: ObservableObject { lyricSyncTask = nil } + // MARK: - Video Artwork + + func fetchVideoArtwork() { + guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else { + videoArtworkURL = nil + return + } + guard bundleIdentifier == "com.apple.Music" else { + return + } + + let title = songTitle + let artist = artistName + + Task { + let url = await fetchAnimatedArtworkURL(title: title, artist: artist) + await MainActor.run { + self.videoArtworkURL = url + } + } + } + + private func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? { + guard !title.isEmpty, !artist.isEmpty else { return nil } + + let query = "\(title) \(artist)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=musicVideo&limit=5") + else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: searchURL) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let first = results.first, + let previewURLString = first["previewUrl"] as? String, + let previewURL = URL(string: previewURLString) + else { return nil } + + return previewURL + } catch { + return nil + } + } + + func fetchHighResArtworkURL() async -> URL? { + let title = songTitle + let artist = artistName + guard !title.isEmpty, !artist.isEmpty else { return nil } + + let query = "\(title) \(artist)" + guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let searchURL = URL(string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=5") + else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: searchURL) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let first = results.first, + let artworkURLString = first["artworkUrl100"] as? String + else { return nil } + + let highRes = artworkURLString.replacingOccurrences(of: "100x100", with: "3000x3000") + return URL(string: highRes) + } catch { + return nil + } + } + func toggleLyrics() { // Toggle the UI state first so the views can react immediately. showLyrics.toggle() diff --git a/DynamicIsland/managers/SpotifyAuthManager.swift b/DynamicIsland/managers/SpotifyAuthManager.swift new file mode 100644 index 0000000..f733452 --- /dev/null +++ b/DynamicIsland/managers/SpotifyAuthManager.swift @@ -0,0 +1,343 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import CryptoKit +import Defaults +import Foundation + +extension Notification.Name { + static let spotifyCanvasSessionDidChange = Notification.Name("SpotifyCanvasSessionDidChange") +} + +@MainActor +final class SpotifyAuthManager: ObservableObject { + static let shared = SpotifyAuthManager() + + @Published private(set) var isAuthenticated = false + @Published private(set) var isAuthorizing = false + @Published private(set) var authErrorMessage: String? + @Published private(set) var sessionStatusText = "Spotify cookie not configured." + + private let secretsURL = URL(string: "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/main/secrets/secretDict.json")! + private let tokenURL = URL(string: "https://open.spotify.com/api/token")! + private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + + private init() { + restorePersistedState() + } + + var configuredCookie: String { + Self.sanitizeCookie(Defaults[.spotifySPDCCookie]) + } + + var hasConfiguredCookie: Bool { + !configuredCookie.isEmpty + } + + func validateSession() async { + let sanitizedCookie = configuredCookie + Defaults[.spotifySPDCCookie] = sanitizedCookie + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = nil + restorePersistedState() + + guard !sanitizedCookie.isEmpty else { + authErrorMessage = "Paste the Spotify sp_dc cookie first." + return + } + + isAuthorizing = true + defer { + isAuthorizing = false + restorePersistedState() + } + + guard await validAccessToken(forceRefresh: true) != nil else { + if authErrorMessage == nil { + authErrorMessage = "Spotify rejected the sp_dc cookie." + } + return + } + + authErrorMessage = nil + NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil) + } + + func clearSession() { + Defaults[.spotifySPDCCookie] = "" + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = nil + restorePersistedState() + NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil) + } + + func invalidateAccessToken() { + clearCachedToken() + restorePersistedState() + } + + func validAccessToken(forceRefresh: Bool = false) async -> String? { + let sanitizedCookie = configuredCookie + if sanitizedCookie != Defaults[.spotifySPDCCookie] { + Defaults[.spotifySPDCCookie] = sanitizedCookie + } + + guard !sanitizedCookie.isEmpty else { + restorePersistedState() + return nil + } + + let cachedToken = Defaults[.spotifyAuthAccessToken] + let cachedExpiration = Defaults[.spotifyAuthAccessTokenExpiration] + if !forceRefresh, !cachedToken.isEmpty, cachedExpiration > Date().timeIntervalSince1970 + 60 { + restorePersistedState() + return cachedToken + } + + do { + let response = try await fetchWebPlayerToken(spDC: sanitizedCookie) + Defaults[.spotifyAuthAccessToken] = response.accessToken + Defaults[.spotifyAuthAccessTokenExpiration] = response.expirationDate.timeIntervalSince1970 + Defaults[.spotifyAuthLastValidatedAt] = Date().timeIntervalSince1970 + authErrorMessage = nil + restorePersistedState() + return response.accessToken + } catch { + clearCachedToken() + Defaults[.spotifyAuthLastValidatedAt] = 0 + authErrorMessage = error.localizedDescription + restorePersistedState() + return nil + } + } + + private func fetchWebPlayerToken(spDC: String) async throws -> SpotifyWebPlayerTokenResponse { + let secrets = try await fetchSecrets() + guard let latestSecret = latestSecretEntry(from: secrets) else { + throw SpotifyAuthError.missingSecrets + } + + let totp = try Self.generateTOTPCode(from: latestSecret.cipher) + + var components = URLComponents(url: tokenURL, resolvingAgainstBaseURL: false) + components?.queryItems = [ + URLQueryItem(name: "reason", value: "transport"), + URLQueryItem(name: "productType", value: "web-player"), + URLQueryItem(name: "totp", value: totp), + URLQueryItem(name: "totpServer", value: totp), + URLQueryItem(name: "totpVer", value: latestSecret.version) + ] + + guard let url = components?.url else { + throw SpotifyAuthError.invalidRequest + } + + var request = URLRequest(url: url) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("https://open.spotify.com/", forHTTPHeaderField: "Referer") + request.setValue("WebPlayer", forHTTPHeaderField: "App-Platform") + request.setValue("sp_dc=\(spDC)", forHTTPHeaderField: "Cookie") + + let (data, response) = try await URLSession.shared.data(for: request) + try Self.validateHTTPResponse(response, data: data) + + let tokenResponse = try JSONDecoder().decode(SpotifyWebPlayerTokenResponse.self, from: data) + if tokenResponse.accessToken.isEmpty { + throw SpotifyAuthError.invalidTokenResponse + } + + return tokenResponse + } + + private func fetchSecrets() async throws -> [String: [Int]] { + var request = URLRequest(url: secretsURL) + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let (data, response) = try await URLSession.shared.data(for: request) + try Self.validateHTTPResponse(response, data: data) + return try JSONDecoder().decode([String: [Int]].self, from: data) + } + + private func latestSecretEntry(from secrets: [String: [Int]]) -> (version: String, cipher: [Int])? { + var latestVersion: String? + var latestCipher: [Int]? + var highestNumericVersion = Int.min + + for (version, cipher) in secrets { + guard let numericVersion = Int(version) else { continue } + if numericVersion > highestNumericVersion { + highestNumericVersion = numericVersion + latestVersion = version + latestCipher = cipher + } + } + + guard let latestVersion, let latestCipher else { + return nil + } + + return (latestVersion, latestCipher) + } + + private func clearCachedToken() { + Defaults[.spotifyAuthAccessToken] = "" + Defaults[.spotifyAuthAccessTokenExpiration] = 0 + } + + private func restorePersistedState() { + let cookie = configuredCookie + let hasToken = !Defaults[.spotifyAuthAccessToken].isEmpty + && Defaults[.spotifyAuthAccessTokenExpiration] > Date().timeIntervalSince1970 + 60 + let hasValidatedCookie = Defaults[.spotifyAuthLastValidatedAt] > 0 + + isAuthenticated = !cookie.isEmpty && (hasToken || hasValidatedCookie) + + if cookie.isEmpty { + sessionStatusText = "Spotify cookie not configured." + } else if hasToken { + sessionStatusText = "Spotify Canvas session ready." + } else if hasValidatedCookie { + sessionStatusText = "Cookie saved. The access token will refresh automatically." + } else { + sessionStatusText = "Cookie pasted. Validate it once to enable Canvas lookup." + } + } + + static func sanitizeCookie(_ rawValue: String) -> String { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + + if let range = value.range(of: "sp_dc=") { + value = String(value[range.upperBound...]) + } + + if let firstSemicolon = value.firstIndex(of: ";") { + value = String(value[.. String { + let transformed = cipher.enumerated().map { index, value in + value ^ ((index % 33) + 9) + } + + let secretString = transformed.map(String.init).joined() + guard let secretData = secretString.data(using: .utf8) else { + throw SpotifyAuthError.invalidSecretMaterial + } + + let counter = UInt64(Date().timeIntervalSince1970 / 30) + var counterBytes = counter.bigEndian + let counterData = Data(bytes: &counterBytes, count: MemoryLayout.size) + + let mac = HMAC.authenticationCode( + for: counterData, + using: SymmetricKey(data: secretData) + ) + let hashData = Data(mac) + let offset = Int(hashData.last.map { $0 & 0x0F } ?? 0) + let truncatedRange = offset..<(offset + 4) + + guard truncatedRange.upperBound <= hashData.count else { + throw SpotifyAuthError.invalidSecretMaterial + } + + let truncatedBytes = Array(hashData[truncatedRange]) + let truncated = + ((UInt32(truncatedBytes[0]) << 24) + | (UInt32(truncatedBytes[1]) << 16) + | (UInt32(truncatedBytes[2]) << 8) + | UInt32(truncatedBytes[3])) + & 0x7FFF_FFFF + + return String(format: "%06u", truncated % 1_000_000) + } + + private static func validateHTTPResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw SpotifyAuthError.invalidServerResponse + } + + guard (200..<300).contains(httpResponse.statusCode) else { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = object["error"] as? String { + throw SpotifyAuthError.serverError(message) + } + + if let body = String(data: data, encoding: .utf8), !body.isEmpty { + throw SpotifyAuthError.serverError(body) + } + + throw SpotifyAuthError.httpFailure(httpResponse.statusCode) + } + } +} + +private struct SpotifyWebPlayerTokenResponse: Decodable { + let accessToken: String + let accessTokenExpirationTimestampMs: Double? + + var expirationDate: Date { + if let accessTokenExpirationTimestampMs { + return Date(timeIntervalSince1970: accessTokenExpirationTimestampMs / 1000) + } + return Date().addingTimeInterval(3300) + } + + private enum CodingKeys: String, CodingKey { + case accessToken + case accessTokenExpirationTimestampMs + } +} + +private enum SpotifyAuthError: LocalizedError { + case invalidRequest + case invalidServerResponse + case invalidTokenResponse + case invalidSecretMaterial + case missingSecrets + case httpFailure(Int) + case serverError(String) + + var errorDescription: String? { + switch self { + case .invalidRequest: + return "Unable to build the Spotify token request." + case .invalidServerResponse: + return "Spotify returned an invalid response." + case .invalidTokenResponse: + return "Spotify did not return a usable access token." + case .invalidSecretMaterial: + return "The Spotify TOTP secret could not be decoded." + case .missingSecrets: + return "No valid Spotify TOTP secret was found." + case let .httpFailure(statusCode): + return "Spotify token request failed with HTTP \(statusCode)." + case let .serverError(message): + return message + } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 198cd85..fd04466 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -910,6 +910,8 @@ extension Defaults.Keys { ) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) + static let lockScreenMusicFullscreenArtworkEnabled = Key("lockScreenMusicFullscreenArtworkEnabled", default: true) + static let lockScreenMusicFullscreenVideoArtwork = Key("lockScreenMusicFullscreenVideoArtwork", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") @@ -988,6 +990,10 @@ extension Defaults.Keys { // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) + static let spotifySPDCCookie = Key("spotifySPDCCookie", default: "") + static let spotifyAuthAccessToken = Key("spotifyAuthAccessToken", default: "") + static let spotifyAuthAccessTokenExpiration = Key("spotifyAuthAccessTokenExpiration", default: 0) + static let spotifyAuthLastValidatedAt = Key("spotifyAuthLastValidatedAt", default: 0) // MARK: Bluetooth Audio Devices static let showBluetoothDeviceConnections = Key("showBluetoothDeviceConnections", default: true) diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift index 516e6c2..312a708 100644 --- a/DynamicIsland/models/PlaybackState.swift +++ b/DynamicIsland/models/PlaybackState.swift @@ -44,6 +44,7 @@ struct PlaybackState { var repeatMode: RepeatMode = .off var lastUpdated: Date = Date.distantPast var artwork: Data? + var liveArtworkURL: URL? } extension PlaybackState: Equatable { @@ -61,5 +62,6 @@ extension PlaybackState: Equatable { && lhs.isShuffled == rhs.isShuffled && lhs.repeatMode == rhs.repeatMode && lhs.artwork == rhs.artwork + && lhs.liveArtworkURL == rhs.liveArtworkURL } }