import AppKit import Foundation import SwiftData import UniformTypeIdentifiers /// Watches for sync files from the iOS MixBoard app. /// Checks two locations: /// 1. A "MixBoard Sync" folder on the Desktop (for manual AirDrop/copy) /// 2. The iOS app's iCloud Drive container (if iCloud Drive sync is enabled) @MainActor final class SyncWatcher: ObservableObject { @Published var lastImportDate: Date? @Published var lastImportResult: String? @Published var hasPendingSync = false @Published var pendingSyncURL: URL? private var fileMonitor: DispatchSourceFileSystemObject? private var timer: Timer? private var lastKnownModDate: Date? /// All paths to check for sync files. var watchPaths: [URL] { var paths: [URL] = [] // 1. Desktop/MixBoard Sync/ (for manual drops) let desktop = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first! paths.append(desktop.appendingPathComponent("MixBoard Sync/mixboard-playlists.json")) // 2. Documents/MixBoard Sync/ let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! paths.append(docs.appendingPathComponent("MixBoard Sync/mixboard-playlists.json")) // 3. iCloud Drive MixBoard app container // ~/Library/Mobile Documents/iCloud~com~mixboard~MixBoardiOS/Documents/Sync/mixboard-playlists.json let home = FileManager.default.homeDirectoryForCurrentUser let iCloudPath = home .appendingPathComponent("Library/Mobile Documents") .appendingPathComponent("com~apple~CloudDocs") // generic iCloud Drive .appendingPathComponent("MixBoard/mixboard-playlists.json") paths.append(iCloudPath) return paths } /// Start polling for sync files (every 10 seconds). func startWatching() { timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in Task { @MainActor in self?.checkForSyncFile() } } // Check immediately checkForSyncFile() } func stopWatching() { timer?.invalidate() timer = nil } /// Check all watch paths for a sync file. func checkForSyncFile() { let fm = FileManager.default for url in watchPaths { guard fm.fileExists(atPath: url.path) else { continue } // Check if file is newer than last import if let attrs = try? fm.attributesOfItem(atPath: url.path), let modDate = attrs[.modificationDate] as? Date { if let lastKnown = lastKnownModDate, modDate <= lastKnown { continue // Already processed this version } lastKnownModDate = modDate hasPendingSync = true pendingSyncURL = url return } } } /// Import from a specific sync file. func importSyncFile(_ url: URL, context: ModelContext) { do { let result = try SyncImporter.importFromFile(url, context: context) lastImportDate = Date() lastImportResult = result.summary hasPendingSync = false pendingSyncURL = nil print("SyncWatcher: \(result.summary)") } catch { lastImportResult = "Import failed: \(error.localizedDescription)" print("SyncWatcher: Import failed: \(error)") } } /// Import from the pending sync file. func importPending(context: ModelContext) { guard let url = pendingSyncURL else { return } importSyncFile(url, context: context) } /// Show an open panel to pick a sync file manually. func importManually(context: ModelContext) { let panel = NSOpenPanel() panel.title = "Import Playlists from iPhone" panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.message = "Select the mixboard-playlists.json file exported from MixBoard iOS" if panel.runModal() == .OK, let url = panel.url { importSyncFile(url, context: context) } } /// Create the sync drop folders so users know where to put the file. func createSyncFolders() { let fm = FileManager.default let desktop = fm.urls(for: .desktopDirectory, in: .userDomainMask).first! let syncFolder = desktop.appendingPathComponent("MixBoard Sync") try? fm.createDirectory(at: syncFolder, withIntermediateDirectories: true) } }