SyncWatcher.swift 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. import AppKit
  2. import Foundation
  3. import SwiftData
  4. import UniformTypeIdentifiers
  5. /// Watches for sync files from the iOS MixBoard app.
  6. /// Checks two locations:
  7. /// 1. A "MixBoard Sync" folder on the Desktop (for manual AirDrop/copy)
  8. /// 2. The iOS app's iCloud Drive container (if iCloud Drive sync is enabled)
  9. @MainActor
  10. final class SyncWatcher: ObservableObject {
  11. @Published var lastImportDate: Date?
  12. @Published var lastImportResult: String?
  13. @Published var hasPendingSync = false
  14. @Published var pendingSyncURL: URL?
  15. private var fileMonitor: DispatchSourceFileSystemObject?
  16. private var timer: Timer?
  17. private var lastKnownModDate: Date?
  18. /// All paths to check for sync files.
  19. var watchPaths: [URL] {
  20. var paths: [URL] = []
  21. // 1. Desktop/MixBoard Sync/ (for manual drops)
  22. let desktop = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
  23. paths.append(desktop.appendingPathComponent("MixBoard Sync/mixboard-playlists.json"))
  24. // 2. Documents/MixBoard Sync/
  25. let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  26. paths.append(docs.appendingPathComponent("MixBoard Sync/mixboard-playlists.json"))
  27. // 3. iCloud Drive MixBoard app container
  28. // ~/Library/Mobile Documents/iCloud~com~mixboard~MixBoardiOS/Documents/Sync/mixboard-playlists.json
  29. let home = FileManager.default.homeDirectoryForCurrentUser
  30. let iCloudPath = home
  31. .appendingPathComponent("Library/Mobile Documents")
  32. .appendingPathComponent("com~apple~CloudDocs") // generic iCloud Drive
  33. .appendingPathComponent("MixBoard/mixboard-playlists.json")
  34. paths.append(iCloudPath)
  35. return paths
  36. }
  37. /// Start polling for sync files (every 10 seconds).
  38. func startWatching() {
  39. timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
  40. Task { @MainActor in
  41. self?.checkForSyncFile()
  42. }
  43. }
  44. // Check immediately
  45. checkForSyncFile()
  46. }
  47. func stopWatching() {
  48. timer?.invalidate()
  49. timer = nil
  50. }
  51. /// Check all watch paths for a sync file.
  52. func checkForSyncFile() {
  53. let fm = FileManager.default
  54. for url in watchPaths {
  55. guard fm.fileExists(atPath: url.path) else { continue }
  56. // Check if file is newer than last import
  57. if let attrs = try? fm.attributesOfItem(atPath: url.path),
  58. let modDate = attrs[.modificationDate] as? Date {
  59. if let lastKnown = lastKnownModDate, modDate <= lastKnown {
  60. continue // Already processed this version
  61. }
  62. lastKnownModDate = modDate
  63. hasPendingSync = true
  64. pendingSyncURL = url
  65. return
  66. }
  67. }
  68. }
  69. /// Import from a specific sync file.
  70. func importSyncFile(_ url: URL, context: ModelContext) {
  71. do {
  72. let result = try SyncImporter.importFromFile(url, context: context)
  73. lastImportDate = Date()
  74. lastImportResult = result.summary
  75. hasPendingSync = false
  76. pendingSyncURL = nil
  77. print("SyncWatcher: \(result.summary)")
  78. } catch {
  79. lastImportResult = "Import failed: \(error.localizedDescription)"
  80. print("SyncWatcher: Import failed: \(error)")
  81. }
  82. }
  83. /// Import from the pending sync file.
  84. func importPending(context: ModelContext) {
  85. guard let url = pendingSyncURL else { return }
  86. importSyncFile(url, context: context)
  87. }
  88. /// Show an open panel to pick a sync file manually.
  89. func importManually(context: ModelContext) {
  90. let panel = NSOpenPanel()
  91. panel.title = "Import Playlists from iPhone"
  92. panel.allowedContentTypes = [.json]
  93. panel.allowsMultipleSelection = false
  94. panel.canChooseDirectories = false
  95. panel.message = "Select the mixboard-playlists.json file exported from MixBoard iOS"
  96. if panel.runModal() == .OK, let url = panel.url {
  97. importSyncFile(url, context: context)
  98. }
  99. }
  100. /// Create the sync drop folders so users know where to put the file.
  101. func createSyncFolders() {
  102. let fm = FileManager.default
  103. let desktop = fm.urls(for: .desktopDirectory, in: .userDomainMask).first!
  104. let syncFolder = desktop.appendingPathComponent("MixBoard Sync")
  105. try? fm.createDirectory(at: syncFolder, withIntermediateDirectories: true)
  106. }
  107. }