mirror of
https://github.com/logseq/logseq.git
synced 2026-04-24 22:25:01 +00:00
306 lines
10 KiB
Swift
306 lines
10 KiB
Swift
//
|
|
// 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"]
|
|
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: "/")
|
|
}
|
|
}
|