Still not broken: Next commit decides the fate

This commit is contained in:
Hariharan Mudaliar 2025-10-05 23:34:57 +05:30
commit dc45c50df4
17 changed files with 1028 additions and 608 deletions

5
.gitignore vendored
View file

@ -77,3 +77,8 @@ boring.notch
referenceimages referenceimages
PrivateWorks PrivateWorks
*.py
*.sh
comparison_report.json
*.txt

View file

@ -12,7 +12,8 @@ import SwiftUI
class AppleMusicController: MediaControllerProtocol { class AppleMusicController: MediaControllerProtocol {
// MARK: - Properties // MARK: - Properties
@Published private var playbackState: PlaybackState = PlaybackState( @Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: "com.apple.Music" bundleIdentifier: "com.apple.Music",
playbackRate: 1
) )
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> { var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
@ -79,7 +80,8 @@ class AppleMusicController: MediaControllerProtocol {
func toggleShuffle() async { func toggleShuffle() async {
await executeCommand("set shuffle enabled to not shuffle enabled") await executeCommand("set shuffle enabled to not shuffle enabled")
playbackState.isShuffled.toggle() try? await Task.sleep(for: .milliseconds(150))
await updatePlaybackInfo()
} }
func toggleRepeat() async { func toggleRepeat() async {
@ -92,14 +94,8 @@ class AppleMusicController: MediaControllerProtocol {
set song repeat to off set song repeat to off
end if end if
""") """)
try? await Task.sleep(for: .milliseconds(150))
if playbackState.repeatMode == .off { await updatePlaybackInfo()
playbackState.repeatMode = .all
} else if playbackState.repeatMode == .all {
playbackState.repeatMode = .one
} else {
playbackState.repeatMode = .off
}
} }
func isActive() -> Bool { func isActive() -> Bool {
@ -122,7 +118,7 @@ class AppleMusicController: MediaControllerProtocol {
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0 let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
updatedState.artwork = descriptor.atIndex(9)?.data as Data? updatedState.artwork = descriptor.atIndex(9)?.data as Data?
updatedState.lastUpdated = Date()
self.playbackState = updatedState self.playbackState = updatedState
} }
@ -147,11 +143,11 @@ class AppleMusicController: MediaControllerProtocol {
set shuffleState to shuffle enabled set shuffleState to shuffle enabled
set repeatState to song repeat set repeatState to song repeat
if repeatState is off then if repeatState is off then
set repeatValue to 0
else if repeatState is all then
set repeatValue to 1 set repeatValue to 1
else if repeatState is one then else if repeatState is one then
set repeatValue to 2 set repeatValue to 2
else if repeatState is all then
set repeatValue to 3
end if end if
try try

View file

@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import AppKit
import Combine import Combine
protocol MediaControllerProtocol: ObservableObject { protocol MediaControllerProtocol: ObservableObject {

View file

@ -26,7 +26,10 @@ class SpotifyController: MediaControllerProtocol {
private var notificationTask: Task<Void, Never>? private var notificationTask: Task<Void, Never>?
// Constant for time between command and update // Constant for time between command and update
let commandUpdateDelay: Duration = .milliseconds(25) private let commandUpdateDelay: Duration = .milliseconds(25)
private var lastArtworkURL: String?
private var artworkFetchTask: Task<Void, Never>?
init() { init() {
setupPlaybackStateChangeObserver() setupPlaybackStateChangeObserver()
@ -51,50 +54,33 @@ class SpotifyController: MediaControllerProtocol {
deinit { deinit {
notificationTask?.cancel() notificationTask?.cancel()
artworkFetchTask?.cancel()
} }
// MARK: - Protocol Implementation // MARK: - Protocol Implementation
func play() async { func play() async { await executeCommand("play") }
await executeCommand("play") func pause() async { await executeCommand("pause") }
} func togglePlay() async { await executeCommand("playpause") }
func nextTrack() async { await executeCommand("next track") }
func pause() async {
await executeCommand("pause")
}
func togglePlay() async {
await executeCommand("playpause")
}
func nextTrack() async {
await executeCommand("next track")
}
func previousTrack() async { func previousTrack() async {
await executeCommand("previous track") await executeAndRefresh("previous track")
} }
func seek(to time: Double) async { func seek(to time: Double) async {
await executeCommand("set player position to \(time)") await executeAndRefresh("set player position to \(time)")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
} }
func toggleShuffle() async { func toggleShuffle() async {
await executeCommand("set shuffling to not shuffling") await executeAndRefresh("set shuffling to not shuffling")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
} }
func toggleRepeat() async { func toggleRepeat() async {
await executeCommand("set repeating to not repeating") await executeAndRefresh("set repeating to not repeating")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
} }
func isActive() -> Bool { func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
} }
func updatePlaybackInfo() async { func updatePlaybackInfo() async {
@ -111,7 +97,7 @@ class SpotifyController: MediaControllerProtocol {
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let state = PlaybackState( var state = PlaybackState(
bundleIdentifier: "com.spotify.client", bundleIdentifier: "com.spotify.client",
isPlaying: isPlaying, isPlaying: isPlaying,
title: currentTrack, title: currentTrack,
@ -125,19 +111,35 @@ class SpotifyController: MediaControllerProtocol {
lastUpdated: Date() lastUpdated: Date()
) )
self.playbackState = state if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork
}
playbackState = state
// Load artwork asynchronously and update the state when complete
if !artworkURL.isEmpty, let url = URL(string: artworkURL) { if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
artworkFetchTask?.cancel()
let currentState = state let currentState = state
artworkFetchTask = Task {
do { do {
let (data, _) = try await URLSession.shared.data(from: url) let data = try await ImageService.shared.fetchImageData(from: url)
// Create a new state with the artwork data and update
await MainActor.run { [weak self] in
guard let self = self else { return }
var updatedState = currentState var updatedState = currentState
updatedState.artwork = data updatedState.artwork = data
playbackState = updatedState self.playbackState = updatedState
self.lastArtworkURL = artworkURL
self.artworkFetchTask = nil
}
} catch { } catch {
print("Failed to load artwork: \(error)") await MainActor.run { [weak self] in
self?.artworkFetchTask = nil
}
}
} }
} }
} }
@ -149,6 +151,12 @@ class SpotifyController: MediaControllerProtocol {
try? await AppleScriptHelper.executeVoid(script) 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? { private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """ let script = """
tell application "Spotify" tell application "Spotify"

View file

@ -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<String, Error>?
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<String, Error> {
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
}
}

View file

@ -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<Void, Never>?
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$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<Void, Never>?
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 }
}
}

View file

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

View file

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

View file

@ -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, Never> {
$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<Void, Never>?
private var notificationTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
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?
}

View file

@ -144,10 +144,10 @@ extension CalendarModel {
init(from calendar: EKCalendar) { init(from calendar: EKCalendar) {
self.init( self.init(
id: calendar.calendarIdentifier, id: calendar.calendarIdentifier,
account: calendar.accountTitle,
title: calendar.title, title: calendar.title,
color: calendar.color, color: calendar.color,
isSubscribed: calendar.isSubscribed || calendar.isDelegate isSubscribed: calendar.isSubscribed || calendar.isDelegate,
isReminder: calendar.allowedEntityTypes.contains(.reminder)
) )
} }
} }

View file

@ -11,7 +11,6 @@ enum ActivityType {
case mediaPlayback case mediaPlayback
case charging case charging
case download case download
case recording
} }
struct LiveActivityModifier<Left: View, Right: View>: ViewModifier { struct LiveActivityModifier<Left: View, Right: View>: ViewModifier {

View file

@ -9,8 +9,6 @@ import Foundation
@MainActor @MainActor
final class MediaChecker: Sendable { final class MediaChecker: Sendable {
private(set) var isNowPlayingDeprecated: Bool = false
enum MediaCheckerError: Error { enum MediaCheckerError: Error {
case missingResources case missingResources
case processExecutionFailed case processExecutionFailed
@ -19,7 +17,7 @@ final class MediaChecker: Sendable {
func checkDeprecationStatus() async throws -> Bool { func checkDeprecationStatus() async throws -> Bool {
guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), 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") let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
else { else {
throw MediaCheckerError.missingResources throw MediaCheckerError.missingResources
@ -28,8 +26,6 @@ final class MediaChecker: Sendable {
let process = Process() let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"] process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"]
let pipe = Pipe()
process.standardOutput = pipe
do { do {
try process.run() try process.run()
@ -38,30 +34,32 @@ final class MediaChecker: Sendable {
} }
// Timeout after 10 seconds // 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 { group.addTask {
process.waitUntilExit() process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() return true
return String(data: data, encoding: .utf8)
} }
group.addTask { group.addTask {
try await Task.sleep(for: .seconds(10)) try await Task.sleep(for: .seconds(10))
if process.isRunning { if process.isRunning {
process.terminate() process.terminate()
} }
return "0" // Default value if process takes too long return false
} }
for try await output in group { for try await exited in group {
if let output = output { if exited {
group.cancelAll() group.cancelAll()
return output return true
} }
} }
throw MediaCheckerError.timeout throw MediaCheckerError.timeout
} }
let isDeprecated = result.trimmingCharacters(in: .whitespacesAndNewlines).last == "1" if !didExit {
self.isNowPlayingDeprecated = isDeprecated throw MediaCheckerError.timeout
}
let isDeprecated = process.terminationStatus == 1
return isDeprecated return isDeprecated
} }
} }

View file

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

View file

@ -8,10 +8,10 @@
import Cocoa import Cocoa
struct CalendarModel: Equatable { struct CalendarModel: Identifiable, Hashable {
let id: String let id: String
let account: String
let title: String let title: String
let color: NSColor let color: NSColor
let isSubscribed: Bool let isSubscribed: Bool
let isReminder: Bool // true if this is a reminder calendar
} }

View file

@ -349,6 +349,9 @@ extension Defaults.Keys {
// Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent) // Polling removed - now uses event-driven private API detection (CGSIsScreenWatcherPresent)
// static let enableScreenRecordingPolling = Key<Bool>("enableScreenRecordingPolling", default: false) // static let enableScreenRecordingPolling = Key<Bool>("enableScreenRecordingPolling", default: false)
// MARK: ImageService
static let didClearLegacyURLCacheV1 = Key<Bool>("didClearLegacyURLCacheV1", default: false)
// MARK: Minimalistic UI Mode // MARK: Minimalistic UI Mode
static let enableMinimalisticUI = Key<Bool>("enableMinimalisticUI", default: false) static let enableMinimalisticUI = Key<Bool>("enableMinimalisticUI", default: false)

View file

@ -9,7 +9,7 @@
import Foundation import Foundation
struct EventModel: Equatable { struct EventModel: Equatable, Identifiable {
let id: String let id: String
let start: Date let start: Date
let end: Date let end: Date

View file

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