mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +00:00
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:
parent
78bf018fe2
commit
5bd84270aa
12 changed files with 1946 additions and 83 deletions
824
DynamicIsland/managers/FullScreenArtworkWindowManager.swift
Normal file
824
DynamicIsland/managers/FullScreenArtworkWindowManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
343
DynamicIsland/managers/SpotifyAuthManager.swift
Normal file
343
DynamicIsland/managers/SpotifyAuthManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue