feat(ios): add polling based file watcher

pull/4420/head^2
Andelf 2022-02-24 18:36:58 +08:00
parent f1aff93807
commit b260648b60
10 changed files with 302 additions and 85 deletions

View File

@ -22,6 +22,8 @@
D32752BE275496C60039291C /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D32752BD275496C60039291C /* CloudKit.framework */; };
D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A09275C92880003FBDC /* FileContainer.swift */; };
D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A0B275C928F0003FBDC /* FileContainer.m */; };
FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF327BDFEDE00F3206B /* FsWatcher.swift */; };
FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -47,6 +49,8 @@
D3D62A09275C92880003FBDC /* FileContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileContainer.swift; sourceTree = "<group>"; };
D3D62A0B275C928F0003FBDC /* FileContainer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileContainer.m; sourceTree = "<group>"; };
DE5650F4AD4E2242AB9C012D /* Pods-Logseq.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Logseq.debug.xcconfig"; path = "Target Support Files/Pods-Logseq/Pods-Logseq.debug.xcconfig"; sourceTree = "<group>"; };
FE647FF327BDFEDE00F3206B /* FsWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FsWatcher.swift; sourceTree = "<group>"; };
FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -96,6 +100,8 @@
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
7435D10B2704659F00AB88E0 /* FolderPicker.swift */,
FE647FF327BDFEDE00F3206B /* FsWatcher.swift */,
FE647FF527BDFEF500F3206B /* FsWatcher.m */,
7435D10E2704660B00AB88E0 /* FolderPicker.m */,
D3D62A09275C92880003FBDC /* FileContainer.swift */,
D3D62A0B275C928F0003FBDC /* FileContainer.m */,
@ -241,11 +247,13 @@
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
5FD5BB71278579F5008E6875 /* DownloadiCloudFiles.swift in Sources */,
FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */,
5FD5BB73278579FF008E6875 /* DownloadiCloudFiles.m in Sources */,
D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */,
D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */,
7435D10F2704660B00AB88E0 /* FolderPicker.m in Sources */,
7435D10C2704659F00AB88E0 /* FolderPicker.swift in Sources */,
FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -1,5 +1,5 @@
//
// DowloadiCloudFiles.m
// DownloadiCloudFiles.m
// Logseq
//
// Created by leizhe on 2021/12/29.

View File

@ -36,7 +36,7 @@ public class FileContainer: CAPPlugin, UIDocumentPickerDelegate {
guard let filename = self.containerUrl?.appendingPathComponent(".logseq") else {
return
}
if !FileManager.default.fileExists(atPath: filename.path) {
do {
try str.write(to: filename, atomically: true, encoding: String.Encoding.utf8)
@ -45,8 +45,6 @@ public class FileContainer: CAPPlugin, UIDocumentPickerDelegate {
// failed to write file bad permissions, bad filename, missing permissions, or more likely it can't be converted to the encoding
}
}
self._call?.resolve([
"path": self.containerUrl?.path
])
self._call?.resolve(["path": self.containerUrl?.path as Any])
}
}

13
ios/App/App/FsWatcher.m Normal file
View File

@ -0,0 +1,13 @@
//
// FsWatcher.m
// Logseq
//
// Created by Mono Wang on 2/17/R4.
//
#import <Capacitor/Capacitor.h>
CAP_PLUGIN(FsWatcher, "FsWatcher",
CAP_PLUGIN_METHOD(watch, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(unwatch, CAPPluginReturnPromise);
)

222
ios/App/App/FsWatcher.swift Normal file
View File

@ -0,0 +1,222 @@
//
// 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? = nil
private var baseUrl: URL? = nil
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
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 recevedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?) {
// NOTE: Event in js {dir path content stat{mtime}}
switch event {
case .Unlink:
self.notifyListeners("watcher", data: ["event": "unlink",
"dir": baseUrl?.description as Any,
"path": url.description,
])
case .Add:
let content = try? String(contentsOf: url, encoding: .utf8)
self.notifyListeners("watcher", data: ["event": "add",
"dir": baseUrl?.description as Any,
"path": url.description,
"content": content as Any,
"stat": ["mtime": metadata?.contentModificationTimestamp,
"ctime": metadata?.creationTimestamp]
])
case .Change:
let content = try? String(contentsOf: url, encoding: .utf8)
self.notifyListeners("watcher", data: ["event": "change",
"dir": baseUrl?.description as Any,
"path": url.description,
"content": content as Any,
"stat": ["mtime": metadata?.contentModificationTimestamp,
"ctime": metadata?.creationTimestamp]])
case .Error:
// TODO: handle error?
break
}
}
}
// MARK: URL extension
extension URL {
func isSkipped() -> Bool {
// skip hidden file
if self.lastPathComponent.starts(with: ".") {
return true
}
let allowedPathExtensions: Set = ["md", "markdown", "org", "css", "edn", "excalidraw"]
if allowedPathExtensions.contains(self.pathExtension.lowercased()) {
return false
}
// skip for other file types
return true
}
func isICloudPlaceholder() -> Bool {
if self.lastPathComponent.starts(with: ".") && self.pathExtension.lowercased() == "icloud" {
return true
}
return false
}
}
// MARK: PollingWatcher
public protocol PollingWatcherDelegate {
func recevedNotification(_ url: URL, _ event: PollingWatcherEvent, _ metadata: SimpleFileMetadata?)
}
public enum PollingWatcherEvent: String {
case Add
case Change
case Unlink
case 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? = nil
private var metaDb: [URL: SimpleFileMetadata] = [:]
public init?(at: URL) {
url = at
let queue = DispatchQueue(label: Bundle.main.bundleIdentifier! + ".timer")
timer = DispatchSource.makeTimerSource(queue: queue)
timer!.setEventHandler(qos: .background, flags: []) { [weak self] in
self?.tick()
}
timer!.schedule(deadline: .now())
timer!.resume()
}
deinit {
self.stop()
}
public func stop() {
timer?.cancel()
timer = nil
}
private func tick() {
let startTime = DispatchTime.now()
if let enumerator = FileManager.default.enumerator(
at: url,
includingPropertiesForKeys: [.isRegularFileKey],
// NOTE: icloud downloading requires non-skipsHiddenFiles
options: [.skipsPackageDescendants]) {
var newMetaDb: [URL: SimpleFileMetadata] = [:]
for case let fileURL as URL in enumerator {
if !fileURL.isSkipped() {
if let meta = SimpleFileMetadata(of: fileURL) {
newMetaDb[fileURL] = meta
}
} else if fileURL.isICloudPlaceholder() {
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
}
}
self.updateMetaDb(with: 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?.recevedNotification(url, .Change, meta)
}
} else {
self.delegate?.recevedNotification(url, .Add, meta)
}
}
for url in self.metaDb.keys {
self.delegate?.recevedNotification(url, .Unlink, nil)
}
self.metaDb = newMetaDb
}
}

View File

@ -114,11 +114,11 @@
:else
(let [[old-path new-path]
(map #(if (or (util/electron?) (mobile-util/is-native-platform?))
%
(str (config/get-repo-dir repo) "/" %))
[old-path new-path])]
(protocol/rename! (get-fs old-path) repo old-path new-path))))
(map #(if (or (util/electron?) (mobile-util/is-native-platform?))
%
(str (config/get-repo-dir repo) "/" %))
[old-path new-path])]
(protocol/rename! (get-fs old-path) repo old-path new-path))))
(defn stat
[dir path]
@ -159,7 +159,7 @@
(defn watch-dir!
[dir]
(protocol/watch-dir! node-record dir))
(protocol/watch-dir! (get-record) dir))
(defn mkdir-if-not-exists
[dir]
@ -184,9 +184,9 @@
(p/let [_stat (stat dir path)]
true)
(p/catch
(fn [_error]
(p/let [_ (write-file! repo dir path initial-content nil)]
false)))))))
(fn [_error]
(p/let [_ (write-file! repo dir path initial-content nil)]
false)))))))
(defn file-exists?
[dir path]

View File

@ -16,6 +16,20 @@
[]
(.ensureDocuments mobile-util/ios-file-container)))
(when (mobile-util/native-ios?)
;; NOTE: avoid circular dependency
#_:clj-kondo/ignore
(def handle-changed! (delay frontend.fs.watcher-handler/handle-changed!))
(p/do!
(.addListener mobile-util/fs-watcher "watcher"
(fn [^js event]
(@handle-changed!
(.-event event)
(update (js->clj event :keywordize-keys true)
:path
js/decodeURI))))))
(defn check-permission-android []
(p/let [permission (.checkPermissions Filesystem)
permission (-> permission
@ -117,9 +131,9 @@
(log/error :write-file-failed error))))
(p/let [disk-content (-> (p/chain (.readFile Filesystem (clj->js {:path path
:encoding (.-UTF8 Encoding)}))
#(js->clj % :keywordize-keys true)
:data)
:encoding (.-UTF8 Encoding)}))
#(js->clj % :keywordize-keys true)
:data)
(p/catch (fn [error]
(js/console.error error)
nil)))
@ -216,30 +230,30 @@
(delete-file! [_this repo dir path {:keys [ok-handler error-handler]}]
(let [path (get-file-path dir path)]
(p/catch
(p/let [result (.deleteFile Filesystem
(clj->js
{:path path}))]
(when ok-handler
(ok-handler repo path result)))
(fn [error]
(if error-handler
(error-handler error)
(log/error :delete-file-failed error))))))
(p/let [result (.deleteFile Filesystem
(clj->js
{:path path}))]
(when ok-handler
(ok-handler repo path result)))
(fn [error]
(if error-handler
(error-handler error)
(log/error :delete-file-failed error))))))
(write-file! [this repo dir path content opts]
(let [path (get-file-path dir path)]
(p/let [stat (p/catch
(.stat Filesystem (clj->js {:path path}))
(fn [_e] :not-found))]
(.stat Filesystem (clj->js {:path path}))
(fn [_e] :not-found))]
(write-file-impl! this repo dir path content opts stat))))
(rename! [_this _repo old-path new-path]
(let [[old-path new-path] (map #(get-file-path "" %) [old-path new-path])]
(p/catch
(p/let [_ (.rename Filesystem
(clj->js
{:from old-path
:to new-path}))])
(fn [error]
(log/error :rename-file-failed error)))))
(p/let [_ (.rename Filesystem
(clj->js
{:from old-path
:to new-path}))])
(fn [error]
(log/error :rename-file-failed error)))))
(stat [_this dir path]
(let [path (get-file-path dir path)]
(p/let [result (.stat Filesystem (clj->js
@ -259,45 +273,8 @@
(into [] (concat [{:path path}] files))))
(get-files [_this path-or-handle _ok-handler]
(readdir path-or-handle))
(watch-dir! [_this _dir]
nil))
(comment
;;open-dir result
#_
["/storage/emulated/0/untitled folder 21"
{:type "file",
:size 2,
:mtime 1630049904000,
:uri "file:///storage/emulated/0/untitled%20folder%2021/pages/contents.md",
:ctime 1630049904000,
:content "-\n"}
{:type "file",
:size 0,
:mtime 1630049904000,
:uri "file:///storage/emulated/0/untitled%20folder%2021/logseq/custom.css",
:ctime 1630049904000,
:content ""}
{:type "file",
:size 2,
:mtime 1630049904000,
:uri "file:///storage/emulated/0/untitled%20folder%2021/logseq/metadata.edn",
:ctime 1630049904000,
:content "{}"}
{:type "file",
:size 181,
:mtime 1630050535000,
:uri
"file:///storage/emulated/0/untitled%20folder%2021/journals/2021_08_27.md",
:ctime 1630050535000,
:content
"- xx\n- xxx\n- xxx\n- xxxxxxxx\n- xxx\n- xzcxz\n- xzcxzc\n- asdsad\n- asdsadasda\n- asdsdaasdsad\n- asdasasdas\n- asdsad\n- sad\n- asd\n- asdsad\n- asdasd\n- sadsd\n-\n- asd\n- saddsa\n- asdsaasd\n- asd"}
{:type "file",
:size 132,
:mtime 1630311293000,
:uri
"file:///storage/emulated/0/untitled%20folder%2021/journals/2021_08_30.md",
:ctime 1630311293000,
:content
"- ccc\n- sadsa\n- sadasd\n- asdasd\n- asdasd\n\t- asdasd\n\t\t- asdasdsasd\n\t\t\t- sdsad\n\t\t-\n- sadasd\n- asdas\n- sadasd\n-\n-\n\t- sadasdasd\n\t- asdsd"}])
(watch-dir! [_this dir]
(when (mobile-util/native-ios?)
(p/do!
(.unwatch mobile-util/fs-watcher)
(.watch mobile-util/fs-watcher #js {:path dir})))))

View File

@ -314,10 +314,9 @@
(defn watch-for-current-graph-dir!
[]
(when (util/electron?)
(when-let [repo (state/get-current-repo)]
(when-let [dir (config/get-repo-dir repo)]
(fs/watch-dir! dir)))))
(when-let [repo (state/get-current-repo)]
(when-let [dir (config/get-repo-dir repo)]
(fs/watch-dir! dir))))
(defn create-metadata-file
[repo-url encrypted?]

View File

@ -179,8 +179,7 @@
(state/add-repo! {:url repo :nfs? true})
(state/set-loading-files! repo false)
(when ok-handler (ok-handler))
(when (util/electron?)
(fs/watch-dir! dir-name))
(fs/watch-dir! dir-name)
(db/persist-if-idle! repo)))))
(p/catch (fn [error]
(log/error :nfs/load-files-error repo)

View File

@ -22,9 +22,10 @@
(defonce folder-picker (registerPlugin "FolderPicker"))
(when (native-ios?)
(defonce download-icloud-files (registerPlugin "DownloadiCloudFiles")))
(when (native-ios?)
(defonce download-icloud-files (registerPlugin "DownloadiCloudFiles"))
(defonce ios-file-container (registerPlugin "FileContainer")))
(when (native-ios?)
(defonce fs-watcher (registerPlugin "FsWatcher")))
(defn sync-icloud-repo [repo-dir]
(let [repo-name (-> (string/split repo-dir "Documents/")
@ -32,7 +33,7 @@
string/trim
js/decodeURI)]
(.syncGraph download-icloud-files
(clj->js {:graph repo-name}))))
(clj->js {:graph repo-name}))))
(defn hide-splash []
(.hide SplashScreen))