| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- //
- // FsWatcher.swift
- // Logseq
- //
- // Created by Mono Wang on 2/17/R4.
- //
- import Foundation
- import Capacitor
- // MARK: Watcher Plugin
- @objc(FsWatcher)
- public class FsWatcher: CAPPlugin, PollingWatcherDelegate {
- private var watcher: PollingWatcher?
- private var baseUrl: URL?
- override public func load() {
- print("debug FsWatcher iOS plugin loaded!")
- }
- @objc func watch(_ call: CAPPluginCall) {
- if let path = call.getString("path") {
- guard let url = URL(string: path) else {
- call.reject("can not parse url")
- return
- }
- self.baseUrl = url
- self.watcher = PollingWatcher(at: url)
- self.watcher?.delegate = self
- self.watcher?.start()
- call.resolve(["ok": true])
- } else {
- call.reject("missing path string parameter")
- }
- }
- @objc func unwatch(_ call: CAPPluginCall) {
- watcher?.stop()
- watcher = nil
- baseUrl = nil
- call.resolve()
- }
- public func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
- guard let baseUrl = baseUrl else {
- // unwatch, ignore incoming
- return
- }
- // NOTE: Event in js {dir path content stat{mtime}}
- switch event {
- case .Unlink:
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
- self.notifyListeners("watcher", data: ["event": "unlink",
- "dir": baseUrl.description as Any,
- "path": url.relativePath(from: baseUrl)?.precomposedStringWithCanonicalMapping as Any
- ])
- }
- case .Add, .Change:
- var content: String?
- if url.shouldNotifyWithContent() {
- content = try? String(contentsOf: url, encoding: .utf8)
- }
- self.notifyListeners("watcher", data: ["event": event.description,
- "dir": baseUrl.description as Any,
- "path": url.relativePath(from: baseUrl)?.precomposedStringWithCanonicalMapping as Any,
- "content": content as Any,
- "stat": ["mtime": metadata?.contentModificationTimestamp ?? 0,
- "ctime": metadata?.creationTimestamp ?? 0,
- "size": metadata?.fileSize as Any]
- ])
- case .Error:
- // TODO: handle error?
- break
- }
- }
- }
- // MARK: URL extension
- extension URL {
- func isSkipped() -> Bool {
- // skip hidden file
- if self.lastPathComponent.starts(with: ".") {
- return true
- }
- if self.absoluteString.contains("/logseq/bak/") || self.absoluteString.contains("/logseq/version-files/") {
- return true
- }
- if self.lastPathComponent == "graphs-txid.edn" || self.lastPathComponent == "broken-config.edn" {
- return true
- }
- return false
- }
- func shouldNotifyWithContent() -> Bool {
- let allowedPathExtensions: Set = ["md", "markdown", "org", "js", "edn", "css", "excalidraw"]
- if allowedPathExtensions.contains(self.pathExtension.lowercased()) {
- return true
- }
- return false
- }
- func isICloudPlaceholder() -> Bool {
- if self.lastPathComponent.starts(with: ".") && self.pathExtension.lowercased() == "icloud" {
- return true
- }
- return false
- }
- }
- // MARK: PollingWatcher
- public protocol PollingWatcherDelegate {
- func receivedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?)
- }
- public enum PollingWatcherEvent {
- case Add
- case Change
- case Unlink
- case Error
- var description: String {
- switch self {
- case .Add:
- return "add"
- case .Change:
- return "change"
- case .Unlink:
- return "unlink"
- case .Error:
- return "error"
- }
- }
- }
- public struct SimpleFileMetadata: CustomStringConvertible, Equatable {
- var contentModificationTimestamp: Double
- var creationTimestamp: Double
- var fileSize: Int
- public init?(of fileURL: URL) {
- do {
- let fileAttributes = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey, .creationDateKey])
- if fileAttributes.isRegularFile! {
- contentModificationTimestamp = fileAttributes.contentModificationDate?.timeIntervalSince1970 ?? 0.0
- creationTimestamp = fileAttributes.creationDate?.timeIntervalSince1970 ?? 0.0
- fileSize = fileAttributes.fileSize ?? 0
- } else {
- return nil
- }
- } catch {
- return nil
- }
- }
- public var description: String {
- return "Meta(size=\(self.fileSize), mtime=\(self.contentModificationTimestamp), ctime=\(self.creationTimestamp)"
- }
- }
- public class PollingWatcher {
- private let url: URL
- private var timer: DispatchSourceTimer?
- public var delegate: PollingWatcherDelegate?
- private var metaDb: [URL: SimpleFileMetadata] = [:]
- public init?(at: URL) {
- url = at
- }
- public func start() {
- self.tick(notify: false)
- let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timer")
- timer = DispatchSource.makeTimerSource(queue: queue)
- timer!.setEventHandler(qos: .background, flags: []) { [weak self] in
- self?.tick(notify: true)
- }
- timer!.schedule(deadline: .now())
- timer!.resume()
- }
- deinit {
- self.stop()
- }
- public func stop() {
- timer?.cancel()
- timer = nil
- }
- private func tick(notify: Bool) {
- // let startTime = DispatchTime.now()
- if let enumerator = FileManager.default.enumerator(
- at: url,
- includingPropertiesForKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey],
- // NOTE: icloud downloading requires non-skipsHiddenFiles
- options: [.skipsPackageDescendants]) {
- var newMetaDb: [URL: SimpleFileMetadata] = [:]
- for case let fileURL as URL in enumerator {
- guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .nameKey, .isDirectoryKey]),
- let isDirectory = resourceValues.isDirectory,
- let isRegularFile = resourceValues.isRegularFile,
- let name = resourceValues.name
- else {
- continue
- }
- if isDirectory {
- // NOTE: URL.path won't end with a `/`
- if fileURL.path.hasSuffix("/logseq/bak") || fileURL.path.hasSuffix("/logseq/version-files") || name == ".recycle" || name.hasPrefix(".") || name == "node_modules" {
- enumerator.skipDescendants()
- }
- }
- if isRegularFile && !fileURL.isSkipped() {
- if let meta = SimpleFileMetadata(of: fileURL) {
- newMetaDb[fileURL] = meta
- }
- } else if fileURL.isICloudPlaceholder() {
- try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
- }
- }
- if notify {
- self.updateMetaDb(with: newMetaDb)
- } else {
- self.metaDb = newMetaDb
- }
- }
- // let elapsedNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds
- // let elapsedInMs = Double(elapsedNanoseconds) / 1_000_000
- // print("debug ticker elapsed=\(elapsedInMs)ms")
- if #available(iOS 13.0, *) {
- timer?.schedule(deadline: .now().advanced(by: .seconds(2)), leeway: .milliseconds(100))
- } else {
- // Fallback on earlier versions
- timer?.schedule(deadline: .now() + 2.0, leeway: .milliseconds(100))
- }
- }
- // TODO: batch?
- private func updateMetaDb(with newMetaDb: [URL: SimpleFileMetadata]) {
- for (url, meta) in newMetaDb {
- if let idx = self.metaDb.index(forKey: url) {
- let (_, oldMeta) = self.metaDb.remove(at: idx)
- if oldMeta != meta {
- self.delegate?.receivedNotification(url, .Change, meta)
- }
- } else {
- self.delegate?.receivedNotification(url, .Add, meta)
- }
- }
- for url in self.metaDb.keys {
- self.delegate?.receivedNotification(url, .Unlink, nil)
- }
- self.metaDb = newMetaDb
- }
- }
- extension URL {
- func relativePath(from base: URL) -> String? {
- // Ensure that both URLs represent files:
- guard self.isFileURL && base.isFileURL else {
- return nil
- }
- // NOTE: standardizedFileURL will remove `/private` prefix
- // If the file is not exist, it won't remove the prefix.
- // Remove/replace "." and "..", make paths absolute:
- var destComponents = self.standardizedFileURL.pathComponents
- let baseComponents = base.standardizedFileURL.pathComponents
- // replace "private" when needed
- if destComponents.count > 1 && destComponents[1] == "private" && baseComponents.count > 1 && baseComponents[1] != "private" {
- destComponents.remove(at: 1)
- }
- // Find number of common path components:
- var i = 0
- while i < destComponents.count && i < baseComponents.count
- && destComponents[i] == baseComponents[i] {
- i += 1
- }
- // Build relative path:
- var relComponents = Array(repeating: "..", count: baseComponents.count - i)
- relComponents.append(contentsOf: destComponents[i...])
- return relComponents.joined(separator: "/")
- }
- }
|