From dc45c50df40b35e252437619542ad74ac9cd9e21 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 5 Oct 2025 23:34:57 +0530 Subject: [PATCH] Still not broken: Next commit decides the fate --- .gitignore | 7 +- .../AppleMusicController.swift | 22 +- .../MediaControllerProtocol.swift | 1 + .../MediaControllers/SpotifyController.swift | 92 ++-- .../YouTubeMusicAuthentication.swift | 87 ++++ .../YouTubeMusicController.swift | 424 ++++++++++++++++ .../YouTubeMusicModels.swift | 157 ++++++ .../YouTubeMusicNetworking.swift | 225 +++++++++ .../YouTubeMusicController.swift | 452 ------------------ .../Providers/CalendarServiceProviding.swift | 4 +- .../LiveActivityModifier.swift | 1 - DynamicIsland/helpers/MediaChecker.swift | 26 +- DynamicIsland/managers/ImageService.swift | 49 ++ DynamicIsland/models/CalendarModel.swift | 4 +- DynamicIsland/models/Constants.swift | 3 + DynamicIsland/models/EventModel.swift | 2 +- reset_app_defaults.sh | 80 ---- 17 files changed, 1028 insertions(+), 608 deletions(-) create mode 100644 DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift create mode 100644 DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift create mode 100644 DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift create mode 100644 DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift delete mode 100644 DynamicIsland/MediaControllers/YouTubeMusicController.swift create mode 100644 DynamicIsland/managers/ImageService.swift delete mode 100755 reset_app_defaults.sh diff --git a/.gitignore b/.gitignore index 5f48451..1c41fef 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,9 @@ stats boring.notch referenceimages -PrivateWorks \ No newline at end of file +PrivateWorks + +*.py +*.sh +comparison_report.json +*.txt \ No newline at end of file diff --git a/DynamicIsland/MediaControllers/AppleMusicController.swift b/DynamicIsland/MediaControllers/AppleMusicController.swift index 5105d41..433f01d 100644 --- a/DynamicIsland/MediaControllers/AppleMusicController.swift +++ b/DynamicIsland/MediaControllers/AppleMusicController.swift @@ -12,7 +12,8 @@ import SwiftUI class AppleMusicController: MediaControllerProtocol { // MARK: - Properties @Published private var playbackState: PlaybackState = PlaybackState( - bundleIdentifier: "com.apple.Music" + bundleIdentifier: "com.apple.Music", + playbackRate: 1 ) var playbackStatePublisher: AnyPublisher { @@ -79,7 +80,8 @@ class AppleMusicController: MediaControllerProtocol { func toggleShuffle() async { await executeCommand("set shuffle enabled to not shuffle enabled") - playbackState.isShuffled.toggle() + try? await Task.sleep(for: .milliseconds(150)) + await updatePlaybackInfo() } func toggleRepeat() async { @@ -92,14 +94,8 @@ class AppleMusicController: MediaControllerProtocol { set song repeat to off end if """) - - if playbackState.repeatMode == .off { - playbackState.repeatMode = .all - } else if playbackState.repeatMode == .all { - playbackState.repeatMode = .one - } else { - playbackState.repeatMode = .off - } + try? await Task.sleep(for: .milliseconds(150)) + await updatePlaybackInfo() } func isActive() -> Bool { @@ -122,7 +118,7 @@ class AppleMusicController: MediaControllerProtocol { let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0 updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off updatedState.artwork = descriptor.atIndex(9)?.data as Data? - + updatedState.lastUpdated = Date() self.playbackState = updatedState } @@ -147,11 +143,11 @@ class AppleMusicController: MediaControllerProtocol { set shuffleState to shuffle enabled set repeatState to song repeat if repeatState is off then - set repeatValue to 0 - else if repeatState is all then set repeatValue to 1 else if repeatState is one then set repeatValue to 2 + else if repeatState is all then + set repeatValue to 3 end if try diff --git a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift index 20f9a5e..def7970 100644 --- a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift +++ b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift @@ -6,6 +6,7 @@ // import Foundation +import AppKit import Combine protocol MediaControllerProtocol: ObservableObject { diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 98a2002..ff7ac7f 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -25,8 +25,11 @@ class SpotifyController: MediaControllerProtocol { private var notificationTask: Task? - //Constant for time between command and update - let commandUpdateDelay: Duration = .milliseconds(25) + // Constant for time between command and update + private let commandUpdateDelay: Duration = .milliseconds(25) + + private var lastArtworkURL: String? + private var artworkFetchTask: Task? init() { setupPlaybackStateChangeObserver() @@ -51,50 +54,33 @@ class SpotifyController: MediaControllerProtocol { deinit { notificationTask?.cancel() + artworkFetchTask?.cancel() } // MARK: - Protocol Implementation - func play() async { - await executeCommand("play") - } - - func pause() async { - await executeCommand("pause") - } - - func togglePlay() async { - await executeCommand("playpause") - } - - func nextTrack() async { - await executeCommand("next track") - } + func play() async { await executeCommand("play") } + func pause() async { await executeCommand("pause") } + func togglePlay() async { await executeCommand("playpause") } + func nextTrack() async { await executeCommand("next track") } func previousTrack() async { - await executeCommand("previous track") + await executeAndRefresh("previous track") } func seek(to time: Double) async { - await executeCommand("set player position to \(time)") - try? await Task.sleep(for: commandUpdateDelay) - await updatePlaybackInfo() + await executeAndRefresh("set player position to \(time)") } func toggleShuffle() async { - await executeCommand("set shuffling to not shuffling") - try? await Task.sleep(for: commandUpdateDelay) - await updatePlaybackInfo() + await executeAndRefresh("set shuffling to not shuffling") } func toggleRepeat() async { - await executeCommand("set repeating to not repeating") - try? await Task.sleep(for: commandUpdateDelay) - await updatePlaybackInfo() + await executeAndRefresh("set repeating to not repeating") } func isActive() -> Bool { - let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } } func updatePlaybackInfo() async { @@ -111,7 +97,7 @@ class SpotifyController: MediaControllerProtocol { let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" - let state = PlaybackState( + var state = PlaybackState( bundleIdentifier: "com.spotify.client", isPlaying: isPlaying, title: currentTrack, @@ -124,20 +110,36 @@ class SpotifyController: MediaControllerProtocol { repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) - - self.playbackState = state - - // Load artwork asynchronously and update the state when complete + + if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { + state.artwork = existingArtwork + } + + playbackState = state + if !artworkURL.isEmpty, let url = URL(string: artworkURL) { + guard artworkURL != lastArtworkURL || state.artwork == nil else { return } + artworkFetchTask?.cancel() + let currentState = state - do { - let (data, _) = try await URLSession.shared.data(from: url) - // Create a new state with the artwork data and update - var updatedState = currentState - updatedState.artwork = data - playbackState = updatedState - } catch { - print("Failed to load artwork: \(error)") + + artworkFetchTask = Task { + do { + let data = try await ImageService.shared.fetchImageData(from: url) + + await MainActor.run { [weak self] in + guard let self = self else { return } + var updatedState = currentState + updatedState.artwork = data + self.playbackState = updatedState + self.lastArtworkURL = artworkURL + self.artworkFetchTask = nil + } + } catch { + await MainActor.run { [weak self] in + self?.artworkFetchTask = nil + } + } } } } @@ -148,6 +150,12 @@ class SpotifyController: MediaControllerProtocol { let script = "tell application \"Spotify\" to \(command)" try? await AppleScriptHelper.executeVoid(script) } + + private func executeAndRefresh(_ command: String) async { + await executeCommand(command) + try? await Task.sleep(for: commandUpdateDelay) + await updatePlaybackInfo() + } private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift new file mode 100644 index 0000000..58494d0 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicAuthentication.swift @@ -0,0 +1,87 @@ +// +// YouTubeMusicAuthentication.swift +// DynamicIsland +// +// Created by Alexander on 2025-09-14. +// + +import Foundation + +// MARK: - Authentication Manager +actor YouTubeMusicAuthManager { + private var accessToken: String? + private var authenticationTask: Task? + private let httpClient: YouTubeMusicHTTPClient + + init(httpClient: YouTubeMusicHTTPClient) { + self.httpClient = httpClient + } + + var currentToken: String? { + accessToken + } + + func authenticate() async throws -> String { + // Return existing token if valid + if let token = accessToken { + return token + } + + // Wait for ongoing authentication if in progress + if let task = authenticationTask { + return try await task.value + } + + // Start new authentication + let task = Task { + do { + let token = try await httpClient.authenticate() + await setToken(token) + return token + } catch { + await clearAuthenticationTask() + throw error + } + } + + authenticationTask = task + return try await task.value + } + + func invalidateToken() async { + accessToken = nil + authenticationTask?.cancel() + authenticationTask = nil + } + + private func setToken(_ token: String) async { + accessToken = token + authenticationTask = nil + } + + private func clearAuthenticationTask() async { + authenticationTask = nil + } +} + +// MARK: - Authentication State +enum AuthenticationState: Sendable { + case unauthenticated + case authenticating + case authenticated(String) + case failed(Error) + + var isAuthenticated: Bool { + if case .authenticated = self { + return true + } + return false + } + + var token: String? { + if case .authenticated(let token) = self { + return token + } + return nil + } +} diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift new file mode 100644 index 0000000..31b0e6a --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift @@ -0,0 +1,424 @@ +// +// YouTubeMusicController.swift +// DynamicIsland +// +// Created By Alexander on 2025-03-30. +// Modified by Pranav on 2025-06-16. +// + +import Foundation +import Combine +import SwiftUI + +final class YouTubeMusicController: MediaControllerProtocol { + // MARK: - Published Properties + @Published var playbackState = PlaybackState( + bundleIdentifier: YouTubeMusicConfiguration.default.bundleIdentifier + ) + + private var artworkFetchTask: Task? + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + isActive() && (updateTimer != nil || webSocketClient != nil) + } + + // MARK: - Private Properties + private let configuration: YouTubeMusicConfiguration + private let httpClient: YouTubeMusicHTTPClient + private let authManager: YouTubeMusicAuthManager + private var webSocketClient: YouTubeMusicWebSocketClient? + + private var updateTimer: Timer? + private var appStateObserver: Task? + private var reconnectDelay: TimeInterval = 1.0 + + // MARK: - Initialization + init(configuration: YouTubeMusicConfiguration = .default) { + self.configuration = configuration + self.httpClient = YouTubeMusicHTTPClient(baseURL: configuration.baseURL) + self.authManager = YouTubeMusicAuthManager(httpClient: httpClient) + + setupAppStateObserver() + + Task { + await initializeIfAppActive() + } + } + + // MARK: - MediaControllerProtocol Implementation + func play() async { await sendCommand(endpoint: "/play", method: "POST") } + + func pause() async { await sendCommand(endpoint: "/pause", method: "POST") } + + func togglePlay() async { + if !isActive() { launchApp() } + await sendCommand(endpoint: "/toggle-play", method: "POST") + } + + func nextTrack() async { await sendCommand(endpoint: "/next", method: "POST") } + + func previousTrack() async { await sendCommand(endpoint: "/previous", method: "POST") } + + func seek(to time: Double) async { + let payload = ["seconds": time] + await sendCommand(endpoint: "/seek-to", method: "POST", body: payload) + } + func fetchShuffleState() async { await sendCommand(endpoint: "/shuffle", method: "GET", refresh: false) } + func fetchRepeatMode() async { await sendCommand(endpoint: "/repeat-mode", method: "GET", refresh: false) } + + func toggleShuffle() async { await sendCommand(endpoint: "/shuffle", method: "POST") } + func toggleRepeat() async { await sendCommand(endpoint: "/switch-repeat", method: "POST") } + + nonisolated func isActive() -> Bool { + NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == configuration.bundleIdentifier + } + } + + func updatePlaybackInfo() async { + guard isActive() else { + resetPlaybackState() + return + } + + do { + let token = try await authManager.authenticate() + let response = try await httpClient.getPlaybackInfo(token: token) + await updatePlaybackState(with: response) + } catch YouTubeMusicError.authenticationRequired { + await authManager.invalidateToken() + } catch { + print("[YouTubeMusicController] Failed to update playback info: \(error)") + } + } + + // MARK: - Private Methods + private func setupAppStateObserver() { + appStateObserver = Task { [weak self] in + await withTaskGroup(of: Void.self) { group in + group.addTask { + let launchNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.didLaunchApplicationNotification + ) + + for await notification in launchNotifications { + await self?.handleAppLaunched(notification) + } + } + + group.addTask { + let terminateNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.didTerminateApplicationNotification + ) + + for await notification in terminateNotifications { + await self?.handleAppTerminated(notification) + } + } + } + } + } + + private func handleAppLaunched(_ notification: Notification) async { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == configuration.bundleIdentifier else { + return + } + + await initializeIfAppActive() + } + + private func handleAppTerminated(_ notification: Notification) async { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == configuration.bundleIdentifier else { + return + } + + Task { @MainActor in + stopPeriodicUpdates() + appStateObserver?.cancel() + } + + Task { + await webSocketClient?.disconnect() + webSocketClient = nil + } + + resetPlaybackState() + } + + private func initializeIfAppActive() async { + guard isActive() else { return } + + do { + let token = try await authManager.authenticate() + await setupWebSocketIfPossible(token: token) + await startPeriodicUpdates() + await updatePlaybackInfo() + } catch { + print("[YouTubeMusicController] Failed to initialize: \(error)") + await scheduleReconnect() + } + } + + private func setupWebSocketIfPossible(token: String) async { + guard let wsURL = WebSocketURLBuilder.buildURL(from: configuration.baseURL) else { + print("[YouTubeMusicController] Failed to build WebSocket URL") + return + } + + let client = YouTubeMusicWebSocketClient( + onMessage: { [weak self] data in + await self?.handleWebSocketMessage(data) + }, + onDisconnect: { [weak self] in + await self?.handleWebSocketDisconnect() + } + ) + + do { + try await client.connect(to: wsURL, with: token) + webSocketClient = client + stopPeriodicUpdates() // WebSocket will provide real-time updates + reconnectDelay = configuration.reconnectDelay.lowerBound + } catch { + print("[YouTubeMusicController] WebSocket connection failed: \(error)") + await scheduleReconnect() + } + } + + private func handleWebSocketMessage(_ data: Data) async { + guard let message = WebSocketMessage(from: data) else { + if let response = try? JSONDecoder().decode(PlaybackResponse.self, from: data) { + await updatePlaybackState(with: response) + } + return + } + switch message.type { + case .playerInfo, .videoChanged, .playerStateChanged: + if let data = message.extractData(), + let response = PlaybackResponse.from(websocketData: data) { + await updatePlaybackState(with: response) + } + + case .positionChanged: + guard let data = message.extractData() else { return } + + var position: Double? = nil + if let pos = data["position"] as? Double { + position = pos + } else if let elapsed = data["elapsedSeconds"] as? Double { + position = elapsed + } + guard let newPosition = position else { return } + + var copied = playbackState + copied.currentTime = newPosition + copied.lastUpdated = Date() + playbackState = copied + + case .repeatChanged: + guard let data = message.extractData() else { return } + var copy = playbackState + + if let repeatStr = data["repeat"] as? String { + switch repeatStr.uppercased() { + case "NONE": copy.repeatMode = .off + case "ALL": copy.repeatMode = .all + case "ONE": copy.repeatMode = .one + default: break + } + } + copy.lastUpdated = Date() + playbackState = copy + + case .shuffleChanged: + guard let data = message.extractData() else { return } + var copy = playbackState + if let shuffle = data["shuffle"] as? Bool { copy.isShuffled = shuffle } + else if let shuffle = data["isShuffled"] as? Bool { copy.isShuffled = shuffle } + copy.lastUpdated = Date() + playbackState = copy + + case .volumeChanged: + break + } + } + + private func handleWebSocketDisconnect() async { + webSocketClient = nil + await startPeriodicUpdates() // Fallback to polling + await scheduleReconnect() + } + + private func scheduleReconnect() async { + try? await Task.sleep(for: .seconds(reconnectDelay)) + reconnectDelay = min(reconnectDelay * 2, configuration.reconnectDelay.upperBound) + + if isActive() { + await initializeIfAppActive() + } + } + + private func startPeriodicUpdates() async { + guard isActive() && webSocketClient == nil else { return } + + stopPeriodicUpdates() + + updateTimer = Timer.scheduledTimer(withTimeInterval: configuration.updateInterval, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.updatePlaybackInfo() + } + } + } + + private func stopPeriodicUpdates() { + updateTimer?.invalidate() + updateTimer = nil + } + + func pollPlaybackState() async { + if !isActive() { + return + } + + await fetchRepeatMode() + await fetchShuffleState() + await updatePlaybackInfo() + } + + private func sendCommand( + endpoint: String, + method: String = "POST", + body: (any Codable & Sendable)? = nil, + refresh: Bool = true + ) async { + do { + let token = try await authManager.authenticate() + + let data = try await httpClient.sendCommand( + endpoint: endpoint, + method: method, + body: body, + token: token + ) + // Lightweight endpoint-specific parsing + if endpoint == "/shuffle" { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let shuffleState = json["state"] as? Bool { + playbackState.isShuffled = shuffleState + } else { + playbackState.isShuffled = !playbackState.isShuffled + } + } else if endpoint == "/repeat-mode" { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let mode = json["mode"] as? String { updateRepeatMode(mode) } + } + } else if endpoint == "/switch-repeat" { + // Find next repeat mode + let nextMode: RepeatMode + switch playbackState.repeatMode { + case .off: nextMode = .all + case .all: nextMode = .one + case .one: nextMode = .off + } + playbackState.repeatMode = nextMode + } else if refresh && webSocketClient == nil { + try? await Task.sleep(for: .milliseconds(100)) + await updatePlaybackInfo() + } + } catch YouTubeMusicError.authenticationRequired { + await authManager.invalidateToken() + } catch { + print("[YouTubeMusicController] Command failed: \(error)") + } + } + + private func updatePlaybackState(with response: PlaybackResponse) async { + var newState = playbackState + + newState.isPlaying = !response.isPaused + + if let title = response.title { + newState.title = title + } + + if let artist = response.artist { + newState.artist = artist + } + + if let album = response.album { + newState.album = album + } + + if let elapsed = response.elapsedSeconds { + newState.currentTime = elapsed + } + + if let duration = response.songDuration { + newState.duration = duration + } + + newState.lastUpdated = Date() + + if let shuffled = response.isShuffled { + newState.isShuffled = shuffled + } + + if let mode = response.repeatMode { + switch mode { + case 0: newState.repeatMode = .off + case 1: newState.repeatMode = .all + case 2: newState.repeatMode = .one + default: break + } + } + + // Always update - removed comparison since PlaybackState doesn't conform to Equatable + playbackState = newState + + artworkFetchTask?.cancel() + artworkFetchTask = nil + + if let artworkURL = response.imageSrc, + let url = URL(string: artworkURL) { + artworkFetchTask = Task { + do { + let data = try await ImageService.shared.fetchImageData(from: url) + await MainActor.run { [weak self] in + self?.playbackState.artwork = data + } + } catch { /* ignore */ } + } + } + } + + private func resetPlaybackState() { + playbackState = PlaybackState( + bundleIdentifier: configuration.bundleIdentifier, + isPlaying: false + ) + } + + private func launchApp() { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: configuration.bundleIdentifier) else { + return + } + NSWorkspace.shared.open(url) + } + + private func updateRepeatMode(_ mode: String) { + var target: RepeatMode? = nil + switch mode { + case "NONE": target = .off + case "ALL": target = .all + case "ONE": target = .one + default: break + } + if let target, target != playbackState.repeatMode { playbackState.repeatMode = target } + } + +} diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift new file mode 100644 index 0000000..f754a08 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicModels.swift @@ -0,0 +1,157 @@ +// +// YouTubeMusicModels.swift +// DynamicIsland +// +// Created by Alexander on 2025-09-14. +// + +import Foundation + +// MARK: - Configuration +struct YouTubeMusicConfiguration: Sendable { + let baseURL: String + let bundleIdentifier: String + let reconnectDelay: ClosedRange + let updateInterval: TimeInterval + + static let `default` = YouTubeMusicConfiguration( + baseURL: "http://localhost:26538", + bundleIdentifier: "com.github.th-ch.youtube-music", + reconnectDelay: 1...60, + updateInterval: 2.0 + ) +} + +// MARK: - API Models +struct AuthResponse: Decodable, Sendable { + let accessToken: String +} + +struct PlaybackResponse: Decodable, Sendable { + let isPaused: Bool + let title: String? + let artist: String? + let album: String? + let elapsedSeconds: Double? + let songDuration: Double? + let imageSrc: String? + let repeatMode: Int? + let isShuffled: Bool? +} + +// MARK: - WebSocket Message Types +enum WebSocketMessageType: String, Sendable { + case playerInfo = "PLAYER_INFO" + case videoChanged = "VIDEO_CHANGED" + case playerStateChanged = "PLAYER_STATE_CHANGED" + case positionChanged = "POSITION_CHANGED" + case volumeChanged = "VOLUME_CHANGED" + case repeatChanged = "REPEAT_CHANGED" + case shuffleChanged = "SHUFFLE_CHANGED" +} + +struct WebSocketMessage { + let type: WebSocketMessageType + let rawData: Data + private let parsedJSON: [String: Any]? + + init?(from data: Data) { + let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) + guard let typeString = json?["type"] as? String, + let messageType = WebSocketMessageType(rawValue: typeString) else { + return nil + } + + self.type = messageType + self.rawData = data + self.parsedJSON = json + } + + func extractData() -> [String: Any]? { + parsedJSON + } +} + +// MARK: - Extensions +extension PlaybackResponse { + static func from(websocketData: [String: Any]) -> PlaybackResponse? { + let songData = websocketData["song"] as? [String: Any] + + let isPaused: Bool + if let paused = songData?["isPaused"] as? Bool { + isPaused = paused + } else if let playing = websocketData["isPlaying"] as? Bool { + isPaused = !playing + } else { + isPaused = true + } + + let title = (songData?["title"] as? String) ?? + (songData?["alternativeTitle"] as? String) ?? + (websocketData["title"] as? String) + let artist = (songData?["artist"] as? String) ?? (websocketData["artist"] as? String) + let album = songData?["album"] as? String + + let elapsed = extractDouble(from: songData, key: "elapsedSeconds") ?? + extractDouble(from: websocketData, key: "position") + + let duration = extractDouble(from: songData, key: "songDuration") ?? + extractDouble(from: websocketData, key: "songDuration") + + let imageSrc = (songData?["imageSrc"] as? String) ?? (websocketData["imageSrc"] as? String) + let isShuffled = (websocketData["shuffle"] as? Bool) ?? (songData?["isShuffled"] as? Bool) + + var repeatModeInt: Int? = nil + if let repeatVal = websocketData["repeat"] as? String { + switch repeatVal.uppercased() { + case "NONE": repeatModeInt = 0 + case "ALL": repeatModeInt = 1 + case "ONE": repeatModeInt = 2 + default: break + } + } else if let repeatStr = songData?["repeat"] as? String { + switch repeatStr.uppercased() { + case "NONE": repeatModeInt = 0 + case "ALL": repeatModeInt = 1 + case "ONE": repeatModeInt = 2 + default: break + } + } + + return PlaybackResponse( + isPaused: isPaused, + title: title, + artist: artist, + album: album, + elapsedSeconds: elapsed, + songDuration: duration, + imageSrc: imageSrc, + repeatMode: repeatModeInt, + isShuffled: isShuffled + ) + } + + func with(elapsedSeconds: Double) -> PlaybackResponse { + PlaybackResponse( + isPaused: isPaused, + title: title, + artist: artist, + album: album, + elapsedSeconds: elapsedSeconds, + songDuration: songDuration, + imageSrc: imageSrc, + repeatMode: repeatMode, + isShuffled: isShuffled + ) + } +} + +private func extractDouble(from dict: [String: Any]?, key: String) -> Double? { + guard let dict = dict else { return nil } + if let value = dict[key] as? Double { + return value + } else if let value = dict[key] as? Int { + return Double(value) + } + return nil +} diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift new file mode 100644 index 0000000..3c3b274 --- /dev/null +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicNetworking.swift @@ -0,0 +1,225 @@ +// +// YouTubeMusicNetworking.swift +// DynamicIsland +// +// Created by Alexander on 2025-09-14. +// + +import Foundation + +// MARK: - HTTP Client +final class YouTubeMusicHTTPClient: ObservableObject { + private let session: URLSession + private let baseURL: String + private static let decoder = JSONDecoder() + private static let encoder = JSONEncoder() + + init(baseURL: String) { + self.baseURL = baseURL + + let config = URLSessionConfiguration.default + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 10 + + self.session = URLSession(configuration: config) + } + + // MARK: - Authentication + func authenticate() async throws -> String { + guard let url = URL(string: "\(baseURL)/auth/DynamicIsland") else { + throw YouTubeMusicError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + let authResponse: AuthResponse = try Self.decoder.decode(AuthResponse.self, from: data) + return authResponse.accessToken + } + + // MARK: - Playback Info + func getPlaybackInfo(token: String) async throws -> PlaybackResponse { + let data = try await sendCommand( + endpoint: "/song", + method: "GET", + token: token + ) + return try Self.decoder.decode(PlaybackResponse.self, from: data) + } + + // MARK: - Commands + func sendCommand( + endpoint: String, + method: String = "POST", + body: (any Codable & Sendable)? = nil, + token: String + ) async throws -> Data { + let request = try createAuthenticatedRequest( + endpoint: "/api/v1\(endpoint)", + method: method, + body: body, + token: token + ) + + let (data, response) = try await session.data(for: request) + try validateResponse(response) + + return data + } + + // MARK: - Private Helpers + private func createAuthenticatedRequest( + endpoint: String, + method: String, + body: (any Codable & Sendable)? = nil, + token: String + ) throws -> URLRequest { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + throw YouTubeMusicError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + if let body = body { + request.httpBody = try Self.encoder.encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + return request + } + + private func validateResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw YouTubeMusicError.invalidResponse + } + + switch httpResponse.statusCode { + case 200..<300: + break + case 401, 403: + throw YouTubeMusicError.authenticationRequired + default: + throw YouTubeMusicError.httpError(httpResponse.statusCode) + } + } +} + +// MARK: - WebSocket Client +actor YouTubeMusicWebSocketClient { + private var task: URLSessionWebSocketTask? + private let session: URLSession + private let onMessage: @Sendable (Data) async -> Void + private let onDisconnect: @Sendable () async -> Void + + var isConnected: Bool { task != nil } + + init( + onMessage: @escaping @Sendable (Data) async -> Void, + onDisconnect: @escaping @Sendable () async -> Void, + session: URLSession = .shared + ) { + self.onMessage = onMessage + self.onDisconnect = onDisconnect + self.session = session + } + + func connect(to url: URL, with token: String) async throws { + await disconnect() + + var request = URLRequest(url: url) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let newTask = session.webSocketTask(with: request) + task = newTask + newTask.resume() + + Task { await listenForMessages() } + } + + func disconnect() async { + task?.cancel(with: .goingAway, reason: nil) + task = nil + } + + private func listenForMessages() async { + guard let currentTask = task else { return } + + while !Task.isCancelled && task != nil { + do { + let message = try await currentTask.receive() + + let data: Data + switch message { + case .data(let d): + data = d + case .string(let s): + data = s.data(using: .utf8) ?? Data() + @unknown default: + continue + } + + await onMessage(data) + } catch { + break + } + } + task = nil + await onDisconnect() + } +} + +// MARK: - WebSocket URL Helper +struct WebSocketURLBuilder { + static func buildURL(from baseURL: String) -> URL? { + guard var components = URLComponents(string: baseURL) else { return nil } + + switch components.scheme { + case "http": + components.scheme = "ws" + case "https": + components.scheme = "wss" + default: + break + } + + components.path = "/api/v1/ws" + return components.url + } +} + +// MARK: - Errors +enum YouTubeMusicError: Error, LocalizedError, Sendable { + case invalidURL + case invalidResponse + case httpError(Int) + case authenticationRequired + case webSocketNotConnected + case encodingFailed + case decodingFailed + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .invalidResponse: + return "Invalid response" + case .httpError(let code): + return "HTTP error: \(code)" + case .authenticationRequired: + return "Authentication required" + case .webSocketNotConnected: + return "WebSocket not connected" + case .encodingFailed: + return "Failed to encode data" + case .decodingFailed: + return "Failed to decode data" + } + } +} diff --git a/DynamicIsland/MediaControllers/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTubeMusicController.swift deleted file mode 100644 index 5a2d3cb..0000000 --- a/DynamicIsland/MediaControllers/YouTubeMusicController.swift +++ /dev/null @@ -1,452 +0,0 @@ -// -// YouTubeMusicController.swift -// DynamicIsland -// -// Created By Alexander on 2025-03-30. -// Modified by Pranav on 2025-06-16. -// - -import Foundation -import Combine -import SwiftUI - -class YouTubeMusicController: MediaControllerProtocol { - // MARK: - Properties - @Published var playbackState: PlaybackState = .init( - bundleIdentifier: "com.github.th-ch.youtube-music" - ) - - var playbackStatePublisher: AnyPublisher { - $playbackState.eraseToAnyPublisher() - } - - var isWorking: Bool { - return true // YouTube Music controller always works - } - private let baseURL = "http://localhost:26538" - private var accessToken: String? - private var periodicUpdateTask: Task? - private var notificationTask: Task? - private var cancellables = Set() - private var isAuthenticating = false - - init() { - setupAppStateObservers() - Task {await authenticateAndSetup()} - } - - deinit { - stopPeriodicUpdates() - notificationTask?.cancel() - cancellables.forEach { $0.cancel() } - } - - // MARK: - Authentication - - private func authenticateAndSetup() async { - // Prevent multiple concurrent authentication attempts - guard !isAuthenticating else { return } - - isAuthenticating = true - let success = await getAccessToken() - - self.isAuthenticating = false - if success { - await self.startPeriodicUpdates() - await updatePlaybackInfo() - } else { - try? await Task.sleep(for: .seconds(5)) - await authenticateAndSetup() - } - } - - private func getAccessToken() async -> Bool { - guard let url = URL(string: "\(baseURL)/auth/boringNotch") else { - return false - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - - do { - let (data, _) = try await URLSession.shared.data(for: request) - let response = try JSONDecoder().decode(AuthResponse.self, from: data) - accessToken = response.accessToken - return true - } catch { - return false - } - } - - // MARK: - Protocol Implementation - - func play() async { - await sendCommand(endpoint: "/play", method: "POST") - await updatePlaybackInfo() - } - - func pause() async { - await sendCommand(endpoint: "/pause", method: "POST") - await updatePlaybackInfo() - } - - func togglePlay() async { - if !isActive() { - if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) { - NSWorkspace.shared.open(url) - } - } - await sendCommand(endpoint: "/toggle-play", method: "POST") - await updatePlaybackInfo() - } - - func nextTrack() async { - await sendCommand(endpoint: "/next", method: "POST") - await updatePlaybackInfo() - } - - func previousTrack() async { - await sendCommand(endpoint: "/previous", method: "POST") - await updatePlaybackInfo() - } - - func seek(to time: Double) async { - // Format the seek data payload according to the API schema - let seekData = ["seconds": time] - - do { - // Convert seek data to JSON - let jsonData = try JSONEncoder().encode(seekData) - - guard let url = URL(string: "\(baseURL)/api/v1/seek-to") else { return } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = accessToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } else { - await authenticateAndSetup() - return - } - - request.httpBody = jsonData - - do { - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - // Update playback info after seeking - try? await Task.sleep(for: .milliseconds(300)) - await updatePlaybackInfo() - - } catch { - print("Seek error: \(error)") - if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { - self.accessToken = nil - await self.authenticateAndSetup() - } - } - - } catch { - print("Error encoding seek data: \(error)") - return - } - } - - func fetchShuffleState() async { - await sendCommand(endpoint: "/shuffle", method: "GET") - } - - func toggleShuffle() async { - await sendCommand(endpoint: "/shuffle", method: "POST") - try? await Task.sleep(for: .milliseconds(200)) - await fetchShuffleState() - } - - func toggleRepeat() async { - await sendCommand(endpoint: "/switch-repeat", method: "POST") - try? await Task.sleep(for: .milliseconds(200)) - await fetchRepeatMode() - } - - func fetchRepeatMode() async { - await sendCommand(endpoint: "/repeat-mode", method: "GET") - } - - func isActive() -> Bool { - let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } - } - - // MARK: - Private Methods - - private func setupAppStateObservers() { - notificationTask = Task { @Sendable [weak self] in - await withTaskGroup(of: Void.self) { group in - // Handle app launch notifications - group.addTask { - let launchNotifications = NSWorkspace.shared.notificationCenter.notifications( - named: NSWorkspace.didLaunchApplicationNotification - ) - - for await notification in launchNotifications { - await self?.handleAppStateChange(notification: notification) - } - } - - // Handle app termination notifications - group.addTask { - let terminateNotifications = NSWorkspace.shared.notificationCenter.notifications( - named: NSWorkspace.didTerminateApplicationNotification - ) - - for await notification in terminateNotifications { - await self?.handleAppStateChange(notification: notification) - } - } - } - } - } - - private func handleAppStateChange(notification: Notification) async { - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, - app.bundleIdentifier == playbackState.bundleIdentifier else { - return - } - - if notification.name == NSWorkspace.didLaunchApplicationNotification { - await startPeriodicUpdates() - } else if notification.name == NSWorkspace.didTerminateApplicationNotification { - // App has terminated - update state and stop timer - stopPeriodicUpdates() - self.playbackState = PlaybackState( - bundleIdentifier: self.playbackState.bundleIdentifier, - isPlaying: false - ) - } - } - - private func startPeriodicUpdates() async { - stopPeriodicUpdates() // Ensure we don't create multiple timers - - // Only start the timer if the app is actually running - guard isActive() else { return } - - periodicUpdateTask = Task { [weak self] in - while !Task.isCancelled { - await self?.updatePlaybackInfo() - try? await Task.sleep(for: .seconds(2)) - } - } - - // Initial update when timer starts - await updatePlaybackInfo() - await fetchRepeatMode() - await fetchShuffleState() - } - - private func stopPeriodicUpdates() { - periodicUpdateTask?.cancel() - periodicUpdateTask = nil - } - - private func updateRepeatMode(_ modeString: String?) { - guard let modeString = modeString else { return } - - let repeatMode: RepeatMode - switch modeString { - case "NONE": - repeatMode = .off - case "ALL": - repeatMode = .all - case "ONE": - repeatMode = .one - default: - repeatMode = .off - } - - if repeatMode != playbackState.repeatMode { - playbackState.repeatMode = repeatMode - } - } - - func updatePlaybackInfo() async { - if !isActive() { - playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false) - stopPeriodicUpdates() - return - } - - guard let request = await createAuthenticatedRequest(for: "/api/v1/song") else { return } - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - // Check for authentication errors - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - // Re-authenticate and retry later - self.accessToken = nil - await authenticateAndSetup() - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - let playbackResponse = try JSONDecoder().decode(PlaybackResponse.self, from: data) - await updatePlaybackState(with: playbackResponse) - - } catch { - // Handle error silently for now, as this is called periodically - } - } - - private func updatePlaybackState(with response: PlaybackResponse) async { - var updatedState = self.playbackState - updatedState.isPlaying = !response.isPaused - updatedState.title = response.title - updatedState.artist = response.artist - updatedState.album = response.album ?? "" - updatedState.currentTime = response.elapsedSeconds - updatedState.duration = response.songDuration - updatedState.lastUpdated = Date() - - if let isShuffled = response.isShuffled { - updatedState.isShuffled = isShuffled - } - - // Load artwork if available - if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) { - do { - let artworkData = try Data(contentsOf: url) - playbackState.artwork = artworkData - - } catch { - NSLog("Failed to load artwork: \(error)") - updatedState.artwork = nil - self.playbackState = updatedState - return - } - } - self.playbackState = updatedState - } - - func pollPlaybackState() async { - if !isActive() { - return - } - - await fetchRepeatMode() - await fetchShuffleState() - await updatePlaybackInfo() - } - - private func sendCommand(endpoint: String, method: String = "GET") async { - guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return } - - var request = URLRequest(url: url) - request.httpMethod = method - - if let token = accessToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } else { - await authenticateAndSetup() - return - } - - do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - // Handle data based on endpoint - if endpoint == "/shuffle" || endpoint == "/shuffle-status" { - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let shuffleState = json["state"] as? Bool { - playbackState.isShuffled = shuffleState - } - } catch { - print("Error parsing shuffle state: \(error)") - } - } else if endpoint == "/repeat-mode" { - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let mode = json["mode"] as? String { - updateRepeatMode(mode) - } - } catch { - print("Error parsing repeat mode: \(error)") - } - } - - // Update playback info for relevant commands - await updatePlaybackInfo() - - } catch { - if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { - // Authentication error - token might be expired - accessToken = nil - await authenticateAndSetup() - - // Try the command again after authentication - try? await Task.sleep(for: .seconds(2)) - await sendCommand(endpoint: endpoint, method: method) - } - } - } - - private func createAuthenticatedRequest(for endpoint: String) async -> URLRequest? { - guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else { - if accessToken == nil { - // Token is missing, try to authenticate again - await authenticateAndSetup() - } - return nil - } - - var request = URLRequest(url: url) - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - return request - } -} - -// MARK: - API Response Models - -struct AuthResponse: Codable { - let accessToken: String -} - -struct PlaybackResponse: Codable { - let isPaused: Bool - let title: String - let artist: String - let album: String? - let elapsedSeconds: Double - let songDuration: Double - let imageSrc: String? - let repeatMode: Int? - let isShuffled: Bool? -} diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index 8b76d79..3a37e04 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -144,10 +144,10 @@ extension CalendarModel { init(from calendar: EKCalendar) { self.init( id: calendar.calendarIdentifier, - account: calendar.accountTitle, title: calendar.title, color: calendar.color, - isSubscribed: calendar.isSubscribed || calendar.isDelegate + isSubscribed: calendar.isSubscribed || calendar.isDelegate, + isReminder: calendar.allowedEntityTypes.contains(.reminder) ) } } diff --git a/DynamicIsland/components/Live activities/LiveActivityModifier.swift b/DynamicIsland/components/Live activities/LiveActivityModifier.swift index 3af0829..051b779 100644 --- a/DynamicIsland/components/Live activities/LiveActivityModifier.swift +++ b/DynamicIsland/components/Live activities/LiveActivityModifier.swift @@ -11,7 +11,6 @@ enum ActivityType { case mediaPlayback case charging case download - case recording } struct LiveActivityModifier: ViewModifier { diff --git a/DynamicIsland/helpers/MediaChecker.swift b/DynamicIsland/helpers/MediaChecker.swift index 04d0c20..b6c5e99 100644 --- a/DynamicIsland/helpers/MediaChecker.swift +++ b/DynamicIsland/helpers/MediaChecker.swift @@ -9,8 +9,6 @@ import Foundation @MainActor final class MediaChecker: Sendable { - private(set) var isNowPlayingDeprecated: Bool = false - enum MediaCheckerError: Error { case missingResources case processExecutionFailed @@ -19,7 +17,7 @@ final class MediaChecker: Sendable { func checkDeprecationStatus() async throws -> Bool { guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), - let nowPlayingTestClientPath = Bundle.main.url(forResource: "NowPlayingTestClient", withExtension: nil)?.path, + let nowPlayingTestClientPath = Bundle.main.url(forResource: "MediaRemoteAdapterTestClient", withExtension: nil)?.path, let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework") else { throw MediaCheckerError.missingResources @@ -28,8 +26,6 @@ final class MediaChecker: Sendable { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"] - let pipe = Pipe() - process.standardOutput = pipe do { try process.run() @@ -38,30 +34,32 @@ final class MediaChecker: Sendable { } // Timeout after 10 seconds - let result: String = try await withThrowingTaskGroup(of: String?.self) { group in + let didExit: Bool = try await withThrowingTaskGroup(of: Bool.self) { group in group.addTask { process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) + return true } group.addTask { try await Task.sleep(for: .seconds(10)) if process.isRunning { process.terminate() } - return "0" // Default value if process takes too long + return false } - for try await output in group { - if let output = output { + for try await exited in group { + if exited { group.cancelAll() - return output + return true } } throw MediaCheckerError.timeout } - let isDeprecated = result.trimmingCharacters(in: .whitespacesAndNewlines).last == "1" - self.isNowPlayingDeprecated = isDeprecated + if !didExit { + throw MediaCheckerError.timeout + } + + let isDeprecated = process.terminationStatus == 1 return isDeprecated } } diff --git a/DynamicIsland/managers/ImageService.swift b/DynamicIsland/managers/ImageService.swift new file mode 100644 index 0000000..9d8a101 --- /dev/null +++ b/DynamicIsland/managers/ImageService.swift @@ -0,0 +1,49 @@ +// +// ImageService.swift +// DynamicIsland +// +// Created by Alexander on 2025-09-13. +// + +import Foundation +import Defaults + +public protocol ImageServiceProtocol { + func fetchImageData(from url: URL) async throws -> Data +} + +public final class ImageService: ImageServiceProtocol { + public static let shared = ImageService() + + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + let cache = URLCache(memoryCapacity: 50 * 1024 * 1024, // 50MB + diskCapacity: 100 * 1024 * 1024, // 100MB + diskPath: "artwork_cache") + config.urlCache = cache + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + config.httpShouldSetCookies = false + self.session = URLSession(configuration: config) + + performLegacyCacheCleanupIfNeeded() + } + + private func performLegacyCacheCleanupIfNeeded() { + + if !Defaults[.didClearLegacyURLCacheV1] { + URLCache.shared.removeAllCachedResponses() + Defaults[.didClearLegacyURLCacheV1] = true + } + } + + public func fetchImageData(from url: URL) async throws -> Data { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + throw URLError(.unsupportedURL) + } + let (data, _) = try await session.data(from: url) + return data + } +} diff --git a/DynamicIsland/models/CalendarModel.swift b/DynamicIsland/models/CalendarModel.swift index 26418fc..d396f0a 100644 --- a/DynamicIsland/models/CalendarModel.swift +++ b/DynamicIsland/models/CalendarModel.swift @@ -8,10 +8,10 @@ import Cocoa -struct CalendarModel: Equatable { +struct CalendarModel: Identifiable, Hashable { let id: String - let account: String let title: String let color: NSColor let isSubscribed: Bool + let isReminder: Bool // true if this is a reminder calendar } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 87ae850..fd62f4f 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -349,6 +349,9 @@ extension Defaults.Keys { // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // static let enableScreenRecordingPolling = Key("enableScreenRecordingPolling", default: false) + // MARK: ImageService + static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false) + // MARK: Minimalistic UI Mode static let enableMinimalisticUI = Key("enableMinimalisticUI", default: false) diff --git a/DynamicIsland/models/EventModel.swift b/DynamicIsland/models/EventModel.swift index 40e7574..aab5b15 100644 --- a/DynamicIsland/models/EventModel.swift +++ b/DynamicIsland/models/EventModel.swift @@ -9,7 +9,7 @@ import Foundation -struct EventModel: Equatable { +struct EventModel: Equatable, Identifiable { let id: String let start: Date let end: Date diff --git a/reset_app_defaults.sh b/reset_app_defaults.sh deleted file mode 100755 index fdfc356..0000000 --- a/reset_app_defaults.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash - -# Reset DynamicIsland App - Clear all defaults and app state -# This script simulates a fresh first launch of the app - -set -e # Exit on error - -APP_NAME="DynamicIsland" -BUNDLE_ID="com.karthikinformationtechnology.DynamicIsland" - -echo "🧹 Resetting $APP_NAME to factory defaults..." -echo "" - -# Step 1: Kill the app if it's running -echo "1️⃣ Checking if $APP_NAME is running..." -if pgrep -x "$APP_NAME" > /dev/null; then - echo " ⚠️ $APP_NAME is running. Terminating..." - killall "$APP_NAME" 2>/dev/null || true - sleep 1 - echo " ✅ App terminated" -else - echo " ℹ️ $APP_NAME is not running" -fi - -# Step 2: Clear UserDefaults -echo "" -echo "2️⃣ Clearing UserDefaults for $BUNDLE_ID..." -defaults delete "$BUNDLE_ID" 2>/dev/null && echo " ✅ UserDefaults cleared" || echo " ℹ️ No UserDefaults found (already clean)" - -# Step 3: Remove Application Support files -echo "" -echo "3️⃣ Removing Application Support files..." -APP_SUPPORT_DIR="$HOME/Library/Application Support/$APP_NAME" -if [ -d "$APP_SUPPORT_DIR" ]; then - rm -rf "$APP_SUPPORT_DIR" - echo " ✅ Removed: $APP_SUPPORT_DIR" -else - echo " ℹ️ No Application Support directory found" -fi - -# Step 4: Remove Caches -echo "" -echo "4️⃣ Removing cached data..." -CACHE_DIR="$HOME/Library/Caches/$BUNDLE_ID" -if [ -d "$CACHE_DIR" ]; then - rm -rf "$CACHE_DIR" - echo " ✅ Removed: $CACHE_DIR" -else - echo " ℹ️ No cache directory found" -fi - -# Step 5: Remove Preferences plist (backup of defaults) -echo "" -echo "5️⃣ Removing preference files..." -PREF_FILE="$HOME/Library/Preferences/$BUNDLE_ID.plist" -if [ -f "$PREF_FILE" ]; then - rm -f "$PREF_FILE" - echo " ✅ Removed: $PREF_FILE" -else - echo " ℹ️ No preference file found" -fi - -# Step 6: Remove Saved Application State -echo "" -echo "6️⃣ Removing saved application state..." -SAVED_STATE_DIR="$HOME/Library/Saved Application State/$BUNDLE_ID.savedState" -if [ -d "$SAVED_STATE_DIR" ]; then - rm -rf "$SAVED_STATE_DIR" - echo " ✅ Removed: $SAVED_STATE_DIR" -else - echo " ℹ️ No saved state found" -fi - -# Done -echo "" -echo "✨ Reset complete! $APP_NAME will show onboarding on next launch." -echo "" -echo "To launch the app now, run:" -echo " open -a $APP_NAME" -echo ""