diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index a05421eb5a..41d14012f7 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/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 = ""; }; D3D62A0B275C928F0003FBDC /* FileContainer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileContainer.m; sourceTree = ""; }; 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 = ""; }; + FE647FF327BDFEDE00F3206B /* FsWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FsWatcher.swift; sourceTree = ""; }; + FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = ""; }; /* 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; }; diff --git a/ios/App/App/DownloadiCloudFiles.m b/ios/App/App/DownloadiCloudFiles.m index d9f564731c..276318c13b 100644 --- a/ios/App/App/DownloadiCloudFiles.m +++ b/ios/App/App/DownloadiCloudFiles.m @@ -1,5 +1,5 @@ // -// DowloadiCloudFiles.m +// DownloadiCloudFiles.m // Logseq // // Created by leizhe on 2021/12/29. diff --git a/ios/App/App/FileContainer.swift b/ios/App/App/FileContainer.swift index 021794e5d7..6e8fbd0dc5 100644 --- a/ios/App/App/FileContainer.swift +++ b/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]) } } diff --git a/ios/App/App/FsWatcher.m b/ios/App/App/FsWatcher.m new file mode 100644 index 0000000000..b6354c58f2 --- /dev/null +++ b/ios/App/App/FsWatcher.m @@ -0,0 +1,13 @@ +// +// FsWatcher.m +// Logseq +// +// Created by Mono Wang on 2/17/R4. +// + +#import + +CAP_PLUGIN(FsWatcher, "FsWatcher", + CAP_PLUGIN_METHOD(watch, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(unwatch, CAPPluginReturnPromise); +) diff --git a/ios/App/App/FsWatcher.swift b/ios/App/App/FsWatcher.swift new file mode 100644 index 0000000000..8a607cc9e7 --- /dev/null +++ b/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 + } +} diff --git a/src/main/frontend/fs.cljs b/src/main/frontend/fs.cljs index ed14e0fb08..76afe6a818 100644 --- a/src/main/frontend/fs.cljs +++ b/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] diff --git a/src/main/frontend/fs/capacitor_fs.cljs b/src/main/frontend/fs/capacitor_fs.cljs index 5f769a7eba..51364d8c2d 100644 --- a/src/main/frontend/fs/capacitor_fs.cljs +++ b/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}))))) diff --git a/src/main/frontend/handler/file.cljs b/src/main/frontend/handler/file.cljs index e9743039aa..e80fd00d9d 100644 --- a/src/main/frontend/handler/file.cljs +++ b/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?] diff --git a/src/main/frontend/handler/web/nfs.cljs b/src/main/frontend/handler/web/nfs.cljs index 042509f0a8..4fb3b9b724 100644 --- a/src/main/frontend/handler/web/nfs.cljs +++ b/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) diff --git a/src/main/frontend/mobile/util.cljs b/src/main/frontend/mobile/util.cljs index 0652f5d27f..bda00dc019 100644 --- a/src/main/frontend/mobile/util.cljs +++ b/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))