FileSync.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. //
  2. // FileSync.swift
  3. // Logseq
  4. //
  5. // Created by Mono Wang on 2/24/R4.
  6. //
  7. import Capacitor
  8. import Foundation
  9. import AWSMobileClient
  10. import CryptoKit
  11. // MARK: Global variables
  12. // Defualts to dev
  13. var URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
  14. var BUCKET: String = "logseq-file-sync-bucket"
  15. var REGION: String = "us-east-2"
  16. public struct SyncMetadata: CustomStringConvertible, Equatable {
  17. var md5: String
  18. var size: Int
  19. public init?(of fileURL: URL) {
  20. do {
  21. let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .fileSizeKey])
  22. guard fileAttributes.isRegularFile! else {
  23. return nil
  24. }
  25. size = fileAttributes.fileSize ?? 0
  26. // incremental MD5sum
  27. let bufferSize = 1024 * 1024
  28. let file = try FileHandle(forReadingFrom: fileURL)
  29. defer {
  30. file.closeFile()
  31. }
  32. var ctx = Insecure.MD5.init()
  33. while autoreleasepool(invoking: {
  34. let data = file.readData(ofLength: bufferSize)
  35. if data.count > 0 {
  36. ctx.update(data: data)
  37. return true // continue
  38. } else {
  39. return false // eof
  40. }
  41. }) {}
  42. let computed = ctx.finalize()
  43. md5 = computed.map { String(format: "%02hhx", $0) }.joined()
  44. } catch {
  45. return nil
  46. }
  47. }
  48. public var description: String {
  49. return "SyncMetadata(md5=\(md5), size=\(size))"
  50. }
  51. }
  52. // MARK: FileSync Plugin
  53. @objc(FileSync)
  54. public class FileSync: CAPPlugin, SyncDebugDelegate {
  55. override public func load() {
  56. print("debug File sync iOS plugin loaded!")
  57. AWSMobileClient.default().initialize { (userState, error) in
  58. guard error == nil else {
  59. print("error initializing AWSMobileClient. Error: \(error!.localizedDescription)")
  60. return
  61. }
  62. }
  63. }
  64. // NOTE: for debug, or an activity indicator
  65. public func debugNotification(_ message: [String: Any]) {
  66. self.notifyListeners("debug", data: message)
  67. }
  68. @objc func setEnv(_ call: CAPPluginCall) {
  69. guard let env = call.getString("env") else {
  70. call.reject("required parameter: env")
  71. return
  72. }
  73. switch env {
  74. case "production", "product", "prod":
  75. URL_BASE = URL(string: "https://api-prod.logseq.com/file-sync/")!
  76. BUCKET = "logseq-file-sync-bucket-prod"
  77. REGION = "us-east-1"
  78. case "development", "develop", "dev":
  79. URL_BASE = URL(string: "https://api.logseq.com/file-sync/")!
  80. BUCKET = "logseq-file-sync-bucket"
  81. REGION = "us-east-2"
  82. default:
  83. call.reject("invalid env: \(env)")
  84. return
  85. }
  86. self.debugNotification(["event": "setenv:\(env)"])
  87. call.resolve(["ok": true])
  88. }
  89. @objc func getLocalFilesMeta(_ call: CAPPluginCall) {
  90. guard let basePath = call.getString("basePath"),
  91. let filePaths = call.getArray("filePaths") as? [String] else {
  92. call.reject("required paremeters: basePath, filePaths")
  93. return
  94. }
  95. guard let baseURL = URL(string: basePath) else {
  96. call.reject("invalid basePath")
  97. return
  98. }
  99. var fileMetadataDict: [String: [String: Any]] = [:]
  100. for filePath in filePaths {
  101. let url = baseURL.appendingPathComponent(filePath)
  102. if let meta = SyncMetadata(of: url) {
  103. fileMetadataDict[filePath] = ["md5": meta.md5,
  104. "size": meta.size]
  105. }
  106. }
  107. call.resolve(["result": fileMetadataDict])
  108. }
  109. @objc func getLocalAllFilesMeta(_ call: CAPPluginCall) {
  110. guard let basePath = call.getString("basePath"),
  111. let baseURL = URL(string: basePath) else {
  112. call.reject("invalid basePath")
  113. return
  114. }
  115. var fileMetadataDict: [String: [String: Any]] = [:]
  116. if let enumerator = FileManager.default.enumerator(at: baseURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsPackageDescendants, .skipsHiddenFiles]) {
  117. for case let fileURL as URL in enumerator {
  118. if !fileURL.isSkipped() {
  119. if let meta = SyncMetadata(of: fileURL) {
  120. fileMetadataDict[fileURL.relativePath(from: baseURL)!] = ["md5": meta.md5,
  121. "size": meta.size]
  122. }
  123. } else if fileURL.isICloudPlaceholder() {
  124. try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
  125. }
  126. }
  127. }
  128. call.resolve(["result": fileMetadataDict])
  129. }
  130. @objc func renameLocalFile(_ call: CAPPluginCall) {
  131. guard let basePath = call.getString("basePath"),
  132. let baseURL = URL(string: basePath) else {
  133. call.reject("invalid basePath")
  134. return
  135. }
  136. guard let from = call.getString("from") else {
  137. call.reject("invalid from file")
  138. return
  139. }
  140. guard let to = call.getString("to") else {
  141. call.reject("invalid to file")
  142. return
  143. }
  144. let fromUrl = baseURL.appendingPathComponent(from)
  145. let toUrl = baseURL.appendingPathComponent(to)
  146. do {
  147. try FileManager.default.moveItem(at: fromUrl, to: toUrl)
  148. } catch {
  149. call.reject("can not rename file: \(error.localizedDescription)")
  150. return
  151. }
  152. call.resolve(["ok": true])
  153. }
  154. @objc func deleteLocalFiles(_ call: CAPPluginCall) {
  155. guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
  156. let filePaths = call.getArray("filePaths") as? [String] else {
  157. call.reject("required paremeters: basePath, filePaths")
  158. return
  159. }
  160. for filePath in filePaths {
  161. let fileUrl = baseURL.appendingPathComponent(filePath)
  162. try? FileManager.default.removeItem(at: fileUrl) // ignore any delete errors
  163. }
  164. call.resolve(["ok": true])
  165. }
  166. /// remote -> local
  167. @objc func updateLocalFiles(_ call: CAPPluginCall) {
  168. guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
  169. let filePaths = call.getArray("filePaths") as? [String],
  170. let graphUUID = call.getString("graphUUID") ,
  171. let token = call.getString("token") else {
  172. call.reject("required paremeters: basePath, filePaths, graphUUID, token")
  173. return
  174. }
  175. let client = SyncClient(token: token, graphUUID: graphUUID)
  176. client.delegate = self // receives notification
  177. client.getFiles(at: filePaths) { (fileURLs, error) in
  178. if let error = error {
  179. print("debug getFiles error \(error)")
  180. self.debugNotification(["event": "download:error", "data": ["message": "error while getting files \(filePaths)"]])
  181. call.reject(error.localizedDescription)
  182. } else {
  183. // handle multiple completionHandlers
  184. let group = DispatchGroup()
  185. var downloaded: [String] = []
  186. for (filePath, remoteFileURL) in fileURLs {
  187. group.enter()
  188. // NOTE: fileURLs from getFiles API is percent-encoded
  189. let localFileURL = baseURL.appendingPathComponent(filePath.decodeFromFname())
  190. remoteFileURL.download(toFile: localFileURL) {error in
  191. if let error = error {
  192. self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]])
  193. print("debug download \(error) in \(filePath)")
  194. } else {
  195. self.debugNotification(["event": "download:file", "data": ["file": filePath]])
  196. downloaded.append(filePath)
  197. }
  198. group.leave()
  199. }
  200. }
  201. group.notify(queue: .main) {
  202. self.debugNotification(["event": "download:done"])
  203. call.resolve(["ok": true, "data": downloaded])
  204. }
  205. }
  206. }
  207. }
  208. @objc func deleteRemoteFiles(_ call: CAPPluginCall) {
  209. guard let filePaths = call.getArray("filePaths") as? [String],
  210. let graphUUID = call.getString("graphUUID"),
  211. let token = call.getString("token"),
  212. let txid = call.getInt("txid") else {
  213. call.reject("required paremeters: filePaths, graphUUID, token, txid")
  214. return
  215. }
  216. guard !filePaths.isEmpty else {
  217. call.reject("empty filePaths")
  218. return
  219. }
  220. let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
  221. client.deleteFiles(filePaths) { txid, error in
  222. guard error == nil else {
  223. call.reject("delete \(error!)")
  224. return
  225. }
  226. guard let txid = txid else {
  227. call.reject("missing txid")
  228. return
  229. }
  230. call.resolve(["ok": true, "txid": txid])
  231. }
  232. }
  233. /// local -> remote
  234. @objc func updateRemoteFiles(_ call: CAPPluginCall) {
  235. guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}),
  236. let filePaths = call.getArray("filePaths") as? [String],
  237. let graphUUID = call.getString("graphUUID"),
  238. let token = call.getString("token"),
  239. let txid = call.getInt("txid") else {
  240. call.reject("required paremeters: basePath, filePaths, graphUUID, token, txid")
  241. return
  242. }
  243. guard !filePaths.isEmpty else {
  244. return call.reject("empty filePaths")
  245. }
  246. print("debug begin updateRemoteFiles \(filePaths)")
  247. let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid)
  248. client.delegate = self
  249. // 1. refresh_temp_credential
  250. client.getTempCredential() { (credentials, error) in
  251. guard error == nil else {
  252. self.debugNotification(["event": "upload:error", "data": ["message": "error while refreshing credential: \(error!)"]])
  253. call.reject("error(getTempCredential): \(error!)")
  254. return
  255. }
  256. var files: [String: URL] = [:]
  257. for filePath in filePaths {
  258. // NOTE: filePath from js may contain spaces
  259. let fileURL = baseURL.appendingPathComponent(filePath)
  260. files[filePath.encodeAsFname()] = fileURL
  261. }
  262. // 2. upload_temp_file
  263. client.uploadTempFiles(files, credentials: credentials!) { (uploadedFileKeyDict, error) in
  264. guard error == nil else {
  265. self.debugNotification(["event": "upload:error", "data": ["message": "error while uploading temp files: \(error!)"]])
  266. call.reject("error(uploadTempFiles): \(error!)")
  267. return
  268. }
  269. // 3. update_files
  270. guard !uploadedFileKeyDict.isEmpty else {
  271. self.debugNotification(["event": "upload:error", "data": ["message": "no file to update"]])
  272. call.reject("no file to update")
  273. return
  274. }
  275. client.updateFiles(uploadedFileKeyDict) { (txid, error) in
  276. guard error == nil else {
  277. self.debugNotification(["event": "upload:error", "data": ["message": "error while updating files: \(error!)"]])
  278. call.reject("error updateFiles: \(error!)")
  279. return
  280. }
  281. guard let txid = txid else {
  282. call.reject("error: missing txid")
  283. return
  284. }
  285. self.debugNotification(["event": "upload:done", "data": ["files": filePaths, "txid": txid]])
  286. call.resolve(["ok": true, "files": uploadedFileKeyDict, "txid": txid])
  287. }
  288. }
  289. }
  290. }
  291. }