From 2a73019b346e7bcf4cf7e8cb5d99a0357c096a03 Mon Sep 17 00:00:00 2001 From: Andelf Date: Fri, 16 Sep 2022 15:54:16 +0800 Subject: [PATCH] refactor(mobile): use ios file-sync as lib --- .gitignore | 1 + android/app/capacitor.build.gradle | 1 + .../app/src/main/assets/capacitor.config.json | 25 - .../src/main/assets/capacitor.plugins.json | 4 + android/capacitor.settings.gradle | 3 + capacitor.config.ts | 5 + ios/App/App.xcodeproj/project.pbxproj | 32 - ios/App/App/FileSync/AgeEncryption.swift | 110 --- ios/App/App/FileSync/Extensions.swift | 172 ----- ios/App/App/FileSync/FileSync.m | 25 - ios/App/App/FileSync/FileSync.swift | 635 ------------------ ios/App/App/FileSync/Payload.swift | 36 - ios/App/App/FileSync/SyncClient.swift | 461 ------------- ios/App/Podfile | 7 +- package.json | 1 + src/main/frontend/mobile/util.cljs | 10 +- yarn.lock | 5 + 17 files changed, 28 insertions(+), 1505 deletions(-) delete mode 100644 android/app/src/main/assets/capacitor.config.json delete mode 100644 ios/App/App/FileSync/AgeEncryption.swift delete mode 100644 ios/App/App/FileSync/Extensions.swift delete mode 100644 ios/App/App/FileSync/FileSync.m delete mode 100644 ios/App/App/FileSync/FileSync.swift delete mode 100644 ios/App/App/FileSync/Payload.swift delete mode 100644 ios/App/App/FileSync/SyncClient.swift diff --git a/.gitignore b/.gitignore index b40ad5ac6c..0ed40f8848 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ startup.png ~*~ ios/App/App/capacitor.config.json +android/app/src/main/assets/capacitor.config.json diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 7009748af5..f2ff2659fb 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(':capacitor-share') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') + implementation project(':logseq-capacitor-file-sync') implementation project(':capacitor-voice-recorder') implementation project(':send-intent') diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json deleted file mode 100644 index e072514d16..0000000000 --- a/android/app/src/main/assets/capacitor.config.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "appId": "com.logseq.app", - "appName": "Logseq", - "bundledWebRuntime": false, - "webDir": "public", - "plugins": { - "SplashScreen": { - "launchShowDuration": 500, - "launchAutoHide": false, - "androidScaleType": "CENTER_CROP", - "splashImmersive": false, - "backgroundColor": "#002b36" - }, - "Keyboard": { - "resize": "none" - } - }, - "ios": { - "scheme": "Logseq" - }, - "server": { - "url": "http://192.168.199.241:3001", - "cleartext": true - } -} diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index b6e83cb578..ebb480c7cf 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -35,6 +35,10 @@ "pkg": "@capacitor/status-bar", "classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin" }, + { + "pkg": "@logseq/capacitor-file-sync", + "classpath": "com.logseq.app.FileSyncPlugin" + }, { "pkg": "capacitor-voice-recorder", "classpath": "com.tchvu3.capacitorvoicerecorder.VoiceRecorder" diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index f058027aea..677ac8db97 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -29,6 +29,9 @@ project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capa include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') +include ':logseq-capacitor-file-sync' +project(':logseq-capacitor-file-sync').projectDir = new File('../node_modules/@logseq/capacitor-file-sync/android') + include ':capacitor-voice-recorder' project(':capacitor-voice-recorder').projectDir = new File('../node_modules/capacitor-voice-recorder/android') diff --git a/capacitor.config.ts b/capacitor.config.ts index b097fa7f7b..c4dcedf814 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -20,6 +20,11 @@ const config: CapacitorConfig = { }, ios: { scheme: 'Logseq' + }, + cordova: { + staticPlugins: [ + '@logseq/capacitor-file-sync', // AgeEncryption requires static link + ] } } diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 6cb548a926..80401f6d28 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -25,14 +25,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 */; }; - FE443F1C27FF5420007ECE65 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443F1B27FF5420007ECE65 /* Extensions.swift */; }; - FE443F1E27FF54AA007ECE65 /* Payload.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443F1D27FF54AA007ECE65 /* Payload.swift */; }; - FE443F2027FF54C9007ECE65 /* SyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE443F1F27FF54C9007ECE65 /* SyncClient.swift */; }; FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF327BDFEDE00F3206B /* FsWatcher.swift */; }; FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; }; - FE8C946B27FD762700C8017B /* FileSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE8C946927FD762700C8017B /* FileSync.swift */; }; - FE8C946C27FD762700C8017B /* FileSync.m in Sources */ = {isa = PBXBuildFile; fileRef = FE8C946A27FD762700C8017B /* FileSync.m */; }; - FEE688A428448F8C0019510E /* AgeEncryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEE688A328448F8C0019510E /* AgeEncryption.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,14 +81,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 = ""; }; - FE443F1B27FF5420007ECE65 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; - FE443F1D27FF54AA007ECE65 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = ""; }; - FE443F1F27FF54C9007ECE65 /* SyncClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncClient.swift; 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 = ""; }; - FE8C946927FD762700C8017B /* FileSync.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileSync.swift; sourceTree = ""; }; - FE8C946A27FD762700C8017B /* FileSync.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileSync.m; sourceTree = ""; }; - FEE688A328448F8C0019510E /* AgeEncryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeEncryption.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -154,7 +142,6 @@ 50B271D01FEDC1A000F3C39B /* public */, 7435D10B2704659F00AB88E0 /* FolderPicker.swift */, FE647FF327BDFEDE00F3206B /* FsWatcher.swift */, - FE443F1A27FF53A2007ECE65 /* FileSync */, FE647FF527BDFEF500F3206B /* FsWatcher.m */, 7435D10E2704660B00AB88E0 /* FolderPicker.m */, D3D62A09275C92880003FBDC /* FileContainer.swift */, @@ -193,19 +180,6 @@ path = Pods; sourceTree = ""; }; - FE443F1A27FF53A2007ECE65 /* FileSync */ = { - isa = PBXGroup; - children = ( - FE8C946927FD762700C8017B /* FileSync.swift */, - FE443F1F27FF54C9007ECE65 /* SyncClient.swift */, - FE443F1D27FF54AA007ECE65 /* Payload.swift */, - FE443F1B27FF5420007ECE65 /* Extensions.swift */, - FE8C946A27FD762700C8017B /* FileSync.m */, - FEE688A328448F8C0019510E /* AgeEncryption.swift */, - ); - path = FileSync; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -356,20 +330,14 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, - FE443F1E27FF54AA007ECE65 /* Payload.swift in Sources */, 5FF8632C283B5BFD0047731B /* Utils.m in Sources */, - FEE688A428448F8C0019510E /* AgeEncryption.swift in Sources */, - FE8C946B27FD762700C8017B /* FileSync.swift in Sources */, FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */, 5FF8632A283B5ADB0047731B /* Utils.swift in Sources */, D3D62A0A275C92880003FBDC /* FileContainer.swift in Sources */, D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */, - FE443F1C27FF5420007ECE65 /* Extensions.swift in Sources */, - FE8C946C27FD762700C8017B /* FileSync.m in Sources */, 7435D10F2704660B00AB88E0 /* FolderPicker.m in Sources */, 7435D10C2704659F00AB88E0 /* FolderPicker.swift in Sources */, FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */, - FE443F2027FF54C9007ECE65 /* SyncClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/FileSync/AgeEncryption.swift b/ios/App/App/FileSync/AgeEncryption.swift deleted file mode 100644 index a0b5cbba6e..0000000000 --- a/ios/App/App/FileSync/AgeEncryption.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// AgeEncryption.swift -// Logseq -// -// Created by Mono Wang on 5/30/R4. -// - -import Foundation -import AgeEncryption - -public enum AgeEncryption { - public static func keygen() -> (String, String) { - let cSecretKey = UnsafeMutablePointer?>.allocate(capacity: 1) - let cPublicKey = UnsafeMutablePointer?>.allocate(capacity: 1) - - rust_age_encryption_keygen(cSecretKey, cPublicKey); - let secretKey = String(cString: cSecretKey.pointee!) - let publicKey = String(cString: cPublicKey.pointee!) - - rust_age_encryption_free_str(cSecretKey.pointee!) - rust_age_encryption_free_str(cPublicKey.pointee!) - cSecretKey.deallocate() - cPublicKey.deallocate() - - return (secretKey, publicKey) - } - - public static func toRawX25519Key(_ secretKey: String) -> Data? { - let cOutput = UnsafeMutablePointer?>.allocate(capacity: 1) - - let ret = rust_age_encryption_to_raw_x25519_key(secretKey.cString(using: .utf8), cOutput) - if ret >= 0 { - let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: 32) - let rawKey = Data.init(buffer: cOutputBuf) - rust_age_encryption_free_vec(cOutput.pointee, ret) - cOutput.deallocate() - return rawKey - } else { - return nil - } - } - - public static func encryptWithPassphrase(_ plaintext: Data, _ passphrase: String, armor: Bool) -> Data? { - plaintext.withUnsafeBytes { (cPlaintext) in - let cOutput = UnsafeMutablePointer?>.allocate(capacity: 1) - - let ret = rust_age_encrypt_with_user_passphrase(passphrase.cString(using: .utf8), cPlaintext.bindMemory(to: CChar.self).baseAddress, Int32(plaintext.count), armor ? 1 : 0, cOutput) - if ret > 0 { - let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret)) - let ciphertext = Data.init(buffer: cOutputBuf) - rust_age_encryption_free_vec(cOutput.pointee, ret) - cOutput.deallocate() - return ciphertext - } else { - return nil - } - } - } - - public static func decryptWithPassphrase(_ ciphertext: Data, _ passphrase: String) -> Data? { - ciphertext.withUnsafeBytes { (cCiphertext) in - let cOutput = UnsafeMutablePointer?>.allocate(capacity: 1) - - let ret = rust_age_decrypt_with_user_passphrase(passphrase.cString(using: .utf8), cCiphertext.bindMemory(to: CChar.self).baseAddress, Int32(ciphertext.count), cOutput) - if ret > 0 { - let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret)) - let plaintext = Data.init(buffer: cOutputBuf) - rust_age_encryption_free_vec(cOutput.pointee, ret) - cOutput.deallocate() - return plaintext - } else { - return nil - } - } - } - - public static func encryptWithX25519(_ plaintext: Data, _ publicKey: String, armor: Bool) -> Data? { - plaintext.withUnsafeBytes { (cPlaintext) in - let cOutput = UnsafeMutablePointer?>.allocate(capacity: 1) - - let ret = rust_age_encrypt_with_x25519(publicKey.cString(using: .utf8), cPlaintext.bindMemory(to: CChar.self).baseAddress, Int32(plaintext.count), armor ? 1 : 0, cOutput) - if ret > 0 { - let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret)) - let ciphertext = Data.init(buffer: cOutputBuf) - rust_age_encryption_free_vec(cOutput.pointee, ret) - cOutput.deallocate() - return ciphertext - } else { - return nil - } - } - } - - public static func decryptWithX25519(_ ciphertext: Data, _ secretKey: String) -> Data? { - ciphertext.withUnsafeBytes { (cCiphertext) in - let cOutput = UnsafeMutablePointer?>.allocate(capacity: 1) - - let ret = rust_age_decrypt_with_x25519(secretKey.cString(using: .utf8), cCiphertext.bindMemory(to: CChar.self).baseAddress, Int32(ciphertext.count), cOutput) - if ret >= 0 { - let cOutputBuf = UnsafeBufferPointer.init(start: cOutput.pointee, count: Int(ret)) - let plaintext = Data.init(buffer: cOutputBuf) - rust_age_encryption_free_vec(cOutput.pointee, ret) - cOutput.deallocate() - return plaintext - } else { - return nil - } - } - } -} diff --git a/ios/App/App/FileSync/Extensions.swift b/ios/App/App/FileSync/Extensions.swift deleted file mode 100644 index 0f452fb41d..0000000000 --- a/ios/App/App/FileSync/Extensions.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// Extensions.swift -// Logseq -// -// Created by Mono Wang on 4/8/R4. -// - -import Foundation -import CryptoKit - -// via https://github.com/krzyzanowskim/CryptoSwift -extension Array where Element == UInt8 { - public init(hex: String) { - self = Array.init() - self.reserveCapacity(hex.unicodeScalars.lazy.underestimatedCount) - var buffer: UInt8? - var skip = hex.hasPrefix("0x") ? 2 : 0 - for char in hex.unicodeScalars.lazy { - guard skip == 0 else { - skip -= 1 - continue - } - guard char.value >= 48 && char.value <= 102 else { - removeAll() - return - } - let v: UInt8 - let c: UInt8 = UInt8(char.value) - switch c { - case let c where c <= 57: - v = c - 48 - case let c where c >= 65 && c <= 70: - v = c - 55 - case let c where c >= 97: - v = c - 87 - default: - removeAll() - return - } - if let b = buffer { - append(b << 4 | v) - buffer = nil - } else { - buffer = v - } - } - if let b = buffer { - append(b) - } - } -} - -extension Data { - public init?(hexEncoded: String) { - self.init(Array(hex: hexEncoded)) - } - - var hexDescription: String { - return map { String(format: "%02hhx", $0) }.joined() - } - - var MD5: String { - let computed = Insecure.MD5.hash(data: self) - return computed.map { String(format: "%02hhx", $0) }.joined() - } -} - -extension String { - var MD5: String { - let computed = Insecure.MD5.hash(data: self.data(using: .utf8)!) - return computed.map { String(format: "%02hhx", $0) }.joined() - } - - static func random(length: Int) -> String { - let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return String((0.. String? { - guard !self.isEmpty else { - return nil - } - guard let raw = self.data(using: .utf8) else { - return nil - } - - let key = SymmetricKey(data: rawKey) - let nonce = try! ChaChaPoly.Nonce(data: Data(repeating: 0, count: 12)) - guard let sealed = try? ChaChaPoly.seal(raw, using: key, nonce: nonce) else { return nil } - - // strip nonce here, since it's all zero - return "e." + (sealed.ciphertext + sealed.tag).hexDescription - - } - - func fnameDecrypt(rawKey: Data) -> String? { - // well-formated, non-empty encrypted string - guard self.hasPrefix("e.") && self.count > 36 else { - return nil - } - - let encryptedHex = self.suffix(from: self.index(self.startIndex, offsetBy: 2)) - guard let encryptedRaw = Data(hexEncoded: String(encryptedHex)) else { - // invalid hex - return nil - } - - let key = SymmetricKey(data: rawKey) - let nonce = Data(repeating: 0, count: 12) - - guard let sealed = try? ChaChaPoly.SealedBox(combined: nonce + encryptedRaw) else { - return nil - } - guard let outputRaw = try? ChaChaPoly.open(sealed, using: key) else { - return nil - } - return String(data: outputRaw, encoding: .utf8) - } -} - -extension URL { - func relativePath(from base: URL) -> String? { - // Ensure that both URLs represent files: - guard self.isFileURL && base.isFileURL else { - return nil - } - - // Remove/replace "." and "..", make paths absolute: - let destComponents = self.standardized.pathComponents - let baseComponents = base.standardized.pathComponents - - // 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: "/") - } - - func ensureParentDir() { - let dirURL = self.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true, attributes: nil) - } - - func writeData(data: Data) throws { - self.ensureParentDir() - if FileManager.default.fileExists(atPath: self.path) { - try FileManager.default.removeItem(at: self) - } - try data.write(to: self, options: .atomic) - } -} - -// MARK: Crypto helper - -extension SymmetricKey { - public init(passwordString keyString: String) throws { - guard let keyData = keyString.data(using: .utf8) else { - print("ERROR: Could not create raw Data from String") - throw CryptoKitError.incorrectParameterSize - } - // SymmetricKeySize.bits256 - let keyDigest = SHA256.hash(data: keyData) - - self.init(data: keyDigest) - } -} diff --git a/ios/App/App/FileSync/FileSync.m b/ios/App/App/FileSync/FileSync.m deleted file mode 100644 index 2804f17b37..0000000000 --- a/ios/App/App/FileSync/FileSync.m +++ /dev/null @@ -1,25 +0,0 @@ -// -// FileSync.m -// Logseq -// -// Created by Mono Wang on 2/24/R4. -// - -#import - -CAP_PLUGIN(FileSync, "FileSync", - CAP_PLUGIN_METHOD(setEnv, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(keygen, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(getLocalFilesMeta, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(getLocalAllFilesMeta, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(renameLocalFile, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(deleteLocalFiles, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(updateLocalFiles, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(deleteRemoteFiles, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(updateRemoteFiles, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(encryptFnames, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(decryptFnames, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(decryptWithPassphrase, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(encryptWithPassphrase, CAPPluginReturnPromise); - CAP_PLUGIN_METHOD(updateLocalVersionFiles, CAPPluginReturnPromise); -) diff --git a/ios/App/App/FileSync/FileSync.swift b/ios/App/App/FileSync/FileSync.swift deleted file mode 100644 index 916fa87bf6..0000000000 --- a/ios/App/App/FileSync/FileSync.swift +++ /dev/null @@ -1,635 +0,0 @@ -// -// FileSync.swift -// Logseq -// -// Created by Mono Wang on 2/24/R4. -// - -import Capacitor -import Foundation -import AWSMobileClient -import CryptoKit - - -// MARK: Global variable - -// Defualts to dev -var URL_BASE = URL(string: "https://api-dev.logseq.com/file-sync/")! -var BUCKET: String = "logseq-file-sync-bucket" -var REGION: String = "us-east-2" - -var ENCRYPTION_SECRET_KEY: String? = nil -var ENCRYPTION_PUBLIC_KEY: String? = nil -var FNAME_ENCRYPTION_KEY: Data? = nil - -let FileSyncErrorDomain = "com.logseq.app.FileSyncErrorDomain" - -// MARK: Helpers -@inline(__always) func fnameEncryptionEnabled() -> Bool { - guard let _ = FNAME_ENCRYPTION_KEY else { - return false - } - return true -} - -// MARK: encryption helper - -func maybeEncrypt(_ plaindata: Data) -> Data! { - // avoid encryption twice - if plaindata.starts(with: "-----BEGIN AGE ENCRYPTED FILE-----".data(using: .utf8)!) || - plaindata.starts(with: "age-encryption.org/v1\n".data(using: .utf8)!) { - return plaindata - } - if let publicKey = ENCRYPTION_PUBLIC_KEY { - // use armor = false, for smaller size - if let cipherdata = AgeEncryption.encryptWithX25519(plaindata, publicKey, armor: true) { - return cipherdata - } - return nil // encryption fail - } - return plaindata -} - -func maybeDecrypt(_ cipherdata: Data) -> Data! { - if let secretKey = ENCRYPTION_SECRET_KEY { - if cipherdata.starts(with: "-----BEGIN AGE ENCRYPTED FILE-----".data(using: .utf8)!) || - cipherdata.starts(with: "age-encryption.org/v1\n".data(using: .utf8)!) { - if let plaindata = AgeEncryption.decryptWithX25519(cipherdata, secretKey) { - return plaindata - } - return nil - } - // not an encrypted file - return cipherdata - } - return cipherdata -} - -// MARK: Metadata type - -public struct SyncMetadata: CustomStringConvertible, Equatable { - var md5: String - var size: Int - var ctime: Int64 - var mtime: Int64 - - public init?(of fileURL: URL) { - do { - let fileAttributes = try fileURL.resourceValues(forKeys:[.isRegularFileKey, .fileSizeKey, .contentModificationDateKey, - .creationDateKey]) - guard fileAttributes.isRegularFile! else { - return nil - } - size = fileAttributes.fileSize ?? 0 - mtime = Int64((fileAttributes.contentModificationDate?.timeIntervalSince1970 ?? 0.0) * 1000) - ctime = Int64((fileAttributes.creationDate?.timeIntervalSince1970 ?? 0.0) * 1000) - - // incremental MD5 checksum - let bufferSize = 512 * 1024 - let file = try FileHandle(forReadingFrom: fileURL) - defer { - file.closeFile() - } - var ctx = Insecure.MD5.init() - while autoreleasepool(invoking: { - let data = file.readData(ofLength: bufferSize) - if data.count > 0 { - ctx.update(data: data) - return true // continue - } else { - return false // eof - } - }) {} - - let computed = ctx.finalize() - md5 = computed.map { String(format: "%02hhx", $0) }.joined() - } catch { - return nil - } - } - - public var description: String { - return "SyncMetadata(md5=\(md5), size=\(size), mtime=\(mtime))" - } -} - -// MARK: FileSync Plugin - -@objc(FileSync) -public class FileSync: CAPPlugin, SyncDebugDelegate { - override public func load() { - print("debug FileSync iOS plugin loaded!") - - AWSMobileClient.default().initialize { (userState, error) in - guard error == nil else { - print("error initializing AWSMobileClient. Error: \(error!.localizedDescription)") - return - } - } - } - - // NOTE: for debug, or an activity indicator - public func debugNotification(_ message: [String: Any]) { - self.notifyListeners("debug", data: message) - } - - @objc func keygen(_ call: CAPPluginCall) { - let (secretKey, publicKey) = AgeEncryption.keygen() - call.resolve(["secretKey": secretKey, - "publicKey": publicKey]) - } - - @objc func setKey(_ call: CAPPluginCall) { - let secretKey = call.getString("secretKey") - let publicKey = call.getString("publicKey") - if secretKey == nil && publicKey == nil { - ENCRYPTION_SECRET_KEY = nil - ENCRYPTION_PUBLIC_KEY = nil - FNAME_ENCRYPTION_KEY = nil - return - } - guard let secretKey = secretKey, let publicKey = publicKey else { - call.reject("both secretKey and publicKey should be provided") - return - } - ENCRYPTION_SECRET_KEY = secretKey - ENCRYPTION_PUBLIC_KEY = publicKey - FNAME_ENCRYPTION_KEY = AgeEncryption.toRawX25519Key(secretKey) - - } - - @objc func setEnv(_ call: CAPPluginCall) { - guard let env = call.getString("env") else { - call.reject("required parameter: env") - return - } - self.setKey(call) - - switch env { - case "production", "product", "prod": - URL_BASE = URL(string: "https://api.logseq.com/file-sync/")! - BUCKET = "logseq-file-sync-bucket-prod" - REGION = "us-east-1" - case "development", "develop", "dev": - URL_BASE = URL(string: "https://api-dev.logseq.com/file-sync/")! - BUCKET = "logseq-file-sync-bucket" - REGION = "us-east-2" - default: - call.reject("invalid env: \(env)") - return - } - - self.debugNotification(["event": "setenv:\(env)"]) - call.resolve(["ok": true]) - } - - @objc func encryptFnames(_ call: CAPPluginCall) { - guard fnameEncryptionEnabled() else { - call.reject("fname encryption key not set") - return - } - guard var fnames = call.getArray("filePaths") as? [String] else { - call.reject("required parameters: filePaths") - return - } - - let nFiles = fnames.count - fnames = fnames.compactMap { $0.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) } - if fnames.count != nFiles { - call.reject("cannot encrypt \(nFiles - fnames.count) file names") - } - call.resolve(["value": fnames]) - } - - @objc func decryptFnames(_ call: CAPPluginCall) { - guard fnameEncryptionEnabled() else { - call.reject("fname encryption key not set") - return - } - guard var fnames = call.getArray("filePaths") as? [String] else { - call.reject("required parameters: filePaths") - return - } - let nFiles = fnames.count - fnames = fnames.compactMap { $0.fnameDecrypt(rawKey: FNAME_ENCRYPTION_KEY!)?.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) } - if fnames.count != nFiles { - call.reject("cannot decrypt \(nFiles - fnames.count) file names") - } - call.resolve(["value": fnames]) - } - - @objc func encryptWithPassphrase(_ call: CAPPluginCall) { - guard let passphrase = call.getString("passphrase"), - let content = call.getString("content") else { - call.reject("required parameters: passphrase, content") - return - } - guard let plaintext = content.data(using: .utf8) else { - call.reject("cannot decode ciphertext with utf8") - return - } - self.bridge?.saveCall(call) - DispatchQueue.global(qos: .default).async { - if let encrypted = AgeEncryption.encryptWithPassphrase(plaintext, passphrase, armor: true) { - call.resolve(["data": String(data: encrypted, encoding: .utf8) as Any]) - } else { - call.reject("cannot encrypt with passphrase") - } - self.bridge?.releaseCall(call) - } - } - - - @objc func decryptWithPassphrase(_ call: CAPPluginCall) { - guard let passphrase = call.getString("passphrase"), - let content = call.getString("content") else { - call.reject("required parameters: passphrase, content") - return - } - guard let ciphertext = content.data(using: .utf8) else { - call.reject("cannot decode ciphertext with utf8") - return - } - self.bridge?.saveCall(call) - DispatchQueue.global(qos: .default).async { - if let decrypted = AgeEncryption.decryptWithPassphrase(ciphertext, passphrase) { - call.resolve(["data": String(data: decrypted, encoding: .utf8) as Any]) - } else { - call.reject("cannot decrypt with passphrase") - } - self.bridge?.releaseCall(call) - } - } - - @objc func getLocalFilesMeta(_ call: CAPPluginCall) { - // filePaths are url encoded - guard let basePath = call.getString("basePath"), - let filePaths = call.getArray("filePaths") as? [String] else { - call.reject("required paremeters: basePath, filePaths") - return - } - guard let baseURL = URL(string: basePath) else { - call.reject("invalid basePath") - return - } - - var fileMetadataDict: [String: [String: Any]] = [:] - for percentFilePath in filePaths { - let filePath = percentFilePath.removingPercentEncoding! - let url = baseURL.appendingPathComponent(filePath) - if let meta = SyncMetadata(of: url) { - var metaObj: [String: Any] = ["md5": meta.md5, - "size": meta.size, - "mtime": meta.mtime] - if fnameEncryptionEnabled() { - metaObj["encryptedFname"] = filePath.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) - } - - fileMetadataDict[percentFilePath] = metaObj - } - } - - call.resolve(["result": fileMetadataDict]) - } - - @objc func getLocalAllFilesMeta(_ call: CAPPluginCall) { - guard let basePath = call.getString("basePath"), - let baseURL = URL(string: basePath) else { - call.reject("invalid basePath") - return - } - - var fileMetadataDict: [String: [String: Any]] = [:] - if let enumerator = FileManager.default.enumerator(at: baseURL, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsPackageDescendants, .skipsHiddenFiles]) { - - for case let fileURL as URL in enumerator { - if !fileURL.isSkipped() { - if let meta = SyncMetadata(of: fileURL) { - let filePath = fileURL.relativePath(from: baseURL)! - var metaObj: [String: Any] = ["md5": meta.md5, - "size": meta.size, - "mtime": meta.mtime] - if fnameEncryptionEnabled() { - metaObj["encryptedFname"] = filePath.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) - } - fileMetadataDict[filePath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!] = metaObj - } - } else if fileURL.isICloudPlaceholder() { - try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL) - } - } - } - call.resolve(["result": fileMetadataDict]) - } - - - @objc func renameLocalFile(_ call: CAPPluginCall) { - guard let basePath = call.getString("basePath"), - let baseURL = URL(string: basePath) else { - call.reject("invalid basePath") - return - } - guard let from = call.getString("from") else { - call.reject("invalid from file") - return - } - guard let to = call.getString("to") else { - call.reject("invalid to file") - return - } - - let fromUrl = baseURL.appendingPathComponent(from.removingPercentEncoding!) - let toUrl = baseURL.appendingPathComponent(to.removingPercentEncoding!) - - do { - try FileManager.default.moveItem(at: fromUrl, to: toUrl) - } catch { - call.reject("can not rename file: \(error.localizedDescription)") - return - } - call.resolve(["ok": true]) - - } - - @objc func deleteLocalFiles(_ call: CAPPluginCall) { - guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}), - let filePaths = call.getArray("filePaths") as? [String] else { - call.reject("required paremeters: basePath, filePaths") - return - } - - for filePath in filePaths { - let fileUrl = baseURL.appendingPathComponent(filePath.removingPercentEncoding!) - try? FileManager.default.removeItem(at: fileUrl) // ignore any delete errors - } - call.resolve(["ok": true]) - } - - /// remote -> local - @objc func updateLocalFiles(_ call: CAPPluginCall) { - guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}), - let filePaths = call.getArray("filePaths") as? [String], - let graphUUID = call.getString("graphUUID") , - let token = call.getString("token") else { - call.reject("required paremeters: basePath, filePaths, graphUUID, token") - return - } - - // [encrypted-fname: original-fname] - var encryptedFilePathDict: [String: String] = [:] - if fnameEncryptionEnabled() { - for filePath in filePaths { - if let encryptedPath = filePath.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) { - encryptedFilePathDict[encryptedPath] = filePath - } else { - call.reject("cannot decrypt all file names") - } - } - } else { - encryptedFilePathDict = Dictionary(uniqueKeysWithValues: filePaths.map { ($0, $0) }) - } - - let encryptedFilePaths = Array(encryptedFilePathDict.keys) - - let client = SyncClient(token: token, graphUUID: graphUUID) - client.delegate = self // receives notification - - client.getFiles(at: encryptedFilePaths) { (fileURLs, error) in - guard error == nil else { - print("debug getFiles error \(String(describing: error))") - self.debugNotification(["event": "download:error", "data": ["message": "error while getting files \(filePaths)"]]) - call.reject(error!.localizedDescription) - return - } - // handle multiple completionHandlers - let group = DispatchGroup() - - var downloaded: [String] = [] - - for (encryptedFilePath, remoteFileURL) in fileURLs { - group.enter() - - let filePath = encryptedFilePathDict[encryptedFilePath]! - // NOTE: fileURLs from getFiles API is percent-encoded - let localFileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!) - - let progressHandler = {(fraction: Double) in - self.debugNotification(["event": "download:progress", - "data": ["file": filePath, - "fraction": fraction]]) - } - - client.download(url: remoteFileURL, progressHandler: progressHandler) {result in - switch result { - case .failure(let error): - self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]]) - print("debug download \(error) in \(filePath)") - case .success(let tempURL): - do { - let rawData = try Data(contentsOf: tempURL!) - guard let decryptedRawData = maybeDecrypt(rawData) else { - throw NSError(domain: FileSyncErrorDomain, - code: 0, - userInfo: [NSLocalizedDescriptionKey: "can not decrypt downloaded file"]) - } - try localFileURL.writeData(data: decryptedRawData) - self.debugNotification(["event": "download:file", "data": ["file": filePath]]) - downloaded.append(filePath) - } catch { - // Handle potential file system errors - self.debugNotification(["event": "download:error", "data": ["message": "error while downloading \(filePath): \(error)"]]) - print("debug download \(error) in \(filePath)") - } - } - - group.leave() - } - } - group.notify(queue: .main) { - self.debugNotification(["event": "download:done"]) - call.resolve(["ok": true, "data": downloaded]) - } - } - } - - @objc func updateLocalVersionFiles(_ call: CAPPluginCall) { - guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}), - let filePaths = call.getArray("filePaths") as? [String], - let graphUUID = call.getString("graphUUID") , - let token = call.getString("token") else { - call.reject("required paremeters: basePath, filePaths, graphUUID, token") - return - } - let client = SyncClient(token: token, graphUUID: graphUUID) - client.delegate = self // receives notification - - client.getVersionFiles(at: filePaths) { (fileURLDict, error) in - if let error = error { - print("debug getVersionFiles error \(error)") - call.reject(error.localizedDescription) - } else { - // handle multiple completionHandlers - let group = DispatchGroup() - - var downloaded: [String] = [] - for (filePath, remoteFileURL) in fileURLDict { - group.enter() - - // NOTE: fileURLs from getFiles API is percent-encoded - let localFileURL = baseURL.appendingPathComponent("logseq/version-files/").appendingPathComponent(filePath.removingPercentEncoding!) - // empty progress handler - let progressHandler = {(fraction: Double) in - } - - client.download(url: remoteFileURL, progressHandler: progressHandler) {result in - switch result { - case .failure(let error): - print("debug download \(error) in \(filePath)") - case .success(let tempURL): - do { - let rawData = try Data(contentsOf: tempURL!) - guard let decryptedRawData = maybeDecrypt(rawData) else { - throw NSError(domain: FileSyncErrorDomain, - code: 0, - userInfo: [NSLocalizedDescriptionKey: "can not decrypt remote file"]) - } - try localFileURL.writeData(data: decryptedRawData) - downloaded.append(filePath) - } catch { - print(error) - } - } - group.leave() - } - } - group.notify(queue: .main) { - call.resolve(["ok": true, "data": downloaded]) - } - - } - } - } - - // filePaths: Encrypted file paths - @objc func deleteRemoteFiles(_ call: CAPPluginCall) { - guard var filePaths = call.getArray("filePaths") as? [String], - let graphUUID = call.getString("graphUUID"), - let token = call.getString("token"), - let txid = call.getInt("txid") else { - call.reject("required paremeters: filePaths, graphUUID, token, txid") - return - } - guard !filePaths.isEmpty else { - call.reject("empty filePaths") - return - } - - let nFiles = filePaths.count - if fnameEncryptionEnabled() { - filePaths = filePaths.compactMap { $0.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) } - } - if filePaths.count != nFiles { - call.reject("cannot encrypt all file names") - } - - let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid) - client.deleteFiles(filePaths) { txid, error in - guard error == nil else { - call.reject("delete \(error!)") - return - } - guard let txid = txid else { - call.reject("missing txid") - return - } - call.resolve(["ok": true, "txid": txid]) - } - } - - /// local -> remote - @objc func updateRemoteFiles(_ call: CAPPluginCall) { - guard let baseURL = call.getString("basePath").flatMap({path in URL(string: path)}), - let filePaths = call.getArray("filePaths") as? [String], - let graphUUID = call.getString("graphUUID"), - let token = call.getString("token"), - let txid = call.getInt("txid") else { - call.reject("required paremeters: basePath, filePaths, graphUUID, token, txid") - return - } - let fnameEncryption = call.getBool("fnameEncryption") ?? false // default to false - - guard !filePaths.isEmpty else { - return call.reject("empty filePaths") - } - - let client = SyncClient(token: token, graphUUID: graphUUID, txid: txid) - client.delegate = self - - // 1. refresh_temp_credential - client.getTempCredential() { (credentials, error) in - guard error == nil else { - self.debugNotification(["event": "upload:error", "data": ["message": "error while refreshing credential: \(error!)"]]) - call.reject("error(getTempCredential): \(error!)") - return - } - - var files: [String: URL] = [:] - for filePath in filePaths { - // NOTE: filePath from js may contain spaces - let fileURL = baseURL.appendingPathComponent(filePath.removingPercentEncoding!) - files[filePath] = fileURL - } - - // 2. upload_temp_file - let progressHandler = {(filePath: String, fraction: Double) in - self.debugNotification(["event": "upload:progress", - "data": ["file": filePath, - "fraction": fraction]]) - } - client.uploadTempFiles(files, credentials: credentials!, progressHandler: progressHandler) { (uploadedFileKeyDict, fileMd5Dict, error) in - guard error == nil else { - self.debugNotification(["event": "upload:error", "data": ["message": "error while uploading temp files: \(error!)"]]) - call.reject("error(uploadTempFiles): \(error!)") - return - } - // 3. update_files - guard !uploadedFileKeyDict.isEmpty else { - self.debugNotification(["event": "upload:error", "data": ["message": "no file to update"]]) - call.reject("no file to update") - return - } - - // encrypted-file-name: (file-key, md5) - var uploadedFileKeyMd5Dict: [String: [String]] = [:] - - if fnameEncryptionEnabled() && fnameEncryption { - for (filePath, fileKey) in uploadedFileKeyDict { - guard let encryptedFilePath = filePath.removingPercentEncoding!.fnameEncrypt(rawKey: FNAME_ENCRYPTION_KEY!) else { - call.reject("cannot encrypt file name") - return - } - uploadedFileKeyMd5Dict[encryptedFilePath] = [fileKey, fileMd5Dict[filePath]!] - } - } else { - for (filePath, fileKey) in uploadedFileKeyDict { - uploadedFileKeyMd5Dict[filePath] = [fileKey, fileMd5Dict[filePath]!] - } - } - client.updateFiles(uploadedFileKeyMd5Dict) { (txid, error) in - guard error == nil else { - self.debugNotification(["event": "upload:error", "data": ["message": "error while updating files: \(error!)"]]) - call.reject("error updateFiles: \(error!)") - return - } - guard let txid = txid else { - call.reject("error: missing txid") - return - } - self.debugNotification(["event": "upload:done", "data": ["files": filePaths, "txid": txid]]) - call.resolve(["ok": true, "files": uploadedFileKeyDict, "txid": txid]) - } - } - } - } - -} diff --git a/ios/App/App/FileSync/Payload.swift b/ios/App/App/FileSync/Payload.swift deleted file mode 100644 index 54c4cb3a7b..0000000000 --- a/ios/App/App/FileSync/Payload.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Payload.swift -// Logseq -// -// Created by Mono Wang on 4/8/R4. -// - -import Foundation - -struct GetFilesResponse: Decodable { - let PresignedFileUrls: [String: String] -} - -struct DeleteFilesResponse: Decodable { - let TXId: Int - let DeleteSuccFiles: [String] - let DeleteFailedFiles: [String: String] -} - -public struct S3Credential: Decodable { - let AccessKeyId: String - let Expiration: String - let SecretKey: String - let SessionToken: String -} - -struct GetTempCredentialResponse: Decodable { - let Credentials: S3Credential - let S3Prefix: String -} - -struct UpdateFilesResponse: Decodable { - let TXId: Int - let UpdateSuccFiles: [String] - let UpdateFailedFiles: [String: String] -} diff --git a/ios/App/App/FileSync/SyncClient.swift b/ios/App/App/FileSync/SyncClient.swift deleted file mode 100644 index 70e0928082..0000000000 --- a/ios/App/App/FileSync/SyncClient.swift +++ /dev/null @@ -1,461 +0,0 @@ -// -// SyncClient.swift -// Logseq -// -// Created by Mono Wang on 4/8/R4. -// - -import os -import Foundation -import AWSMobileClient -import AWSS3 - -public protocol SyncDebugDelegate { - func debugNotification(_ message: [String: Any]) -} - -public class SyncClient { - private var token: String - private var graphUUID: String? - private var txid: Int = 0 - private var s3prefix: String? - - public var delegate: SyncDebugDelegate? = nil - - public init(token: String) { - self.token = token - } - - public init(token: String, graphUUID: String) { - self.token = token - self.graphUUID = graphUUID - } - - public init(token: String, graphUUID: String, txid: Int) { - self.token = token - self.graphUUID = graphUUID - self.txid = txid - } - - // get_files - // => file_path, file_url - public func getFiles(at filePaths: [String], completionHandler: @escaping ([String: URL], Error?) -> Void) { - let url = URL_BASE.appendingPathComponent("get_files") - - var request = URLRequest(url: url) - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent") - request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization") - - let payload = [ - "GraphUUID": self.graphUUID ?? "", - "Files": filePaths - ] as [String : Any] - let bodyData = try? JSONSerialization.data( - withJSONObject: payload, - options: [] - ) - request.httpMethod = "POST" - request.httpBody = bodyData - - let task = URLSession.shared.dataTask(with: request) { (data, response, error) in - guard error == nil else { - completionHandler([:], error) - return - } - - if (response as? HTTPURLResponse)?.statusCode != 200 { - let body = String(data: data!, encoding: .utf8) ?? ""; - completionHandler([:], NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "http error \(body)"])) - return - } - - if let data = data { - let resp = try? JSONDecoder().decode([String:[String:String]].self, from: data) - let files = resp?["PresignedFileUrls"] ?? [:] - self.delegate?.debugNotification(["event": "download:prepare"]) - completionHandler(files.mapValues({ url in URL(string: url)!}), nil) - } else { - // Handle unexpected error - completionHandler([:], NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"])) - } - } - task.resume() - } - - public func getVersionFiles(at filePaths: [String], completionHandler: @escaping ([String: URL], Error?) -> Void) { - let url = URL_BASE.appendingPathComponent("get_version_files") - - var request = URLRequest(url: url) - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent") - request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization") - - let payload = [ - "GraphUUID": self.graphUUID ?? "", - "Files": filePaths - ] as [String : Any] - let bodyData = try? JSONSerialization.data( - withJSONObject: payload, - options: [] - ) - request.httpMethod = "POST" - request.httpBody = bodyData - - let task = URLSession.shared.dataTask(with: request) { (data, response, error) in - guard error == nil else { - completionHandler([:], error) - return - } - - if (response as? HTTPURLResponse)?.statusCode != 200 { - let body = String(data: data!, encoding: .utf8) ?? ""; - completionHandler([:], NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "http error \(body)"])) - return - } - - if let data = data { - let resp = try? JSONDecoder().decode([String:[String:String]].self, from: data) - let files = resp?["PresignedFileUrls"] ?? [:] - self.delegate?.debugNotification(["event": "version-download:prepare"]) - completionHandler(files.mapValues({ url in URL(string: url)!}), nil) - } else { - // Handle unexpected error - completionHandler([:], NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"])) - } - } - task.resume() - } - - - public func deleteFiles(_ filePaths: [String], completionHandler: @escaping (Int?, Error?) -> Void) { - let url = URL_BASE.appendingPathComponent("delete_files") - - var request = URLRequest(url: url) - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent") - request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization") - - let payload = [ - "GraphUUID": self.graphUUID ?? "", - "Files": filePaths, - "TXId": self.txid, - ] as [String : Any] - let bodyData = try? JSONSerialization.data( - withJSONObject: payload, - options: [] - ) - request.httpMethod = "POST" - request.httpBody = bodyData - - let task = URLSession.shared.dataTask(with: request) { (data, response, error) in - guard error == nil else { - completionHandler(nil, error) - return - } - - if let response = response as? HTTPURLResponse { - let body = String(data: data!, encoding: .utf8) ?? "" - - if response.statusCode == 409 { - if body.contains("txid_to_validate") { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, - code: 409, - userInfo: [NSLocalizedDescriptionKey: "invalid txid: \(body)"])) - return - } - // fallthrough - } - if response.statusCode != 200 { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, - code: response.statusCode, - userInfo: [NSLocalizedDescriptionKey: "invalid http status \(response.statusCode): \(body)"])) - return - } - } - - if let data = data { - do { - let resp = try JSONDecoder().decode(DeleteFilesResponse.self, from: data) - // TODO: handle api resp? - self.delegate?.debugNotification(["event": "delete"]) - completionHandler(resp.TXId, nil) - } catch { - completionHandler(nil, error) - } - } else { - // Handle unexpected error - completionHandler(nil, NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"])) - } - } - task.resume() - } - - // (txid, error) - // filePath => [S3Key, md5] - public func updateFiles(_ fileKeyDict: [String: [String]], completionHandler: @escaping (Int?, Error?) -> Void) { - let url = URL_BASE.appendingPathComponent("update_files") - - var request = URLRequest(url: url) - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent") - request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization") - - let payload = [ - "GraphUUID": self.graphUUID ?? "", - "Files": Dictionary(uniqueKeysWithValues: fileKeyDict.map { ($0, $1) }) as [String: [String]] as Any, - "TXId": self.txid, - ] as [String : Any] - let bodyData = try? JSONSerialization.data( - withJSONObject: payload, - options: [] - ) - request.httpMethod = "POST" - request.httpBody = bodyData - - let task = URLSession.shared.dataTask(with: request) { (data, response, error) in - guard error == nil else { - completionHandler(nil, error) - return - } - - if let response = response as? HTTPURLResponse { - let body = String(data: data!, encoding: .utf8) ?? "" - - if response.statusCode == 409 { - if body.contains("txid_to_validate") { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, - code: 409, - userInfo: [NSLocalizedDescriptionKey: "invalid txid: \(body)"])) - return - } - // fallthrough - } - if response.statusCode != 200 { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, - code: response.statusCode, - userInfo: [NSLocalizedDescriptionKey: "invalid http status \(response.statusCode): \(body)"])) - return - } - } - - if let data = data { - let resp = try? JSONDecoder().decode(UpdateFilesResponse.self, from: data) - if resp?.UpdateFailedFiles.isEmpty ?? true { - completionHandler(resp?.TXId, nil) - } else { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "update fail for some files: \(resp?.UpdateFailedFiles.debugDescription)"])) - } - } else { - // Handle unexpected error - completionHandler(nil, NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"])) - } - } - task.resume() - } - - public func getTempCredential(completionHandler: @escaping (S3Credential?, Error?) -> Void) { - let url = URL_BASE.appendingPathComponent("get_temp_credential") - - var request = URLRequest(url: url) - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - request.setValue("Logseq-sync/0.1", forHTTPHeaderField: "User-Agent") - request.setValue("Bearer \(self.token)", forHTTPHeaderField: "Authorization") - request.httpMethod = "POST" - request.httpBody = Data() - - let task = URLSession.shared.dataTask(with: request) { (data, response, error) in - guard error == nil else { - completionHandler(nil, error) - return - } - if let response = response as? HTTPURLResponse { - let body = String(data: data!, encoding: .utf8) ?? "" - if response.statusCode == 401 { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, code: 401, userInfo: [NSLocalizedDescriptionKey: "unauthorized"])) - return - } - if response.statusCode != 200 { - completionHandler(nil, NSError(domain: FileSyncErrorDomain, - code: response.statusCode, - userInfo: [NSLocalizedDescriptionKey: "invalid http status \(response.statusCode): \(body)"])) - return - } - } - if let data = data { - let resp = try? JSONDecoder().decode(GetTempCredentialResponse.self, from: data) - // NOTE: remove BUCKET prefix here. - self.s3prefix = resp?.S3Prefix.replacingOccurrences(of: "\(BUCKET)/", with: "") - self.delegate?.debugNotification(["event": "upload:prepare"]) - completionHandler(resp?.Credentials, nil) - } else { - // Handle unexpected error - completionHandler(nil, NSError(domain: FileSyncErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "unexpected error"])) - } - } - task.resume() - } - - // [filePath, Key] - public func uploadTempFiles(_ files: [String: URL], - credentials: S3Credential, - // key, fraction - progressHandler: @escaping ((String, Double) -> Void), - completionHandler: @escaping ([String: String], [String: String], Error?) -> Void) - { - let credentialsProvider = AWSBasicSessionCredentialsProvider( - accessKey: credentials.AccessKeyId, secretKey: credentials.SecretKey, sessionToken: credentials.SessionToken) - - var region = AWSRegionType.USEast2 - if REGION == "us-east-2" { - region = .USEast2 - } else if REGION == "us-east-1" { - region = .USEast1 - } // TODO: string to REGION conversion - - let configuration = AWSServiceConfiguration(region: region, credentialsProvider: credentialsProvider) - configuration?.timeoutIntervalForRequest = 5.0 - configuration?.timeoutIntervalForResource = 5.0 - - let group = DispatchGroup() - var keyFileDict: [String: String] = [:] - var fileKeyDict: [String: String] = [:] - var fileMd5Dict: [String: String] = [:] - - - for (filePath, fileLocalURL) in files { - guard let rawData = try? Data(contentsOf: fileLocalURL) else { continue } - guard let encryptedRawData = maybeEncrypt(rawData) else { continue } - group.enter() - - let randFileName = String.random(length: 15).appending(".").appending(fileLocalURL.pathExtension) - let key = "\(self.s3prefix!)/ios\(randFileName)" - - keyFileDict[key] = filePath - fileMd5Dict[filePath] = rawData.MD5 - - guard let presignURL = getPresignedPutURL(configration: configuration!, key: key) else { - completionHandler([:], [:], NSError(domain: FileSyncErrorDomain, - code: 0, - userInfo: [NSLocalizedDescriptionKey: "cannot get presigned url"])) - return - } - - let progressHandler = {(fraction: Double) in - progressHandler(filePath, fraction) - } - putContent(url: presignURL, content: encryptedRawData, progressHandler: progressHandler) { error in - guard error == nil else { - print("debug put error \(error!)") - completionHandler([:], [:], error!) - return - } - // only save successful keys - fileKeyDict[filePath] = key - keyFileDict.removeValue(forKey: key) - group.leave() - } - } - - group.notify(queue: .main) { - completionHandler(fileKeyDict, fileMd5Dict, nil) - } - } - - public func putContent(url: URL, content: Data, - progressHandler: @escaping ((Double) -> Void), - completion: @escaping (Error?) -> Void) { - var observation: NSKeyValueObservation! = nil - - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") - request.httpBody = content - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - observation?.invalidate() - - guard error == nil else { - completion(error!) - return - } - if let response = response as? HTTPURLResponse { - guard (200 ..< 299) ~= response.statusCode else { - NSLog("debug error put content \(String(data: data!, encoding: .utf8))") - completion(NSError(domain: FileSyncErrorDomain, - code: response.statusCode, - userInfo: [NSLocalizedDescriptionKey: "http put request failed"])) - return - } - } - completion(nil) - } - - observation = task.progress.observe(\.fractionCompleted) { progress, _ in - progressHandler(progress.fractionCompleted) - } - - task.resume() - - } - - public func download(url: URL, - progressHandler: @escaping ((Double) -> Void), // FIXME: cannot get total bytes - completion: @escaping (Result) -> Void) { - var observation: NSKeyValueObservation! = nil - - let task = URLSession.shared.downloadTask(with: url) {(tempURL, response, error) in - observation?.invalidate() - - guard let tempURL = tempURL else { - completion(.failure(error!)) - return - } - guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { - completion(.failure(NSError(domain: FileSyncErrorDomain, - code: 0, - userInfo: [NSLocalizedDescriptionKey: "http get request failed"]))) - return - } - completion(.success(tempURL)) - } - - observation = task.progress.observe(\.fractionCompleted) { progress, _ in - progressHandler(progress.fractionCompleted) - } - - task.resume() - } - - public func download(url: URL, progressHandler: @escaping ((Double) -> Void)) async -> Result { - return await withCheckedContinuation { continuation in - download(url: url, progressHandler: progressHandler) { result in - continuation.resume(returning: result) - } - } - } - - private func getPresignedPutURL(configration: AWSServiceConfiguration, key: String) -> URL? { - let req = AWSS3GetPreSignedURLRequest() - - req.key = key - req.bucket = BUCKET - req.httpMethod = .PUT - req.expires = Date(timeIntervalSinceNow: 600) // 10min - - var presignedURLString: String? = nil - AWSS3PreSignedURLBuilder(configuration: configration).getPreSignedURL(req).continueWith { task -> Any? in - if let error = task.error as NSError? { - NSLog("error generating presigend url \(error)") - return nil - } - presignedURLString = task.result?.absoluteString - return nil - } - if let presignedURLString = presignedURLString { - return URL(string: presignedURLString) - } else { - return nil - } - } -} diff --git a/ios/App/Podfile b/ios/App/Podfile index b144154dd0..7544b7e320 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -1,5 +1,8 @@ require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' +# pod specs for AgeEncryption +source 'https://github.com/CocoaPods/Specs.git' + platform :ios, '13.0' use_frameworks! @@ -20,6 +23,7 @@ def capacitor_pods pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' + pod 'LogseqCapacitorFileSync', :path => '../../node_modules/@logseq/capacitor-file-sync' pod 'CapacitorVoiceRecorder', :path => '../../node_modules/capacitor-voice-recorder' pod 'SendIntent', :path => '../../node_modules/send-intent' end @@ -27,9 +31,6 @@ end target 'Logseq' do capacitor_pods # Add your Pods here - pod 'AWSMobileClient' - pod 'AWSS3' - pod 'AgeEncryption', :podspec => './LogseqSpecs/AgeEncryption.podspec' end diff --git a/package.json b/package.json index 0d2e2d859a..13a9d28549 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@capacitor/status-bar": "^4.0.0", "@excalidraw/excalidraw": "0.10.0", "@kanru/rage-wasm": "0.2.1", + "@logseq/capacitor-file-sync": "0.0.5", "@logseq/react-tweet-embed": "1.3.1-1", "@sentry/react": "^6.18.2", "@sentry/tracing": "^6.18.2", diff --git a/src/main/frontend/mobile/util.cljs b/src/main/frontend/mobile/util.cljs index c739179720..e1522edc00 100644 --- a/src/main/frontend/mobile/util.cljs +++ b/src/main/frontend/mobile/util.cljs @@ -1,6 +1,7 @@ (ns frontend.mobile.util (:require ["@capacitor/core" :refer [Capacitor registerPlugin]] ["@capacitor/splash-screen" :refer [SplashScreen]] + ["@logseq/capacitor-file-sync" :refer [FileSync]] [clojure.string :as string] [promesa.core :as p])) @@ -24,14 +25,11 @@ (defonce folder-picker (registerPlugin "FolderPicker")) (when (native-ios?) (defonce ios-utils (registerPlugin "Utils")) - (defonce ios-file-container (registerPlugin "FileContainer")) - (defonce file-sync (registerPlugin "FileSync"))) + (defonce ios-file-container (registerPlugin "FileContainer"))) -(when (native-android?) - (defonce file-sync (registerPlugin "FileSync"))) - -;; NOTE: both iOS and android share the same FsWatcher API +;; NOTE: both iOS and android share the same API (when (native-platform?) + (defonce file-sync FileSync) (defonce fs-watcher (registerPlugin "FsWatcher"))) (defn hide-splash [] diff --git a/yarn.lock b/yarn.lock index 6f7a35c983..6ca5472b0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -480,6 +480,11 @@ resolved "https://registry.yarnpkg.com/@kanru/rage-wasm/-/rage-wasm-0.2.1.tgz#dd8fdd3133992c42bf68c0086d8cad40a13bc329" integrity sha512-sYi4F2mL6Mpcz7zbS4myasw11xLBEbgZkDMRVg9jNxTKt6Ct/LT7/vCHDmEzAFcPcPqixD5De6Ql3bJijAX0/w== +"@logseq/capacitor-file-sync@0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@logseq/capacitor-file-sync/-/capacitor-file-sync-0.0.5.tgz#e391d3ec9eb65d200fa5af18738913d19a223f39" + integrity sha512-3cdpwt5lsEE7occQwJKaalaKGXxgucSDzFNeRkRQMylRehlZskAQtCjgDFR7Wt3tBQZdLZmjpgj7ioYQesWbTA== + "@logseq/react-tweet-embed@1.3.1-1": version "1.3.1-1" resolved "https://registry.yarnpkg.com/@logseq/react-tweet-embed/-/react-tweet-embed-1.3.1-1.tgz#119d22be8234de006fc35c3fa2a36f85634c5be6"