Browse Source

refactor(mobile): use ios file-sync as lib

Andelf 3 years ago
parent
commit
2a73019b34

+ 1 - 0
.gitignore

@@ -47,3 +47,4 @@ startup.png
 ~*~
 
 ios/App/App/capacitor.config.json
+android/app/src/main/assets/capacitor.config.json

+ 1 - 0
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')
 

+ 0 - 25
android/app/src/main/assets/capacitor.config.json

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

+ 4 - 0
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"

+ 3 - 0
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')
 

+ 5 - 0
capacitor.config.ts

@@ -20,6 +20,11 @@ const config: CapacitorConfig = {
   },
   ios: {
     scheme: 'Logseq'
+  },
+  cordova: {
+    staticPlugins: [
+      '@logseq/capacitor-file-sync', // AgeEncryption requires static link
+    ]
   }
 }
 

+ 0 - 32
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 = "<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>"; };
-		FE443F1B27FF5420007ECE65 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
-		FE443F1D27FF54AA007ECE65 /* Payload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Payload.swift; sourceTree = "<group>"; };
-		FE443F1F27FF54C9007ECE65 /* SyncClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncClient.swift; 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>"; };
-		FE8C946927FD762700C8017B /* FileSync.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileSync.swift; sourceTree = "<group>"; };
-		FE8C946A27FD762700C8017B /* FileSync.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FileSync.m; sourceTree = "<group>"; };
-		FEE688A328448F8C0019510E /* AgeEncryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgeEncryption.swift; sourceTree = "<group>"; };
 /* 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 = "<group>";
 		};
-		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 = "<group>";
-		};
 /* 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;
 		};

+ 0 - 110
ios/App/App/FileSync/AgeEncryption.swift

@@ -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<UnsafeMutablePointer<CChar>?>.allocate(capacity: 1)
-        let cPublicKey = UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>.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<UnsafeMutablePointer<CChar>?>.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<UnsafeMutablePointer<CChar>?>.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<UnsafeMutablePointer<CChar>?>.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<UnsafeMutablePointer<CChar>?>.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<UnsafeMutablePointer<CChar>?>.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
-            }
-        }
-    }
-}

+ 0 - 172
ios/App/App/FileSync/Extensions.swift

@@ -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<UInt8>(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..<length).map{ _ in letters.randomElement()! })
-    }
-    
-    func fnameEncrypt(rawKey: Data) -> 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)
-    }
-}

+ 0 - 25
ios/App/App/FileSync/FileSync.m

@@ -1,25 +0,0 @@
-//
-//  FileSync.m
-//  Logseq
-//
-//  Created by Mono Wang on 2/24/R4.
-//
-
-#import <Capacitor/Capacitor.h>
-
-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);
-)

+ 0 - 635
ios/App/App/FileSync/FileSync.swift

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

+ 0 - 36
ios/App/App/FileSync/Payload.swift

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

+ 0 - 461
ios/App/App/FileSync/SyncClient.swift

@@ -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<URL?, Error>) -> 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<URL?, Error> {
-        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
-        }
-    }
-}

+ 4 - 3
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
 
 

+ 1 - 0
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",

+ 4 - 6
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 []

+ 5 - 0
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/[email protected]":
+  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/[email protected]":
   version "1.3.1-1"
   resolved "https://registry.yarnpkg.com/@logseq/react-tweet-embed/-/react-tweet-embed-1.3.1-1.tgz#119d22be8234de006fc35c3fa2a36f85634c5be6"