Fix: Migrations

This commit is contained in:
Hariharan Mudaliar 2025-08-03 21:26:50 +05:30
commit be152c1531
41 changed files with 2373 additions and 1321 deletions

View file

@ -5,193 +5,222 @@
// Created by Alexander on 2025-03-29.
//
import Combine
import AppKit
import Combine
import Foundation
class NowPlayingController: ObservableObject, MediaControllerProtocol {
final class NowPlayingController: ObservableObject, MediaControllerProtocol {
// Stub for now to conform with ControllerProtocol
func updatePlaybackInfo() {}
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
@Published private(set) var playbackState: PlaybackState = .init(
bundleIdentifier: "com.apple.Music"
)
private var isResourcesAvailable = false
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
private var cancellables = Set<AnyCancellable>()
private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
var isWorking: Bool {
return isResourcesAvailable
}
private var lastMusicItem:
(title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
// MARK: - Media Remote Functions
private let mediaRemoteBundle: CFBundle
private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void
private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void
private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String?
private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String?
private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void
private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void
private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void
private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void
private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void
private var process: Process?
private var pipe: Pipe?
private var buffer = ""
// MARK: - Initialization
init?() {
guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString),
let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString),
let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString),
let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString),
let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString),
let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString),
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString),
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString)
guard
let bundle = CFBundleCreate(
kCFAllocatorDefault,
NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSendCommand" as CFString),
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetElapsedTime" as CFString),
let MRMediaRemoteSetShuffleModePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetShuffleMode" as CFString),
let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetRepeatMode" as CFString)
else { return nil }
mediaRemoteBundle = bundle
MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self)
MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self)
MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self)
MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self)
MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
MRMediaRemoteSendCommandFunction = unsafeBitCast(
MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(
MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
MRMediaRemoteSetShuffleModeFunction = unsafeBitCast(
MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self)
MRMediaRemoteSetRepeatModeFunction = unsafeBitCast(
MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self)
setupNowPlayingObserver()
updatePlaybackInfo()
}
deinit {
// Clean up all Combine subscribers
cancellables.removeAll()
// Remove distributed notification observers
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
if let process = self.process {
if process.isRunning {
process.terminate()
process.waitUntilExit()
}
}
if let pipe = self.pipe {
pipe.fileHandleForReading.closeFile()
pipe.fileHandleForWriting.closeFile()
}
self.process = nil
self.pipe = nil
}
// MARK: - Protocol Implementation
func play() {
func play() async {
MRMediaRemoteSendCommandFunction(0, nil)
}
func pause() {
func pause() async {
MRMediaRemoteSendCommandFunction(1, nil)
}
func togglePlay() {
func togglePlay() async {
MRMediaRemoteSendCommandFunction(2, nil)
}
func nextTrack() {
func nextTrack() async {
MRMediaRemoteSendCommandFunction(4, nil)
}
func previousTrack() {
func previousTrack() async {
MRMediaRemoteSendCommandFunction(5, nil)
}
func seek(to time: Double) {
func seek(to time: Double) async {
MRMediaRemoteSetElapsedTimeFunction(time)
}
func isActive() -> Bool {
return true
}
// MARK: - Setup Methods
private func setupNowPlayingObserver() {
MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main)
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"))
.sink { [weak self] _ in self?.updatePlaybackInfo() }
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification"))
.sink { [weak self] _ in self?.updateApp() }
.store(in: &cancellables)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
func toggleShuffle() async {
// MRMediaRemoteSendCommandFunction(6, nil)
MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 3 : 1)
playbackState.isShuffled.toggle()
}
// MARK: - Update Methods
@objc func updateApp() {
// Get the now playing client
MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in
guard let clientObj = clientObj else {
DispatchQueue.main.async {
self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback
}
return
}
// Try to get parent bundle ID first, then fall back to direct bundle ID
var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj)
if appBundleID == nil {
appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj)
}
// Special case for WebKit.GPU which is often Safari
if appBundleID == "com.apple.WebKit.GPU" {
appBundleID = "com.apple.Safari"
}
DispatchQueue.main.async {
self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music"
}
}
func toggleRepeat() async {
// MRMediaRemoteSendCommandFunction(7, nil)
let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1)
playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off
MRMediaRemoteSetRepeatModeFunction(newRepeatMode)
}
// MARK: - Setup Methods
private func setupNowPlayingObserver() {
let process = Process()
guard
let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
else {
print("⚠️ Could not find mediaremote-adapter.pl script or framework path - NowPlaying controller disabled")
isResourcesAvailable = false
return
}
isResourcesAvailable = true
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
process.arguments = [scriptURL.path, frameworkPath, "stream"]
let pipe = Pipe()
process.standardOutput = pipe
self.process = process
self.pipe = pipe
@objc func updatePlaybackInfo() {
updateApp()
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in
guard let self = self else { return }
let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? ""
let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? ""
let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? ""
let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0
let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0
let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data
let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date()
let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1
// Update playback state
DispatchQueue.main.async {
self.playbackState.title = title
self.playbackState.artist = artist
self.playbackState.album = album
self.playbackState.duration = duration
self.playbackState.currentTime = currentTime
self.playbackState.artwork = artworkData
self.playbackState.lastUpdated = timestamp
self.playbackState.playbackRate = playbackRate
// Check playback state
self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in
DispatchQueue.main.async {
self?.playbackState.isPlaying = isPlaying
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let chunk = String(data: data, encoding: .utf8) {
self?.buffer.append(chunk)
while let range = self?.buffer.range(of: "\n") {
let line = String(self?.buffer[..<range.lowerBound] ?? "")
self?.buffer = String(self?.buffer[range.upperBound...] ?? "")
if !line.isEmpty {
self?.handleAdapterUpdate(line)
}
}
}
}
do {
try process.run()
} catch {
print("⚠️ Failed to launch mediaremote-adapter.pl: \(error)")
}
}
// MARK: - Update Methods
private func handleAdapterUpdate(_ jsonLine: String) {
guard let data = jsonLine.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let payload = object["payload"] as? [String: Any]
else { return }
let diff = object["diff"] as? Bool ?? false
var newPlaybackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier)
newPlaybackState.title = payload["title"] as? String ?? (diff ? self.playbackState.title : "")
newPlaybackState.artist = payload["artist"] as? String ?? (diff ? self.playbackState.artist : "")
newPlaybackState.album = payload["album"] as? String ?? (diff ? self.playbackState.album : "")
newPlaybackState.duration = payload["duration"] as? Double ?? (diff ? self.playbackState.duration : 0)
newPlaybackState.currentTime = payload["elapsedTime"] as? Double ?? (diff ? self.playbackState.currentTime : 0)
if let shuffleMode = payload["shuffleMode"] as? Int {
newPlaybackState.isShuffled = shuffleMode != 1
} else if !diff {
newPlaybackState.isShuffled = false
}
if let repeatModeValue = payload["repeatMode"] as? Int {
newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off
} else if !diff {
newPlaybackState.repeatMode = .off
}
if let artworkDataString = payload["artworkData"] as? String {
newPlaybackState.artwork = Data(
base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines)
)
} else if !diff {
newPlaybackState.artwork = nil
}
if let dateString = payload["timestamp"] as? String,
let date = ISO8601DateFormatter().date(from: dateString) {
newPlaybackState.lastUpdated = date
}
newPlaybackState.playbackRate = payload["playbackRate"] as? Double ?? (diff ? self.playbackState.playbackRate : 1.0)
newPlaybackState.isPlaying = payload["playing"] as? Bool ?? (diff ? self.playbackState.isPlaying : false)
newPlaybackState.bundleIdentifier = (
payload["parentApplicationBundleIdentifier"] as? String ??
payload["bundleIdentifier"] as? String ??
(diff ? self.playbackState.bundleIdentifier : "")
)
self.playbackState = newPlaybackState
}
}