Atoll: fullscreen album artwork as lockscreen wallpaper

Right-click on the album art in the lock screen music panel to set it
as the desktop wallpaper. Right-click again or click anywhere on the
background to restore the original wallpaper. Wallpaper also restores
automatically on track change or screen unlock.

Uses direct plist manipulation of the wallpaper config to avoid leaving
traces in the wallpaper picker. Album art PNG is pre-cached in the
background on track change for instant application.

Adds video artwork URL fetching from iTunes catalog for Apple Music
tracks (musicVideo entity search).

Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
This commit is contained in:
Jis G Jacob 2026-04-16 16:02:10 +05:30 • committed by delli
commit 5bd84270aa
12 changed files with 1946 additions and 83 deletions

View file

@ -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;

View file

@ -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" : {

View file

@ -20,8 +20,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import Combine
import Foundation
import SwiftUI
class SpotifyController: MediaControllerProtocol {
@ -37,19 +37,26 @@ class SpotifyController: MediaControllerProtocol {
}
var isWorking: Bool {
return true // Spotify controller always works
true
}
private var notificationTask: Task<Void, Never>?
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<Void, Never>?
private var canvasFetchTask: Task<Void, Never>?
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()
@ -69,9 +76,27 @@ class SpotifyController: MediaControllerProtocol {
}
}
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
@ -109,12 +134,13 @@ class SpotifyController: MediaControllerProtocol {
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,
@ -129,15 +155,31 @@ 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
}
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 }
artworkFetchTask?.cancel()
@ -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
@ -165,7 +208,41 @@ 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)"
@ -192,15 +269,9 @@ 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
@ -209,4 +280,283 @@ class SpotifyController: MediaControllerProtocol {
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..<endIndex]
index = endIndex
return Data(slice)
}
mutating func readString() throws -> 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
}
}

View file

@ -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) {
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)
}
.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))
}
.buttonStyle(PlainButtonStyle())
.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")
}

View file

@ -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")

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Void, Never>?
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()
}
}
}
}

View file

@ -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)

View file

@ -478,7 +478,9 @@ class MusicManager: ObservableObject {
private var explicitLookupTask: Task<Void, Never>?
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()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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[..<firstSemicolon])
}
value = value
.replacingOccurrences(of: "\"", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return value
}
private static func generateTOTPCode(from cipher: [Int]) throws -> 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<UInt64>.size)
let mac = HMAC<Insecure.SHA1>.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
}
}
}

View file

@ -910,6 +910,8 @@ extension Defaults.Keys {
)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenMusicFullscreenArtworkEnabled = Key<Bool>("lockScreenMusicFullscreenArtworkEnabled", default: true)
static let lockScreenMusicFullscreenVideoArtwork = Key<Bool>("lockScreenMusicFullscreenVideoArtwork", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
@ -988,6 +990,10 @@ extension Defaults.Keys {
// MARK: Media Controller
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
static let spotifySPDCCookie = Key<String>("spotifySPDCCookie", default: "")
static let spotifyAuthAccessToken = Key<String>("spotifyAuthAccessToken", default: "")
static let spotifyAuthAccessTokenExpiration = Key<Double>("spotifyAuthAccessTokenExpiration", default: 0)
static let spotifyAuthLastValidatedAt = Key<Double>("spotifyAuthLastValidatedAt", default: 0)
// MARK: Bluetooth Audio Devices
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)

View file

@ -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
}
}