diff --git a/Core/source/assets/flowcrypt-ios-begin.js b/Core/source/assets/flowcrypt-ios-begin.js index 1c3ace8d5..fac5e2833 100644 --- a/Core/source/assets/flowcrypt-ios-begin.js +++ b/Core/source/assets/flowcrypt-ios-begin.js @@ -35,8 +35,8 @@ const window = { navigator, crypto, setTimeout, clearTimeout }; const self = { window, navigator, crypto }; global.window = window; -var engine_host_cb_value_formatter = function(val) { - coreHost.handleCallback(val.json, val.data); +var engine_host_cb_value_formatter = function(callbackId, val) { + coreHost.handleCallback(callbackId, val.json, val.data); }; (function() { diff --git a/Core/source/entrypoint-bare.ts b/Core/source/entrypoint-bare.ts index ca0d66bc2..7e15dbc5c 100644 --- a/Core/source/entrypoint-bare.ts +++ b/Core/source/entrypoint-bare.ts @@ -11,17 +11,17 @@ declare const global: any; const endpoints = new Endpoints(); -global.handleRequestFromHost = (endpointName: string, request: string, data: Uint8Array, cb: (response: EndpointRes) => void): void => { +global.handleRequestFromHost = (endpointName: string, callbackId: string, request: string, data: Uint8Array, cb: (key: string, response: EndpointRes) => void): void => { try { const handler = endpoints[endpointName]; if (!handler) { - cb(fmtErr(new Error(`Unknown endpoint: ${endpointName}`))); + cb(callbackId, fmtErr(new Error(`Unknown endpoint: ${endpointName}`))); } else { handler(JSON.parse(request), [data]) - .then(res => cb(res)) - .catch(err => cb(fmtErr(err))); + .then(res => cb(callbackId, res)) + .catch(err => cb(callbackId, fmtErr(err))); } } catch (err) { - cb(fmtErr(err)); + cb(callbackId, fmtErr(err)); } }; diff --git a/FlowCrypt/Core/Core.swift b/FlowCrypt/Core/Core.swift index 2ec387773..041ee537c 100644 --- a/FlowCrypt/Core/Core.swift +++ b/FlowCrypt/Core/Core.swift @@ -53,18 +53,18 @@ protocol KeyParser { func parseKeys(armoredOrBinary: Data) async throws -> CoreRes.ParseKeys } -final class Core: KeyDecrypter, KeyParser, CoreComposeMessageType { +actor Core: KeyDecrypter, KeyParser, CoreComposeMessageType { static let shared = Core() + private typealias CallbackResult = (String, [UInt8]) + private var jsEndpointListener: JSValue? private var cb_catcher: JSValue? - private var cb_last_value: (String, [UInt8])? private var vm = JSVirtualMachine()! private var context: JSContext? - private dynamic var started = false - private dynamic var ready = false - - private let queue = DispatchQueue(label: "com.flowcrypt.core", qos: .default) // todo - try also with .userInitiated + + private var callbackResults: [String: CallbackResult] = [:] + private var ready = false private lazy var logger = Logger.nested(in: Self.self, with: "Js") @@ -188,32 +188,25 @@ final class Core: KeyDecrypter, KeyParser, CoreComposeMessageType { return try r.json.decodeJson(as: CoreRes.ZxcvbnStrengthBar.self) } - func startInBackgroundIfNotAlreadyRunning(_ completion: @escaping (() -> Void)) { - if self.ready { - completion() - } - if !started { - started = true - DispatchQueue.global(qos: .default).async { [weak self] in - guard let self = self else { return } - let trace = Trace(id: "Start in background") - let jsFile = Bundle(for: Core.self).path(forResource: "flowcrypt-ios-prod.js.txt", ofType: nil)! - let jsFileSrc = try? String(contentsOfFile: jsFile) - self.context = JSContext(virtualMachine: self.vm)! - self.context?.setObject(CoreHost(), forKeyedSubscript: "coreHost" as (NSCopying & NSObjectProtocol)) - self.context!.exceptionHandler = { [weak self] _, exception in - guard let exception = exception else { return } - self?.logger.logWarning("\(exception)") - } - self.context!.evaluateScript("const APP_VERSION = 'iOS 0.2';") - self.context!.evaluateScript(jsFileSrc) - self.jsEndpointListener = self.context!.objectForKeyedSubscript("handleRequestFromHost") - self.cb_catcher = self.context!.objectForKeyedSubscript("engine_host_cb_value_formatter") - self.ready = true - self.logger.logInfo("JsContext took \(trace.finish()) to start") - completion() - } + func startIfNotAlreadyRunning() async { + guard !ready else { return } + + let trace = Trace(id: "Start in background") + let jsFile = Bundle(for: Self.self).path(forResource: "flowcrypt-ios-prod.js.txt", ofType: nil)! + let jsFileSrc = try? String(contentsOfFile: jsFile) + context = JSContext(virtualMachine: vm)! + context?.setObject(CoreHost(), forKeyedSubscript: "coreHost" as (NSCopying & NSObjectProtocol)) + context!.exceptionHandler = { _, exception in + guard let exception = exception else { return } + let logger = Logger.nested(in: Self.self, with: "Js") + logger.logWarning("\(exception)") } + context!.evaluateScript("const APP_VERSION = 'iOS 0.2';") + context!.evaluateScript(jsFileSrc) + jsEndpointListener = context!.objectForKeyedSubscript("handleRequestFromHost") + cb_catcher = context!.objectForKeyedSubscript("engine_host_cb_value_formatter") + ready = true + logger.logInfo("JsContext took \(trace.finish()) to start") } func gmailBackupSearch(for email: String) async throws -> String { @@ -222,8 +215,8 @@ final class Core: KeyDecrypter, KeyParser, CoreComposeMessageType { return result.query } - func handleCallbackResult(json: String, data: [UInt8]) { - cb_last_value = (json, data) + func handleCallbackResult(callbackId: String, json: String, data: [UInt8]) { + callbackResults[callbackId] = (json, data) } // MARK: Private calls @@ -236,55 +229,45 @@ final class Core: KeyDecrypter, KeyParser, CoreComposeMessageType { } private func call(_ endpoint: String, jsonData: Data, data: Data) async throws -> RawRes { - try await sleepUntilReadyOrThrow() - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // tom - todo - currently there is only one callback storage variable "cb_last_value" - // for all JavaScript calls, and so we have to synchronize - // all calls into JavaScript to happen serially, else - // the return values would be undefined when used concurrently - // see https://github.com/FlowCrypt/flowcrypt-ios/issues/852 - // A possible solution would be to only synchronize returning o fthe callbac values into some dict. But I'm unsure if JavaScript is otherwise safe to call concurrently, so for now we'll do the safer thing. - queue.async { - self.cb_last_value = nil - self.jsEndpointListener!.call(withArguments: [endpoint, String(data: jsonData, encoding: .utf8)!, Array(data), self.cb_catcher!]) - guard - let resJsonData = self.cb_last_value?.0.data(using: .utf8), - let rawResponse = self.cb_last_value?.1 - else { - self.logger.logError("could not see callback response, got cb_last_value: \(String(describing: self.cb_last_value))") - continuation.resume(throwing: CoreError.format("JavaScript callback response not available")) - return - } - let error = try? resJsonData.decodeJson(as: CoreRes.Error.self) - if let error = error { - let errMsg = "------ js err -------\nCore \(endpoint):\n\(error.error.message)\n\(error.error.stack ?? "no stack")\n------- end js err -----" - self.logger.logError(errMsg) - continuation.resume(throwing: CoreError(coreError: error)) - return - } - continuation.resume(returning: RawRes(json: resJsonData, data: Data(rawResponse))) - } + guard ready else { + throw CoreError.exception("Core is not ready yet. Most likeyly startIfNotAlreadyRunning wasn't called first") } - } - - private func sleepUntilReadyOrThrow() async throws { - // This will block the task for up to 1000ms if the app was just started and Core method was called before JSContext is ready - // It should only affect the user if Core method was called within 500-800ms of starting the app - let start = DispatchTime.now() - while !ready { - if start.millisecondsSince > 1000 { // already waited for 1000 ms, give up - throw CoreError.notReady("App Core not ready yet") - } - await Task.sleep(50 * 1_000_000) // 50ms + + let callbackId = NSUUID().uuidString + jsEndpointListener!.call(withArguments: [ + endpoint, + callbackId, + String(data: jsonData, encoding: .utf8)!, + Array(data), cb_catcher! + ]) + + while callbackResults[callbackId] == nil { + await Task.sleep(1_000_000) // 1ms } - } - private struct RawRes { - let json: Data - let data: Data + guard + let result = callbackResults.removeValue(forKey: callbackId), + let resJsonData = result.0.data(using: .utf8) + else { + throw CoreError.format("JavaScript callback response not available") + } + + let error = try? resJsonData.decodeJson(as: CoreRes.Error.self) + if let error = error { + let errMsg = "------ js err -------\nCore \(endpoint):\n\(error.error.message)\n\(error.error.stack ?? "no stack")\n------- end js err -----" + logger.logError(errMsg) + throw CoreError(coreError: error) + } + + return RawRes(json: resJsonData, data: Data(result.1)) } } +private struct RawRes { + let json: Data + let data: Data +} + private struct GmailBackupSearchResponse: Decodable { let query: String } diff --git a/FlowCrypt/Core/CoreHost.swift b/FlowCrypt/Core/CoreHost.swift index 4762b2e56..19daf5bb7 100644 --- a/FlowCrypt/Core/CoreHost.swift +++ b/FlowCrypt/Core/CoreHost.swift @@ -22,7 +22,7 @@ import SwiftyRSA // for rsa func setTimeout(_ callback: JSValue, _ ms: Double) -> String func clearTimeout(_ identifier: String) - func handleCallback(_ string: String, _ data: [UInt8]) + func handleCallback(_ endpointKey: String, _ string: String, _ data: [UInt8]) } var timers = [String: Timer]() @@ -119,8 +119,10 @@ final class CoreHost: NSObject, CoreHostExports { return uuid } - func handleCallback(_ string: String, _ data: [UInt8]) { - Core.shared.handleCallbackResult(json: string, data: data) + func handleCallback(_ callbackId: String, _ string: String, _ data: [UInt8]) { + Task { + await Core.shared.handleCallbackResult(callbackId: callbackId, json: string, data: data) + } } @objc func callJsCb(_ timer: Timer) { diff --git a/FlowCrypt/Functionality/Services/AppStartup.swift b/FlowCrypt/Functionality/Services/AppStartup.swift index 99c97d9e3..e322801cf 100644 --- a/FlowCrypt/Functionality/Services/AppStartup.swift +++ b/FlowCrypt/Functionality/Services/AppStartup.swift @@ -26,7 +26,7 @@ struct AppStartup { Task { do { - try awaitPromise(setupCore()) + await setupCore() try setupMigrationIfNeeded() try setupSession() try await getUserOrgRulesIfNeeded() @@ -37,13 +37,9 @@ struct AppStartup { } } - private func setupCore() -> Promise { - Promise { resolve, _ in - logger.logInfo("Setup Core") - Core.shared.startInBackgroundIfNotAlreadyRunning { - resolve(()) - } - } + private func setupCore() async { + logger.logInfo("Setup Core") + await Core.shared.startIfNotAlreadyRunning() } private func setupMigrationIfNeeded() throws { diff --git a/FlowCrypt/Resources/flowcrypt-ios-prod.js.txt b/FlowCrypt/Resources/flowcrypt-ios-prod.js.txt index 531313b35..ce0b14a96 100644 --- a/FlowCrypt/Resources/flowcrypt-ios-prod.js.txt +++ b/FlowCrypt/Resources/flowcrypt-ios-prod.js.txt @@ -40,8 +40,8 @@ const window = { navigator, crypto, setTimeout, clearTimeout }; const self = { window, navigator, crypto }; global.window = window; -var engine_host_cb_value_formatter = function(val) { - coreHost.handleCallback(val.json, val.data); +var engine_host_cb_value_formatter = function(callbackId, val) { + coreHost.handleCallback(callbackId, val.json, val.data); }; (function() { @@ -86405,20 +86405,20 @@ Object.defineProperty(exports, "__esModule", { value: true }); const endpoints_1 = __webpack_require__(2); const format_output_1 = __webpack_require__(3); const endpoints = new endpoints_1.Endpoints(); -global.handleRequestFromHost = (endpointName, request, data, cb) => { +global.handleRequestFromHost = (endpointName, callbackId, request, data, cb) => { try { const handler = endpoints[endpointName]; if (!handler) { - cb((0, format_output_1.fmtErr)(new Error(`Unknown endpoint: ${endpointName}`))); + cb(callbackId, (0, format_output_1.fmtErr)(new Error(`Unknown endpoint: ${endpointName}`))); } else { handler(JSON.parse(request), [data]) - .then(res => cb(res)) - .catch(err => cb((0, format_output_1.fmtErr)(err))); + .then(res => cb(callbackId, res)) + .catch(err => cb(callbackId, (0, format_output_1.fmtErr)(err))); } } catch (err) { - cb((0, format_output_1.fmtErr)(err)); + cb(callbackId, (0, format_output_1.fmtErr)(err)); } }; @@ -111770,7 +111770,7 @@ elliptic.eddsa = __webpack_require__(170); /* 144 */ /***/ (function(module) { -module.exports = JSON.parse("{\"_from\":\"elliptic@^6.5.3\",\"_id\":\"elliptic@6.5.4\",\"_inBundle\":false,\"_integrity\":\"sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==\",\"_location\":\"/elliptic\",\"_phantomChildren\":{},\"_requested\":{\"type\":\"range\",\"registry\":true,\"raw\":\"elliptic@^6.5.3\",\"name\":\"elliptic\",\"escapedName\":\"elliptic\",\"rawSpec\":\"^6.5.3\",\"saveSpec\":null,\"fetchSpec\":\"^6.5.3\"},\"_requiredBy\":[\"/browserify-sign\",\"/create-ecdh\"],\"_resolved\":\"https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz\",\"_shasum\":\"da37cebd31e79a1367e941b592ed1fbebd58abbb\",\"_spec\":\"elliptic@^6.5.3\",\"_where\":\"/home/ivan/pro/flowcrypt/flowcrypt-ios/Core/node_modules/browserify-sign\",\"author\":{\"name\":\"Fedor Indutny\",\"email\":\"fedor@indutny.com\"},\"bugs\":{\"url\":\"https://github.com/indutny/elliptic/issues\"},\"bundleDependencies\":false,\"dependencies\":{\"bn.js\":\"^4.11.9\",\"brorand\":\"^1.1.0\",\"hash.js\":\"^1.0.0\",\"hmac-drbg\":\"^1.0.1\",\"inherits\":\"^2.0.4\",\"minimalistic-assert\":\"^1.0.1\",\"minimalistic-crypto-utils\":\"^1.0.1\"},\"deprecated\":false,\"description\":\"EC cryptography\",\"devDependencies\":{\"brfs\":\"^2.0.2\",\"coveralls\":\"^3.1.0\",\"eslint\":\"^7.6.0\",\"grunt\":\"^1.2.1\",\"grunt-browserify\":\"^5.3.0\",\"grunt-cli\":\"^1.3.2\",\"grunt-contrib-connect\":\"^3.0.0\",\"grunt-contrib-copy\":\"^1.0.0\",\"grunt-contrib-uglify\":\"^5.0.0\",\"grunt-mocha-istanbul\":\"^5.0.2\",\"grunt-saucelabs\":\"^9.0.1\",\"istanbul\":\"^0.4.5\",\"mocha\":\"^8.0.1\"},\"files\":[\"lib\"],\"homepage\":\"https://github.com/indutny/elliptic\",\"keywords\":[\"EC\",\"Elliptic\",\"curve\",\"Cryptography\"],\"license\":\"MIT\",\"main\":\"lib/elliptic.js\",\"name\":\"elliptic\",\"repository\":{\"type\":\"git\",\"url\":\"git+ssh://git@github.com/indutny/elliptic.git\"},\"scripts\":{\"lint\":\"eslint lib test\",\"lint:fix\":\"npm run lint -- --fix\",\"test\":\"npm run lint && npm run unit\",\"unit\":\"istanbul test _mocha --reporter=spec test/index.js\",\"version\":\"grunt dist && git add dist/\"},\"version\":\"6.5.4\"}"); +module.exports = JSON.parse("{\"_args\":[[\"elliptic@6.5.4\",\"/Users/ivan/Documents/Projects/FlowCrypt/flowcrypt-ios/Core\"]],\"_development\":true,\"_from\":\"elliptic@6.5.4\",\"_id\":\"elliptic@6.5.4\",\"_inBundle\":false,\"_integrity\":\"sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==\",\"_location\":\"/elliptic\",\"_phantomChildren\":{},\"_requested\":{\"type\":\"version\",\"registry\":true,\"raw\":\"elliptic@6.5.4\",\"name\":\"elliptic\",\"escapedName\":\"elliptic\",\"rawSpec\":\"6.5.4\",\"saveSpec\":null,\"fetchSpec\":\"6.5.4\"},\"_requiredBy\":[\"/browserify-sign\",\"/create-ecdh\"],\"_resolved\":\"https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz\",\"_spec\":\"6.5.4\",\"_where\":\"/Users/ivan/Documents/Projects/FlowCrypt/flowcrypt-ios/Core\",\"author\":{\"name\":\"Fedor Indutny\",\"email\":\"fedor@indutny.com\"},\"bugs\":{\"url\":\"https://github.com/indutny/elliptic/issues\"},\"dependencies\":{\"bn.js\":\"^4.11.9\",\"brorand\":\"^1.1.0\",\"hash.js\":\"^1.0.0\",\"hmac-drbg\":\"^1.0.1\",\"inherits\":\"^2.0.4\",\"minimalistic-assert\":\"^1.0.1\",\"minimalistic-crypto-utils\":\"^1.0.1\"},\"description\":\"EC cryptography\",\"devDependencies\":{\"brfs\":\"^2.0.2\",\"coveralls\":\"^3.1.0\",\"eslint\":\"^7.6.0\",\"grunt\":\"^1.2.1\",\"grunt-browserify\":\"^5.3.0\",\"grunt-cli\":\"^1.3.2\",\"grunt-contrib-connect\":\"^3.0.0\",\"grunt-contrib-copy\":\"^1.0.0\",\"grunt-contrib-uglify\":\"^5.0.0\",\"grunt-mocha-istanbul\":\"^5.0.2\",\"grunt-saucelabs\":\"^9.0.1\",\"istanbul\":\"^0.4.5\",\"mocha\":\"^8.0.1\"},\"files\":[\"lib\"],\"homepage\":\"https://github.com/indutny/elliptic\",\"keywords\":[\"EC\",\"Elliptic\",\"curve\",\"Cryptography\"],\"license\":\"MIT\",\"main\":\"lib/elliptic.js\",\"name\":\"elliptic\",\"repository\":{\"type\":\"git\",\"url\":\"git+ssh://git@github.com/indutny/elliptic.git\"},\"scripts\":{\"lint\":\"eslint lib test\",\"lint:fix\":\"npm run lint -- --fix\",\"test\":\"npm run lint && npm run unit\",\"unit\":\"istanbul test _mocha --reporter=spec test/index.js\",\"version\":\"grunt dist && git add dist/\"},\"version\":\"6.5.4\"}"); /***/ }), /* 145 */ diff --git a/FlowCryptAppTests/Core/FlowCryptCoreTests.swift b/FlowCryptAppTests/Core/FlowCryptCoreTests.swift index 16af131b2..1b3b95758 100644 --- a/FlowCryptAppTests/Core/FlowCryptCoreTests.swift +++ b/FlowCryptAppTests/Core/FlowCryptCoreTests.swift @@ -15,7 +15,8 @@ final class FlowCryptCoreTests: XCTestCase { override func setUp() { let expectation = XCTestExpectation() - core.startInBackgroundIfNotAlreadyRunning { + Task { + await core.startIfNotAlreadyRunning() expectation.fulfill() } wait(for: [expectation], timeout: 20) @@ -387,9 +388,8 @@ final class FlowCryptCoreTests: XCTestCase { } } - // this test is only meaningful on a real device - // it passes on simulator even if implementation is broken - // maybe there's a way to run simulator with more cores? (on a mac that has them) which would simulate real device better + // This test always passes, even wrongly, on simulators running on a mac with 2 or fewer cores. + // Behaves meaningfully on real iPhone or simulator on a mac with many cores func testCoreResponseCorrectnessUnderConcurrency() async throws { // given: a bunch of keys let pp = "this particular pass phrase is long enough" diff --git a/FlowCryptAppTests/Functionality/Services/Key Services/KeyServiceTests.swift b/FlowCryptAppTests/Functionality/Services/Key Services/KeyServiceTests.swift index 6e5db9971..e3bb5292f 100644 --- a/FlowCryptAppTests/Functionality/Services/Key Services/KeyServiceTests.swift +++ b/FlowCryptAppTests/Functionality/Services/Key Services/KeyServiceTests.swift @@ -15,7 +15,8 @@ final class KeyServiceTests: XCTestCase { super.setUp() let expectation = XCTestExpectation() - Core.shared.startInBackgroundIfNotAlreadyRunning { + Task { + await Core.shared.startIfNotAlreadyRunning() expectation.fulfill() } wait(for: [expectation], timeout: 10)