Files
logseq/ios/App/App/FsWatcher.swift
Tienson Qin bcc478b5f7 refactor: separate og and db version (#12276)
separate og and new version apps

remove file sync, tldraw, excalidraw and zotero
2025-12-29 15:39:32 +08:00

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: "/")
}
}