浏览代码

feat(ios): add polling based file watcher

Andelf 3 年之前
父节点
当前提交
b260648b60

+ 8 - 0
ios/App/App.xcodeproj/project.pbxproj

@@ -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;
 		};

+ 1 - 1
ios/App/App/DownloadiCloudFiles.m

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

+ 2 - 4
ios/App/App/FileContainer.swift

@@ -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 - 0
ios/App/App/FsWatcher.m

@@ -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 - 0
ios/App/App/FsWatcher.swift

@@ -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
+    }
+}

+ 9 - 9
src/main/frontend/fs.cljs

@@ -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]

+ 39 - 62
src/main/frontend/fs/capacitor_fs.cljs

@@ -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})))))

+ 3 - 4
src/main/frontend/handler/file.cljs

@@ -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?]

+ 1 - 2
src/main/frontend/handler/web/nfs.cljs

@@ -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)

+ 4 - 3
src/main/frontend/mobile/util.cljs

@@ -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))