FsWatcher.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. //
  2. // FsWatcher.swift
  3. // Logseq
  4. //
  5. // Created by Mono Wang on 2/17/R4.
  6. //
  7. import Foundation
  8. import Capacitor
  9. // MARK: Watcher Plugin
  10. @objc(FsWatcher)
  11. public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
  12. private var watcher: PollingWatcher?
  13. private var baseUrl: URL?
  14. override public func load() {
  15. print("debug FsWatcher iOS plugin loaded!")
  16. }
  17. @objc func watch(_ call: CAPPluginCall) {
  18. if let path = call.getString("path") {
  19. guard let url = URL(string: path) else {
  20. call.reject("can not parse url")
  21. return
  22. }
  23. self.baseUrl = url
  24. self.watcher = PollingWatcher(at: url)
  25. self.watcher?.delegate = self
  26. self.watcher?.start()
  27. call.resolve(["ok": true])
  28. } else {
  29. call.reject("missing path string parameter")
  30. }
  31. }
  32. @objc func unwatch(_ call: CAPPluginCall) {
  33. watcher?.stop()
  34. watcher = nil
  35. baseUrl = nil
  36. call.resolve()
  37. }
  38. public func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
  39. guard let baseUrl = baseUrl else {
  40. // unwatch, ignore incoming
  41. return
  42. }
  43. // NOTE: Event in js {dir path content stat{mtime}}
  44. switch event {
  45. case .Unlink:
  46. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
  47. self.notifyListeners("watcher", data: ["event": "unlink",
  48. "dir": baseUrl.description as Any,
  49. "path": url.relativePath(from: baseUrl)?.precomposedStringWithCanonicalMapping as Any
  50. ])
  51. }
  52. case .Add, .Change:
  53. var content: String?
  54. if url.shouldNotifyWithContent() {
  55. content = try? String(contentsOf: url, encoding: .utf8)
  56. }
  57. self.notifyListeners("watcher", data: ["event": event.description,
  58. "dir": baseUrl.description as Any,
  59. "path": url.relativePath(from: baseUrl)?.precomposedStringWithCanonicalMapping as Any,
  60. "content": content as Any,
  61. "stat": ["mtime": metadata?.contentModificationTimestamp ?? 0,
  62. "ctime": metadata?.creationTimestamp ?? 0,
  63. "size": metadata?.fileSize as Any]
  64. ])
  65. case .Error:
  66. // TODO: handle error?
  67. break
  68. }
  69. }
  70. }
  71. // MARK: URL extension
  72. extension URL {
  73. func isSkipped() -> Bool {
  74. // skip hidden file
  75. if self.lastPathComponent.starts(with: ".") {
  76. return true
  77. }
  78. if self.absoluteString.contains("/logseq/bak/") || self.absoluteString.contains("/logseq/version-files/") {
  79. return true
  80. }
  81. if self.lastPathComponent == "graphs-txid.edn" || self.lastPathComponent == "broken-config.edn" {
  82. return true
  83. }
  84. return false
  85. }
  86. func shouldNotifyWithContent() -> Bool {
  87. let allowedPathExtensions: Set = ["md", "markdown", "org", "js", "edn", "css", "excalidraw"]
  88. if allowedPathExtensions.contains(self.pathExtension.lowercased()) {
  89. return true
  90. }
  91. return false
  92. }
  93. func isICloudPlaceholder() -> Bool {
  94. if self.lastPathComponent.starts(with: ".") && self.pathExtension.lowercased() == "icloud" {
  95. return true
  96. }
  97. return false
  98. }
  99. }
  100. // MARK: PollingWatcher
  101. public protocol PollingWatcherDelegate {
  102. func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?)
  103. }
  104. public enum PollingWatcherEvent {
  105. case Add
  106. case Change
  107. case Unlink
  108. case Error
  109. var description: String {
  110. switch self {
  111. case .Add:
  112. return "add"
  113. case .Change:
  114. return "change"
  115. case .Unlink:
  116. return "unlink"
  117. case .Error:
  118. return "error"
  119. }
  120. }
  121. }
  122. public struct SimpleFileMetadata: CustomStringConvertible, Equatable {
  123. var contentModificationTimestamp: Double
  124. var creationTimestamp: Double
  125. var fileSize: Int
  126. public init?(of fileURL: URL) {
  127. do {
  128. let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey, .creationDateKey])
  129. if fileAttributes.isRegularFile! {
  130. contentModificationTimestamp = fileAttributes.contentModificationDate?.timeIntervalSince1970 ?? 0.0
  131. creationTimestamp = fileAttributes.creationDate?.timeIntervalSince1970 ?? 0.0
  132. fileSize = fileAttributes.fileSize ?? 0
  133. } else {
  134. return nil
  135. }
  136. } catch {
  137. return nil
  138. }
  139. }
  140. public var description: String {
  141. return "Meta(size=\(self.fileSize), mtime=\(self.contentModificationTimestamp), ctime=\(self.creationTimestamp)"
  142. }
  143. }
  144. public class PollingWatcher {
  145. private let url: URL
  146. private var timer: DispatchSourceTimer?
  147. public var delegate: PollingWatcherDelegate?
  148. private var metaDb: [URL: SimpleFileMetadata] = [:]
  149. public init?(at: URL) {
  150. url = at
  151. }
  152. public func start() {
  153. self.tick(notify: false)
  154. let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timer")
  155. timer = DispatchSource.makeTimerSource(queue: queue)
  156. timer!.setEventHandler(qos: .background, flags: []) { [weak self] in
  157. self?.tick(notify: true)
  158. }
  159. timer!.schedule(deadline: .now())
  160. timer!.resume()
  161. }
  162. deinit {
  163. self.stop()
  164. }
  165. public func stop() {
  166. timer?.cancel()
  167. timer = nil
  168. }
  169. private func tick(notify: Bool) {
  170. // let startTime = DispatchTime.now()
  171. if let enumerator = FileManager.default.enumerator(
  172. at: url,
  173. includingPropertiesForKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey],
  174. // NOTE: icloud downloading requires non-skipsHiddenFiles
  175. options: [.skipsPackageDescendants]) {
  176. var newMetaDb: [URL: SimpleFileMetadata] = [:]
  177. for case let fileURL as URL in enumerator {
  178. guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey]),
  179. let isDirectory = resourceValues.isDirectory,
  180. let isRegularFile = resourceValues.isRegularFile,
  181. let name = resourceValues.name
  182. else {
  183. continue
  184. }
  185. if isDirectory {
  186. // NOTE: URL.path won't end with a `/`
  187. if fileURL.path.hasSuffix("/logseq/bak") || fileURL.path.hasSuffix("/logseq/version-files") || name == ".recycle" || name.hasPrefix(".") || name == "node_modules" {
  188. enumerator.skipDescendants()
  189. }
  190. }
  191. if isRegularFile && !fileURL.isSkipped() {
  192. if let meta = SimpleFileMetadata(of: fileURL) {
  193. newMetaDb[fileURL] = meta
  194. }
  195. } else if fileURL.isICloudPlaceholder() {
  196. try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
  197. }
  198. }
  199. if notify {
  200. self.updateMetaDb(with: newMetaDb)
  201. } else {
  202. self.metaDb = newMetaDb
  203. }
  204. }
  205. // let elapsedNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
  206. // let elapsedInMs = Double(elapsedNanoseconds) / 1_000_000
  207. // print("debug ticker elapsed=\(elapsedInMs)ms")
  208. if #available(iOS 13.0, *) {
  209. timer?.schedule(deadline: .now().advanced(by: .seconds(2)), leeway: .milliseconds(100))
  210. } else {
  211. // Fallback on earlier versions
  212. timer?.schedule(deadline: .now() + 2.0, leeway: .milliseconds(100))
  213. }
  214. }
  215. // TODO: batch?
  216. private func updateMetaDb(with newMetaDb: [URL: SimpleFileMetadata]) {
  217. for (url, meta) in newMetaDb {
  218. if let idx = self.metaDb.index(forKey: url) {
  219. let (_, oldMeta) = self.metaDb.remove(at: idx)
  220. if oldMeta != meta {
  221. self.delegate?.receivedNotification(url, .Change, meta)
  222. }
  223. } else {
  224. self.delegate?.receivedNotification(url, .Add, meta)
  225. }
  226. }
  227. for url in self.metaDb.keys {
  228. self.delegate?.receivedNotification(url, .Unlink, nil)
  229. }
  230. self.metaDb = newMetaDb
  231. }
  232. }
  233. extension URL {
  234. func relativePath(from base: URL) -> String? {
  235. // Ensure that both URLs represent files:
  236. guard self.isFileURL && base.isFileURL else {
  237. return nil
  238. }
  239. // NOTE: standardizedFileURL will remove `/private` prefix
  240. // If the file is not exist, it won't remove the prefix.
  241. // Remove/replace "." and "..", make paths absolute:
  242. var destComponents = self.standardizedFileURL.pathComponents
  243. let baseComponents = base.standardizedFileURL.pathComponents
  244. // replace "private" when needed
  245. if destComponents.count > 1 && destComponents[1] == "private" && baseComponents.count > 1 && baseComponents[1] != "private" {
  246. destComponents.remove(at: 1)
  247. }
  248. // Find number of common path components:
  249. var i = 0
  250. while i < destComponents.count && i < baseComponents.count
  251. && destComponents[i] == baseComponents[i] {
  252. i += 1
  253. }
  254. // Build relative path:
  255. var relComponents = Array(repeating: "..", count: baseComponents.count - i)
  256. relComponents.append(contentsOf: destComponents[i...])
  257. return relComponents.joined(separator: "/")
  258. }
  259. }