diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed7cda72e..1a1a14122 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: build - args: -p deltachat_ffi + args: -p deltachat_ffi --features jsonrpc - name: run python tests if: ${{ matrix.python }} diff --git a/.github/workflows/jsonrpc.yml b/.github/workflows/jsonrpc.yml new file mode 100644 index 000000000..082ddb569 --- /dev/null +++ b/.github/workflows/jsonrpc.yml @@ -0,0 +1,45 @@ +name: JSON-RPC API Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CARGO_TERM_COLOR: always + +jobs: + build_and_test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 16.x + uses: actions/setup-node@v1 + with: + node-version: 16.x + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + - name: Add Rust cache + uses: Swatinem/rust-cache@v1.3.0 + - name: npm install + run: | + cd deltachat-jsonrpc/typescript + npm install + - name: Build TypeScript, run Rust tests, generate bindings + run: | + cd deltachat-jsonrpc/typescript + npm run build + - name: Run integration tests + run: | + cd deltachat-jsonrpc/typescript + npm run test + env: + DCC_NEW_TMP_EMAIL: ${{ secrets.DCC_NEW_TMP_EMAIL }} + - name: Run linter + run: | + cd deltachat-jsonrpc/typescript + npm run prettier:check diff --git a/.npmignore b/.npmignore index 333d2886b..bdf8be46a 100644 --- a/.npmignore +++ b/.npmignore @@ -40,3 +40,17 @@ node/old_docs.md .vscode/ .github/ node/.prettierrc.yml + +deltachat-jsonrpc/TODO.md +deltachat-jsonrpc/README.MD +deltachat-jsonrpc/.gitignore +deltachat-jsonrpc/typescript/.gitignore +deltachat-jsonrpc/typescript/.prettierignore +deltachat-jsonrpc/typescript/accounts/ +deltachat-jsonrpc/typescript/index.html +deltachat-jsonrpc/typescript/node-demo.js +deltachat-jsonrpc/typescript/report_api_coverage.mjs +deltachat-jsonrpc/typescript/test +deltachat-jsonrpc/typescript/example.ts + +.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c771fe575..c2441250e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ ## Unreleased +### API-Changes +- jsonrpc api over websocket server (basically a new api next to the cffi) #3463 +- jsonrpc methods in cffi #3463: + - `dc_jsonrpc_instance_t* dc_jsonrpc_init(dc_accounts_t* account_manager);` + - `void dc_jsonrpc_unref(dc_jsonrpc_instance_t* jsonrpc_instance);` + - `void dc_jsonrpc_request(dc_jsonrpc_instance_t* jsonrpc_instance, char* request);` + - `char* dc_jsonrpc_next_response(dc_jsonrpc_instance_t* jsonrpc_instance);` +- node: json rpc methods #3463: + - `AccountManager.prototype.startJsonRpcHandler(callback: ((response: string) => void)): void` + - `AccountManager.prototype.jsonRpcRequest(message: string): void` + ### Added +- added a JSON RPC API, accessible through a WebSocket server, the CFFI bindings and the Node.js bindings #3463 ### Changes diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d4b303ce..70bf4b339 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,7 +21,7 @@ add_custom_command( PREFIX=${CMAKE_INSTALL_PREFIX} LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR} INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR} - ${CARGO} build --release --no-default-features + ${CARGO} build --release --no-default-features --features jsonrpc # Build in `deltachat-ffi` directory instead of using # `--package deltachat_ffi` to avoid feature resolver version diff --git a/Cargo.lock b/Cargo.lock index 1f833bdc3..0b298351e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-mutex" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479db852db25d9dbf6204e6cb6253698f175c15726470f78af0d918e99d6156e" +dependencies = [ + "event-listener", +] + [[package]] name = "async-native-tls" version = "0.4.0" @@ -216,6 +225,54 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "axum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2cc6e8e8c993cb61a005fab8c1e5093a29199b7253b05a6883999312935c1ff" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.13.0", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa 1.0.2", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sha-1 0.10.0", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-http", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4d047478b986f14a13edad31a009e2e05cb241f9805d0d75e4cba4e129ad4d" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", +] + [[package]] name = "backtrace" version = "0.3.66" @@ -518,6 +575,12 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" +[[package]] +name = "convert_case" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" + [[package]] name = "core-foundation" version = "0.9.3" @@ -712,8 +775,28 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.10.2", + "darling_macro 0.10.2", +] + +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529658bdda7fd6769b8614be250cdcfc3aeb0ee72fe66f9e41e5e5eb73eac02" +dependencies = [ + "darling_core 0.14.1", + "darling_macro 0.14.1", ] [[package]] @@ -726,7 +809,35 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.9.3", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "649c91bc01e8b1eac09fb91e8dbc7d517684ca6be8ebc75bb9cafc894f9fdb6f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", "syn", ] @@ -736,7 +847,29 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" dependencies = [ - "darling_core", + "darling_core 0.10.2", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc69c5bfcbd2fc09a0f38451d2daf0e372e367986a83906d1b0dbc88134fb5" +dependencies = [ + "darling_core 0.14.1", "quote", "syn", ] @@ -828,6 +961,26 @@ dependencies = [ "uuid 1.1.2", ] +[[package]] +name = "deltachat-jsonrpc" +version = "1.86.0" +dependencies = [ + "anyhow", + "async-channel", + "axum", + "deltachat", + "env_logger 0.9.0", + "futures", + "log", + "num-traits", + "serde", + "serde_json", + "tempfile", + "tokio", + "typescript-type-def", + "yerpc", +] + [[package]] name = "deltachat_derive" version = "2.0.0" @@ -842,6 +995,7 @@ version = "1.92.0" dependencies = [ "anyhow", "deltachat", + "deltachat-jsonrpc", "human-panic", "libc", "num-traits", @@ -869,7 +1023,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0" dependencies = [ - "darling", + "darling 0.10.2", "derive_builder_core", "proc-macro2", "quote", @@ -882,7 +1036,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef" dependencies = [ - "darling", + "darling 0.10.2", "proc-macro2", "quote", "syn", @@ -1136,7 +1290,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" dependencies = [ "atty", - "humantime", + "humantime 1.3.0", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "env_logger" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +dependencies = [ + "atty", + "humantime 2.1.0", "log", "regex", "termcolor", @@ -1550,6 +1717,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" + [[package]] name = "httparse" version = "1.7.1" @@ -1592,6 +1765,12 @@ dependencies = [ "quick-error 1.2.3", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.19" @@ -1885,6 +2064,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "matchit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cbba799671b762df5a175adf59ce145165747bb891505c43d09aefbbf38beb" + [[package]] name = "md-5" version = "0.9.1" @@ -2411,7 +2596,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" dependencies = [ - "env_logger", + "env_logger 0.7.1", "log", ] @@ -3050,6 +3235,15 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "1.5.0" @@ -3130,6 +3324,12 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strum" version = "0.24.1" @@ -3166,6 +3366,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8" + [[package]] name = "synstructure" version = "0.12.6" @@ -3298,6 +3504,7 @@ dependencies = [ "once_cell", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "winapi", @@ -3350,6 +3557,18 @@ dependencies = [ "xattr", ] +[[package]] +name = "tokio-tungstenite" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06cda1232a49558c46f8a504d5b93101d42c0bf7f911f12a105ba48168f821ae" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.3" @@ -3373,6 +3592,47 @@ dependencies = [ "serde", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c530c8675c1dbf98facee631536fa116b5fb6382d7dd6dc1b118d970eafe3ba" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62" + [[package]] name = "tower-service" version = "0.3.2" @@ -3386,10 +3646,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" dependencies = [ "cfg-if", + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c75893af559bc8e10716548bdef5cb2b983f8e637db9d0e15126b61b484ee2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.28" @@ -3450,6 +3723,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "tungstenite" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96a2dea40e7570482f28eb57afbe42d97551905da6a9400acc5c328d24004f5" +dependencies = [ + "base64 0.13.0", + "byteorder", + "bytes", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha-1 0.10.0", + "thiserror", + "url", + "utf-8", +] + [[package]] name = "twofish" version = "0.6.0" @@ -3467,6 +3759,30 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "typescript-type-def" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b4d7f4a1cf8923b722bd56456e0dd560869a37fb849169f18b848f261f01b96" +dependencies = [ + "serde_json", + "typescript-type-def-derive", +] + +[[package]] +name = "typescript-type-def-derive" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8b246d662eb07a8627513dc78bad0b79da6d05e8224ecd559963efc597af0" +dependencies = [ + "darling 0.13.4", + "ident_case", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-bidi" version = "0.3.8" @@ -3527,6 +3843,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8parse" version = "0.2.0" @@ -3815,6 +4137,41 @@ dependencies = [ "libc", ] +[[package]] +name = "yerpc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1baa6fce4cf16d1cff91b557baceac3e363106f66e555fff906a7f82dce8153" +dependencies = [ + "anyhow", + "async-channel", + "async-mutex", + "async-trait", + "axum", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "tokio", + "tracing", + "typescript-type-def", + "yerpc_derive", +] + +[[package]] +name = "yerpc_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f944ca6789bc55ddc86839478f6d49c9d2a66e130f69fd1f8d171b3108990" +dependencies = [ + "convert_case", + "darling 0.14.1", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zeroize" version = "1.5.6" diff --git a/Cargo.toml b/Cargo.toml index 324e3231a..7d44a3ba1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ tokio = { version = "1", features = ["parking_lot", "rt-multi-thread", "macros"] members = [ "deltachat-ffi", "deltachat_derive", + "deltachat-jsonrpc" ] [[example]] diff --git a/deltachat-ffi/Cargo.toml b/deltachat-ffi/Cargo.toml index e3182c926..a8e4c3db8 100644 --- a/deltachat-ffi/Cargo.toml +++ b/deltachat-ffi/Cargo.toml @@ -16,6 +16,7 @@ crate-type = ["cdylib", "staticlib"] [dependencies] deltachat = { path = "../", default-features = false } +deltachat-jsonrpc = { path = "../deltachat-jsonrpc", optional = true } libc = "0.2" human-panic = "1" num-traits = "0.2" @@ -30,4 +31,5 @@ once_cell = "1.13.0" default = ["vendored"] vendored = ["deltachat/vendored"] nightly = ["deltachat/nightly"] +jsonrpc = ["deltachat-jsonrpc"] diff --git a/deltachat-ffi/deltachat.h b/deltachat-ffi/deltachat.h index 3db00c2dd..6e8dedf85 100644 --- a/deltachat-ffi/deltachat.h +++ b/deltachat-ffi/deltachat.h @@ -23,7 +23,7 @@ typedef struct _dc_provider dc_provider_t; typedef struct _dc_event dc_event_t; typedef struct _dc_event_emitter dc_event_emitter_t; typedef struct _dc_accounts_event_emitter dc_accounts_event_emitter_t; - +typedef struct _dc_jsonrpc_instance dc_jsonrpc_instance_t; /** * @mainpage Getting started @@ -5192,6 +5192,55 @@ int64_t dc_lot_get_timestamp (const dc_lot_t* lot); */ + +/** + * @class dc_jsonrpc_instance_t + * + * Opaque object for using the json rpc api from the cffi bindings. + */ + +/** + * Create the jsonrpc instance that is used to call the jsonrpc. + * + * @memberof dc_accounts_t + * @param account_manager The accounts object as created by dc_accounts_new(). + * @return Returns the jsonrpc instance, NULL on errors. + * Must be freed using dc_jsonrpc_unref() after usage. + * + */ +dc_jsonrpc_instance_t* dc_jsonrpc_init(dc_accounts_t* account_manager); + +/** + * Free a jsonrpc instance. + * + * @memberof dc_jsonrpc_instance_t + * @param jsonrpc_instance jsonrpc instance as returned from dc_jsonrpc_init(). + * If NULL is given, nothing is done and an error is logged. + */ +void dc_jsonrpc_unref(dc_jsonrpc_instance_t* jsonrpc_instance); + +/** + * Makes an asynchronous jsonrpc request, + * returns immediately and once the result is ready it can be retrieved via dc_jsonrpc_next_response() + * the jsonrpc specification defines an invocation id that can then be used to match request and response. + * + * @memberof dc_jsonrpc_instance_t + * @param jsonrpc_instance jsonrpc instance as returned from dc_jsonrpc_init(). + * @param request JSON-RPC request as string + */ +void dc_jsonrpc_request(dc_jsonrpc_instance_t* jsonrpc_instance, const char* request); + +/** + * Get the next json_rpc response, blocks until there is a new event, so call this in a loop from a thread. + * + * @memberof dc_jsonrpc_instance_t + * @param jsonrpc_instance jsonrpc instance as returned from dc_jsonrpc_init(). + * @return JSON-RPC response as string, must be freed using dc_str_unref() after usage. + * If NULL is returned, the accounts_t belonging to the jsonrpc instance is unref'd and no more events will come; + * in this case, free the jsonrpc instance using dc_jsonrpc_unref(). + */ +char* dc_jsonrpc_next_response(dc_jsonrpc_instance_t* jsonrpc_instance); + /** * @class dc_event_emitter_t * diff --git a/deltachat-ffi/src/lib.rs b/deltachat-ffi/src/lib.rs index 7143c1f8c..d355aab98 100644 --- a/deltachat-ffi/src/lib.rs +++ b/deltachat-ffi/src/lib.rs @@ -19,6 +19,7 @@ use std::future::Future; use std::ops::Deref; use std::ptr; use std::str::FromStr; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use anyhow::Context as _; @@ -4118,11 +4119,11 @@ pub unsafe extern "C" fn dc_provider_unref(provider: *mut dc_provider_t) { /// Reader-writer lock wrapper for accounts manager to guarantee thread safety when using /// `dc_accounts_t` in multiple threads at once. pub struct AccountsWrapper { - inner: RwLock, + inner: Arc>, } impl Deref for AccountsWrapper { - type Target = RwLock; + type Target = Arc>; fn deref(&self) -> &Self::Target { &self.inner @@ -4131,7 +4132,7 @@ impl Deref for AccountsWrapper { impl AccountsWrapper { fn new(accounts: Accounts) -> Self { - let inner = RwLock::new(accounts); + let inner = Arc::new(RwLock::new(accounts)); Self { inner } } } @@ -4447,3 +4448,77 @@ pub unsafe extern "C" fn dc_accounts_get_next_event( .map(|ev| Box::into_raw(Box::new(ev))) .unwrap_or_else(ptr::null_mut) } + +#[cfg(feature = "jsonrpc")] +mod jsonrpc { + use super::*; + use deltachat_jsonrpc::api::CommandApi; + use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession}; + + pub struct dc_jsonrpc_instance_t { + receiver: OutReceiver, + handle: RpcSession, + } + + #[no_mangle] + pub unsafe extern "C" fn dc_jsonrpc_init( + account_manager: *mut dc_accounts_t, + ) -> *mut dc_jsonrpc_instance_t { + if account_manager.is_null() { + eprintln!("ignoring careless call to dc_jsonrpc_init()"); + return ptr::null_mut(); + } + + let cmd_api = + deltachat_jsonrpc::api::CommandApi::from_arc((*account_manager).inner.clone()); + + let (request_handle, receiver) = RpcClient::new(); + let handle = RpcSession::new(request_handle, cmd_api); + + let instance = dc_jsonrpc_instance_t { receiver, handle }; + + Box::into_raw(Box::new(instance)) + } + + #[no_mangle] + pub unsafe extern "C" fn dc_jsonrpc_unref(jsonrpc_instance: *mut dc_jsonrpc_instance_t) { + if jsonrpc_instance.is_null() { + eprintln!("ignoring careless call to dc_jsonrpc_unref()"); + return; + } + + Box::from_raw(jsonrpc_instance); + } + + #[no_mangle] + pub unsafe extern "C" fn dc_jsonrpc_request( + jsonrpc_instance: *mut dc_jsonrpc_instance_t, + request: *const libc::c_char, + ) { + if jsonrpc_instance.is_null() || request.is_null() { + eprintln!("ignoring careless call to dc_jsonrpc_request()"); + return; + } + + let api = &*jsonrpc_instance; + let handle = &api.handle; + let request = to_string_lossy(request); + spawn(async move { + handle.handle_incoming(&request).await; + }); + } + + #[no_mangle] + pub unsafe extern "C" fn dc_jsonrpc_next_response( + jsonrpc_instance: *mut dc_jsonrpc_instance_t, + ) -> *mut libc::c_char { + if jsonrpc_instance.is_null() { + eprintln!("ignoring careless call to dc_jsonrpc_next_response()"); + return ptr::null_mut(); + } + let api = &*jsonrpc_instance; + block_on(api.receiver.recv()) + .map(|result| serde_json::to_string(&result).unwrap_or_default().strdup()) + .unwrap_or(ptr::null_mut()) + } +} diff --git a/deltachat-jsonrpc/.gitignore b/deltachat-jsonrpc/.gitignore new file mode 100644 index 000000000..c12c4a8ba --- /dev/null +++ b/deltachat-jsonrpc/.gitignore @@ -0,0 +1,3 @@ +accounts/ + +.cargo \ No newline at end of file diff --git a/deltachat-jsonrpc/Cargo.toml b/deltachat-jsonrpc/Cargo.toml new file mode 100644 index 000000000..5666489dd --- /dev/null +++ b/deltachat-jsonrpc/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "deltachat-jsonrpc" +version = "1.86.0" +description = "DeltaChat JSON-RPC API" +authors = ["Delta Chat Developers (ML) "] +edition = "2021" +default-run = "deltachat-jsonrpc-server" +license = "MPL-2.0" + +[[bin]] +name = "deltachat-jsonrpc-server" +path = "src/webserver.rs" +required-features = ["webserver"] + +[dependencies] +anyhow = "1" +deltachat = { path = ".." } +num-traits = "0.2" +serde = { version = "1.0", features = ["derive"] } +tempfile = "3.3.0" +log = "0.4" +async-channel = { version = "1.6.1" } +futures = { version = "0.3.19" } +serde_json = "1.0.75" +yerpc = { version = "^0.3.1", features = ["anyhow_expose"] } +typescript-type-def = { version = "0.5.3", features = ["json_value"] } +tokio = { version = "1.19.2" } + +# optional dependencies +axum = { version = "0.5.9", optional = true, features = ["ws"] } +env_logger = { version = "0.9.0", optional = true } + +[dev-dependencies] +tokio = { version = "1.19.2", features = ["full", "rt-multi-thread"] } + + +[features] +default = [] +webserver = ["env_logger", "axum", "tokio/full", "yerpc/support-axum"] diff --git a/deltachat-jsonrpc/README.md b/deltachat-jsonrpc/README.md new file mode 100644 index 000000000..c76329814 --- /dev/null +++ b/deltachat-jsonrpc/README.md @@ -0,0 +1,123 @@ +# deltachat-jsonrpc + +This crate provides a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) interface to DeltaChat. + +The JSON-RPC API is exposed in two fashions: + +* A executable that exposes the JSON-RPC API through a [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) server running on localhost. +* The JSON-RPC API can also be called through the [C FFI](../deltachat-ffi). The C FFI needs to be built with the `jsonrpc` feature. It will then expose the functions `dc_jsonrpc_init`, `dc_jsonrpc_request`, `dc_jsonrpc_next_response` and `dc_jsonrpc_unref`. See the docs in the [header file](../deltachat-ffi/deltachat.h) for details. + +We also include a JavaScript and TypeScript client for the JSON-RPC API. The source for this is in the [`typescript`](typescript) folder. The client can easily be used with the WebSocket server to build DeltaChat apps for web browsers or Node.js. See the [examples](typescript/example) for details. + +## Usage + +#### Running the WebSocket server + +From within this folder, you can start the WebSocket server with the following command: + +```sh +cargo run --features webserver +``` + +If you want to use the server in a production setup, first build it in release mode: + +```sh +cargo build --features webserver --release +``` +You will then find the `deltachat-jsonrpc-server` executable in your `target/release` folder. + +The executable currently does not support any command-line arguments. By default, once started it will accept WebSocket connections on `ws://localhost:20808/ws`. It will store the persistent configuration and databases in a `./accounts` folder relative to the directory from where it is started. + +The server can be configured with environment variables: + +|variable|default|description| +|-|-|-| +|`DC_PORT`|`20808`|port to listen on| +|`DC_ACCOUNTS_PATH`|`./accounts`|path to storage directory| + +If you are targetting other architectures (like KaiOS or Android), the webserver binary can be cross-compiled easily with [rust-cross](https://github.com/cross-rs/cross): + +```sh +cross build --features=webserver --target armv7-linux-androideabi --release +``` + +#### Using the TypeScript/JavaScript client + +The package includes a JavaScript/TypeScript client which is partially auto-generated through the JSON-RPC library used by this crate ([yerpc](https://github.com/Frando/yerpc/)). Find the source in the [`typescript`](typescript) folder. + +To use it locally, first install the dependencies and compile the TypeScript code to JavaScript: +```sh +cd typescript +npm install +npm run build +``` + +The JavaScript client is not yet published on NPM (but will likely be soon). Currently, it is recommended to vendor the bundled build. After running `npm run build` as documented above, there will be a file `dist/deltachat.bundle.js`. This is an ESM module containing all dependencies. Copy this file to your project and import the DeltaChat class. + + +```typescript +import { DeltaChat } from './deltachat.bundle.js' +const dc = new DeltaChat('ws://localhost:20808/ws') +const accounts = await dc.rpc.getAllAccounts() +console.log('accounts', accounts) +``` + +A script is included to build autogenerated documentation, which includes all RPC methods: +```sh +cd typescript +npm run docs +``` +Then open the [`typescript/docs`](typescript/docs) folder in a web browser. + +## Development + +#### Running the example app + +We include a small demo web application that talks to the WebSocket server. It can be used for testing. Feel invited to expand this. + +```sh +cd typescript +npm run build +npm run example:build +npm run example:start +``` +Then, open [`http://localhost:8080/example.html`](http://localhost:8080/example.html) in a web browser. + +Run `npm run example:dev` to live-rebuild the example app when files changes. + +### Testing + +The crate includes both a basic Rust smoke test and more featureful integration tests that use the TypeScript client. + +#### Rust tests + +To run the Rust test, use this command: + +``` +cargo test +``` + +#### TypeScript tests + +``` +cd typescript +npm run test +``` + +This will build the `deltachat-jsonrpc-server` binary and then run a test suite against the WebSocket server. + +The test suite includes some tests that need online connectivity and a way to create test email accounts. To run these tests, talk to DeltaChat developers to get a token for the `testrun.org` service, or use a local instance of [`mailadm`](https://github.com/deltachat/docker-mailadm). + +Then, set the `DCC_NEW_TMP_EMAIL` environment variable to your mailadm token before running the tests. + +``` +DCC_NEW_TMP_EMAIL=https://testrun.org/new_email?t=yourtoken npm run test +``` + +#### Test Coverage + +Running `npm run test` will report test coverage. For the coverage to be accurate the online tests need to be run. + +> If you are offline and want to see the coverage results anyway (even though they are inaccurate), you can bypass the errors of the online tests by setting the `COVERAGE_OFFLINE=1` environment variable. + +A summary of the coverage will be reported in the terminal after the test run. Open `coverage/index.html` in a web browser for a detailed report. diff --git a/deltachat-jsonrpc/TODO.md b/deltachat-jsonrpc/TODO.md new file mode 100644 index 000000000..c09079a01 --- /dev/null +++ b/deltachat-jsonrpc/TODO.md @@ -0,0 +1,28 @@ +# TODO + +- [ ] different test type to simulate two devices: to test autocrypt_initiate_key_transfer & autocrypt_continue_key_transfer + +## MVP - Websocket server&client + +For kaiOS and other experiments, like a deltachat "web" over network from an android phone. + +- [ ] coverage for a majority of the API +- [ ] Blobs served +- [ ] Blob upload (for attachments, setting profile-picture, importing backup and so on) +- [ ] other way blobs can be addressed when using websocket vs. jsonrpc over dc-node +- [ ] Web push API? At least some kind of notification hook closure this lib can accept. + +### Other Ideas for the Websocket server + +- [ ] make sure there can only be one connection at a time to the ws + - why? , it could give problems if its commanded from multiple connections +- [ ] encrypted connection? +- [ ] authenticated connection? +- [ ] Look into unit-testing for the proc macros? +- [ ] proc macro taking over doc comments to generated typescript file + +## Desktop Apis + +Incomplete todo for desktop api porting, just some remainders for points that might need more work: + +- [ ] manual start/stop io functions in the api for context and accounts, so "not syncing all accounts" can still be done in desktop -> webserver should then not do start io on all accounts by default diff --git a/deltachat-jsonrpc/src/api/events.rs b/deltachat-jsonrpc/src/api/events.rs new file mode 100644 index 000000000..ad0fb5faf --- /dev/null +++ b/deltachat-jsonrpc/src/api/events.rs @@ -0,0 +1,157 @@ +use deltachat::{Event, EventType}; +use serde::Serialize; +use serde_json::{json, Value}; +use typescript_type_def::TypeDef; + +pub fn event_to_json_rpc_notification(event: Event) -> Value { + let (field1, field2): (Value, Value) = match &event.typ { + // events with a single string in field1 + EventType::Info(txt) + | EventType::SmtpConnected(txt) + | EventType::ImapConnected(txt) + | EventType::SmtpMessageSent(txt) + | EventType::ImapMessageDeleted(txt) + | EventType::ImapMessageMoved(txt) + | EventType::NewBlobFile(txt) + | EventType::DeletedBlobFile(txt) + | EventType::Warning(txt) + | EventType::Error(txt) + | EventType::ErrorSelfNotInGroup(txt) => (json!(txt), Value::Null), + EventType::ImexFileWritten(path) => (json!(path.to_str()), Value::Null), + // single number + EventType::MsgsNoticed(chat_id) | EventType::ChatModified(chat_id) => { + (json!(chat_id), Value::Null) + } + EventType::ImexProgress(progress) => (json!(progress), Value::Null), + // both fields contain numbers + EventType::MsgsChanged { chat_id, msg_id } + | EventType::IncomingMsg { chat_id, msg_id } + | EventType::MsgDelivered { chat_id, msg_id } + | EventType::MsgFailed { chat_id, msg_id } + | EventType::MsgRead { chat_id, msg_id } => (json!(chat_id), json!(msg_id)), + EventType::ChatEphemeralTimerModified { chat_id, timer } => (json!(chat_id), json!(timer)), + EventType::SecurejoinInviterProgress { + contact_id, + progress, + } + | EventType::SecurejoinJoinerProgress { + contact_id, + progress, + } => (json!(contact_id), json!(progress)), + // field 1 number or null + EventType::ContactsChanged(maybe_number) | EventType::LocationChanged(maybe_number) => ( + match maybe_number { + Some(number) => json!(number), + None => Value::Null, + }, + Value::Null, + ), + // number and maybe string + EventType::ConfigureProgress { progress, comment } => ( + json!(progress), + match comment { + Some(content) => json!(content), + None => Value::Null, + }, + ), + EventType::ConnectivityChanged => (Value::Null, Value::Null), + EventType::SelfavatarChanged => (Value::Null, Value::Null), + EventType::WebxdcStatusUpdate { + msg_id, + status_update_serial, + } => (json!(msg_id), json!(status_update_serial)), + }; + + let id: EventTypeName = event.typ.into(); + json!({ + "id": id, + "contextId": event.id, + "field1": field1, + "field2": field2 + }) +} + +#[derive(Serialize, TypeDef)] +pub enum EventTypeName { + Info, + SmtpConnected, + ImapConnected, + SmtpMessageSent, + ImapMessageDeleted, + ImapMessageMoved, + NewBlobFile, + DeletedBlobFile, + Warning, + Error, + ErrorSelfNotInGroup, + MsgsChanged, + IncomingMsg, + MsgsNoticed, + MsgDelivered, + MsgFailed, + MsgRead, + ChatModified, + ChatEphemeralTimerModified, + ContactsChanged, + LocationChanged, + ConfigureProgress, + ImexProgress, + ImexFileWritten, + SecurejoinInviterProgress, + SecurejoinJoinerProgress, + ConnectivityChanged, + SelfavatarChanged, + WebxdcStatusUpdate, +} + +impl From for EventTypeName { + fn from(event: EventType) -> Self { + use EventTypeName::*; + match event { + EventType::Info(_) => Info, + EventType::SmtpConnected(_) => SmtpConnected, + EventType::ImapConnected(_) => ImapConnected, + EventType::SmtpMessageSent(_) => SmtpMessageSent, + EventType::ImapMessageDeleted(_) => ImapMessageDeleted, + EventType::ImapMessageMoved(_) => ImapMessageMoved, + EventType::NewBlobFile(_) => NewBlobFile, + EventType::DeletedBlobFile(_) => DeletedBlobFile, + EventType::Warning(_) => Warning, + EventType::Error(_) => Error, + EventType::ErrorSelfNotInGroup(_) => ErrorSelfNotInGroup, + EventType::MsgsChanged { .. } => MsgsChanged, + EventType::IncomingMsg { .. } => IncomingMsg, + EventType::MsgsNoticed(_) => MsgsNoticed, + EventType::MsgDelivered { .. } => MsgDelivered, + EventType::MsgFailed { .. } => MsgFailed, + EventType::MsgRead { .. } => MsgRead, + EventType::ChatModified(_) => ChatModified, + EventType::ChatEphemeralTimerModified { .. } => ChatEphemeralTimerModified, + EventType::ContactsChanged(_) => ContactsChanged, + EventType::LocationChanged(_) => LocationChanged, + EventType::ConfigureProgress { .. } => ConfigureProgress, + EventType::ImexProgress(_) => ImexProgress, + EventType::ImexFileWritten(_) => ImexFileWritten, + EventType::SecurejoinInviterProgress { .. } => SecurejoinInviterProgress, + EventType::SecurejoinJoinerProgress { .. } => SecurejoinJoinerProgress, + EventType::ConnectivityChanged => ConnectivityChanged, + EventType::SelfavatarChanged => SelfavatarChanged, + EventType::WebxdcStatusUpdate { .. } => WebxdcStatusUpdate, + } + } +} + +#[cfg(test)] +#[test] +fn generate_events_ts_types_definition() { + let events = { + let mut buf = Vec::new(); + let options = typescript_type_def::DefinitionFileOptions { + root_namespace: None, + ..typescript_type_def::DefinitionFileOptions::default() + }; + typescript_type_def::write_definition_file::<_, EventTypeName>(&mut buf, options).unwrap(); + String::from_utf8(buf).unwrap() + }; + std::fs::write("typescript/generated/events.ts", events).unwrap(); +} diff --git a/deltachat-jsonrpc/src/api/mod.rs b/deltachat-jsonrpc/src/api/mod.rs new file mode 100644 index 000000000..20bc39bb7 --- /dev/null +++ b/deltachat-jsonrpc/src/api/mod.rs @@ -0,0 +1,691 @@ +use anyhow::{anyhow, bail, Context, Result}; +use deltachat::{ + chat::{get_chat_media, get_chat_msgs, ChatId}, + chatlist::Chatlist, + config::Config, + contact::{may_be_valid_addr, Contact, ContactId}, + context::get_info, + message::{Message, MsgId, Viewtype}, + provider::get_provider_info, + qr, + webxdc::StatusUpdateSerial, +}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::{collections::HashMap, str::FromStr}; +use tokio::sync::RwLock; +use yerpc::rpc; + +pub use deltachat::accounts::Accounts; + +pub mod events; +pub mod types; + +use crate::api::types::chat_list::{get_chat_list_item_by_id, ChatListItemFetchResult}; +use crate::api::types::QrObject; + +use types::account::Account; +use types::chat::FullChat; +use types::chat_list::ChatListEntry; +use types::contact::ContactObject; +use types::message::MessageObject; +use types::provider_info::ProviderInfo; +use types::webxdc::WebxdcMessageInfo; + +use self::types::message::MessageViewtype; + +#[derive(Clone, Debug)] +pub struct CommandApi { + pub(crate) accounts: Arc>, +} + +impl CommandApi { + pub fn new(accounts: Accounts) -> Self { + CommandApi { + accounts: Arc::new(RwLock::new(accounts)), + } + } + + #[allow(dead_code)] + pub fn from_arc(accounts: Arc>) -> Self { + CommandApi { accounts } + } + + async fn get_context(&self, id: u32) -> Result { + let sc = self + .accounts + .read() + .await + .get_account(id) + .await + .ok_or_else(|| anyhow!("account with id {} not found", id))?; + Ok(sc) + } +} + +#[rpc(all_positional, ts_outdir = "typescript/generated")] +impl CommandApi { + // --------------------------------------------- + // Misc top level functions + // --------------------------------------------- + + /// Check if an email address is valid. + async fn check_email_validity(&self, email: String) -> bool { + may_be_valid_addr(&email) + } + + /// Get general system info. + async fn get_system_info(&self) -> BTreeMap<&'static str, String> { + get_info() + } + + // --------------------------------------------- + // Account Management + // --------------------------------------------- + + async fn add_account(&self) -> Result { + self.accounts.write().await.add_account().await + } + + async fn remove_account(&self, account_id: u32) -> Result<()> { + self.accounts.write().await.remove_account(account_id).await + } + + async fn get_all_account_ids(&self) -> Vec { + self.accounts.read().await.get_all().await + } + + /// Select account id for internally selected state. + /// TODO: Likely this is deprecated as all methods take an account id now. + async fn select_account(&self, id: u32) -> Result<()> { + self.accounts.write().await.select_account(id).await + } + + /// Get the selected account id of the internal state.. + /// TODO: Likely this is deprecated as all methods take an account id now. + async fn get_selected_account_id(&self) -> Option { + self.accounts.read().await.get_selected_account_id().await + } + + /// Get a list of all configured accounts. + async fn get_all_accounts(&self) -> Result> { + let mut accounts = Vec::new(); + for id in self.accounts.read().await.get_all().await { + let context_option = self.accounts.read().await.get_account(id).await; + if let Some(ctx) = context_option { + accounts.push(Account::from_context(&ctx, id).await?) + } else { + println!("account with id {} doesn't exist anymore", id); + } + } + Ok(accounts) + } + + // --------------------------------------------- + // Methods that work on individual accounts + // --------------------------------------------- + + /// Get top-level info for an account. + async fn get_account_info(&self, account_id: u32) -> Result { + let context_option = self.accounts.read().await.get_account(account_id).await; + if let Some(ctx) = context_option { + Ok(Account::from_context(&ctx, account_id).await?) + } else { + Err(anyhow!( + "account with id {} doesn't exist anymore", + account_id + )) + } + } + + /// Returns provider for the given domain. + /// + /// This function looks up domain in offline database. + /// + /// For compatibility, email address can be passed to this function + /// instead of the domain. + async fn get_provider_info( + &self, + account_id: u32, + email: String, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + + let socks5_enabled = ctx + .get_config_bool(deltachat::config::Config::Socks5Enabled) + .await?; + + let provider_info = + get_provider_info(&ctx, email.split('@').last().unwrap_or(""), socks5_enabled).await; + Ok(ProviderInfo::from_dc_type(provider_info)) + } + + /// Checks if the context is already configured. + async fn is_configured(&self, account_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + ctx.is_configured().await + } + + /// Get system info for an account. + async fn get_info(&self, account_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + ctx.get_info().await + } + + async fn set_config(&self, account_id: u32, key: String, value: Option) -> Result<()> { + let ctx = self.get_context(account_id).await?; + set_config(&ctx, &key, value.as_deref()).await + } + + async fn batch_set_config( + &self, + account_id: u32, + config: HashMap>, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + for (key, value) in config.into_iter() { + set_config(&ctx, &key, value.as_deref()) + .await + .with_context(|| format!("Can't set {} to {:?}", key, value))?; + } + Ok(()) + } + + /// Set configuration values from a QR code. (technically from the URI that is stored in the qrcode) + /// Before this function is called, dc_check_qr() should confirm the type of the + /// QR code is DC_QR_ACCOUNT or DC_QR_WEBRTC_INSTANCE. + /// + /// Internally, the function will call dc_set_config() with the appropriate keys, + /// e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT + /// or `webrtc_instance` for DC_QR_WEBRTC_INSTANCE. + async fn set_config_from_qr(&self, account_id: u32, qr_content: String) -> Result<()> { + let ctx = self.get_context(account_id).await?; + qr::set_config_from_qr(&ctx, &qr_content).await + } + + async fn check_qr(&self, account_id: u32, qr_content: String) -> Result { + let ctx = self.get_context(account_id).await?; + let qr = qr::check_qr(&ctx, &qr_content).await?; + let qr_object = QrObject::from(qr); + Ok(qr_object) + } + + async fn get_config(&self, account_id: u32, key: String) -> Result> { + let ctx = self.get_context(account_id).await?; + get_config(&ctx, &key).await + } + + async fn batch_get_config( + &self, + account_id: u32, + keys: Vec, + ) -> Result>> { + let ctx = self.get_context(account_id).await?; + let mut result: HashMap> = HashMap::new(); + for key in keys { + result.insert(key.clone(), get_config(&ctx, &key).await?); + } + Ok(result) + } + + /// Configures this account with the currently set parameters. + /// Setup the credential config before calling this. + async fn configure(&self, account_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ctx.stop_io().await; + let result = ctx.configure().await; + if result.is_err() { + if let Ok(true) = ctx.is_configured().await { + ctx.start_io().await; + } + return result; + } + ctx.start_io().await; + Ok(()) + } + + /// Signal an ongoing process to stop. + async fn stop_ongoing_process(&self, account_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ctx.stop_ongoing().await; + Ok(()) + } + + /// Returns the message IDs of all _fresh_ messages of any chat. + /// Typically used for implementing notification summaries + /// or badge counters e.g. on the app icon. + /// The list is already sorted and starts with the most recent fresh message. + /// + /// Messages belonging to muted chats or to the contact requests are not returned; + /// these messages should not be notified + /// and also badge counters should not include these messages. + /// + /// To get the number of fresh messages for a single chat, muted or not, + /// use `get_fresh_msg_cnt()`. + async fn get_fresh_msgs(&self, account_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + Ok(ctx + .get_fresh_msgs() + .await? + .iter() + .map(|msg_id| msg_id.to_u32()) + .collect()) + } + + /// Get the number of _fresh_ messages in a chat. + /// Typically used to implement a badge with a number in the chatlist. + /// + /// If the specified chat is muted, + /// the UI should show the badge counter "less obtrusive", + /// e.g. using "gray" instead of "red" color. + async fn get_fresh_msg_cnt(&self, account_id: u32, chat_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + ChatId::new(chat_id).get_fresh_msg_cnt(&ctx).await + } + + // --------------------------------------------- + // autocrypt + // --------------------------------------------- + + async fn autocrypt_initiate_key_transfer(&self, account_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + deltachat::imex::initiate_key_transfer(&ctx).await + } + + async fn autocrypt_continue_key_transfer( + &self, + account_id: u32, + message_id: u32, + setup_code: String, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + deltachat::imex::continue_key_transfer(&ctx, MsgId::new(message_id), &setup_code).await + } + + // --------------------------------------------- + // chat list + // --------------------------------------------- + + async fn get_chatlist_entries( + &self, + account_id: u32, + list_flags: Option, + query_string: Option, + query_contact_id: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let list = Chatlist::try_load( + &ctx, + list_flags.unwrap_or(0) as usize, + query_string.as_deref(), + query_contact_id.map(ContactId::new), + ) + .await?; + let mut l: Vec = Vec::with_capacity(list.len()); + for i in 0..list.len() { + l.push(ChatListEntry( + list.get_chat_id(i)?.to_u32(), + list.get_msg_id(i)?.unwrap_or_default().to_u32(), + )); + } + Ok(l) + } + + async fn get_chatlist_items_by_entries( + &self, + account_id: u32, + entries: Vec, + ) -> Result> { + // todo custom json deserializer for ChatListEntry? + let ctx = self.get_context(account_id).await?; + let mut result: HashMap = + HashMap::with_capacity(entries.len()); + for entry in entries.iter() { + result.insert( + entry.0, + match get_chat_list_item_by_id(&ctx, entry).await { + Ok(res) => res, + Err(err) => ChatListItemFetchResult::Error { + id: entry.0, + error: format!("{:?}", err), + }, + }, + ); + } + Ok(result) + } + + // --------------------------------------------- + // chat + // --------------------------------------------- + + async fn chatlist_get_full_chat_by_id( + &self, + account_id: u32, + chat_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + FullChat::try_from_dc_chat_id(&ctx, chat_id).await + } + + async fn accept_chat(&self, account_id: u32, chat_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ChatId::new(chat_id).accept(&ctx).await + } + + async fn block_chat(&self, account_id: u32, chat_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ChatId::new(chat_id).block(&ctx).await + } + + // for now only text messages, because we only used text messages in desktop thusfar + async fn add_device_message( + &self, + account_id: u32, + label: String, + text: String, + ) -> Result { + let ctx = self.get_context(account_id).await?; + let mut msg = Message::new(Viewtype::Text); + msg.set_text(Some(text)); + let message_id = + deltachat::chat::add_device_msg(&ctx, Some(&label), Some(&mut msg)).await?; + Ok(message_id.to_u32()) + } + + // --------------------------------------------- + // message list + // --------------------------------------------- + + async fn message_list_get_message_ids( + &self, + account_id: u32, + chat_id: u32, + flags: u32, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let msg = get_chat_msgs(&ctx, ChatId::new(chat_id), flags).await?; + Ok(msg + .iter() + .filter_map(|chat_item| match chat_item { + deltachat::chat::ChatItem::Message { msg_id } => Some(msg_id.to_u32()), + _ => None, + }) + .collect()) + } + + async fn message_get_message(&self, account_id: u32, message_id: u32) -> Result { + let ctx = self.get_context(account_id).await?; + MessageObject::from_message_id(&ctx, message_id).await + } + + async fn message_get_messages( + &self, + account_id: u32, + message_ids: Vec, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let mut messages: HashMap = HashMap::new(); + for message_id in message_ids { + messages.insert( + message_id, + MessageObject::from_message_id(&ctx, message_id).await?, + ); + } + Ok(messages) + } + + // --------------------------------------------- + // contact + // --------------------------------------------- + + /// Get a single contact options by ID. + async fn contacts_get_contact( + &self, + account_id: u32, + contact_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + let contact_id = ContactId::new(contact_id); + + ContactObject::try_from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, contact_id).await?, + ) + .await + } + + /// Add a single contact as a result of an explicit user action. + /// + /// Returns contact id of the created or existing contact + async fn contacts_create_contact( + &self, + account_id: u32, + email: String, + name: Option, + ) -> Result { + let ctx = self.get_context(account_id).await?; + if !may_be_valid_addr(&email) { + bail!(anyhow!( + "provided email address is not a valid email address" + )) + } + let contact_id = Contact::create(&ctx, &name.unwrap_or_default(), &email).await?; + Ok(contact_id.to_u32()) + } + + /// Returns contact id of the created or existing DM chat with that contact + async fn contacts_create_chat_by_contact_id( + &self, + account_id: u32, + contact_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + let contact = Contact::get_by_id(&ctx, ContactId::new(contact_id)).await?; + ChatId::create_for_contact(&ctx, contact.id) + .await + .map(|id| id.to_u32()) + } + + async fn contacts_block(&self, account_id: u32, contact_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + Contact::block(&ctx, ContactId::new(contact_id)).await + } + + async fn contacts_unblock(&self, account_id: u32, contact_id: u32) -> Result<()> { + let ctx = self.get_context(account_id).await?; + Contact::unblock(&ctx, ContactId::new(contact_id)).await + } + + async fn contacts_get_blocked(&self, account_id: u32) -> Result> { + let ctx = self.get_context(account_id).await?; + let blocked_ids = Contact::get_all_blocked(&ctx).await?; + let mut contacts: Vec = Vec::with_capacity(blocked_ids.len()); + for id in blocked_ids { + contacts.push( + ContactObject::try_from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, id).await?, + ) + .await?, + ); + } + Ok(contacts) + } + + async fn contacts_get_contact_ids( + &self, + account_id: u32, + list_flags: u32, + query: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let contacts = Contact::get_all(&ctx, list_flags, query.as_deref()).await?; + Ok(contacts.into_iter().map(|c| c.to_u32()).collect()) + } + + /// Get a list of contacts. + /// (formerly called getContacts2 in desktop) + async fn contacts_get_contacts( + &self, + account_id: u32, + list_flags: u32, + query: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + let contact_ids = Contact::get_all(&ctx, list_flags, query.as_deref()).await?; + let mut contacts: Vec = Vec::with_capacity(contact_ids.len()); + for id in contact_ids { + contacts.push( + ContactObject::try_from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, id).await?, + ) + .await?, + ); + } + Ok(contacts) + } + + async fn contacts_get_contacts_by_ids( + &self, + account_id: u32, + ids: Vec, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + + let mut contacts = HashMap::with_capacity(ids.len()); + for id in ids { + contacts.insert( + id, + ContactObject::try_from_dc_contact( + &ctx, + deltachat::contact::Contact::get_by_id(&ctx, ContactId::new(id)).await?, + ) + .await?, + ); + } + Ok(contacts) + } + // --------------------------------------------- + // chat + // --------------------------------------------- + + /// Returns all message IDs of the given types in a chat. + /// Typically used to show a gallery. + /// + /// The list is already sorted and starts with the oldest message. + /// Clients should not try to re-sort the list as this would be an expensive action + /// and would result in inconsistencies between clients. + async fn chat_get_media( + &self, + account_id: u32, + chat_id: u32, + message_type: MessageViewtype, + or_message_type2: Option, + or_message_type3: Option, + ) -> Result> { + let ctx = self.get_context(account_id).await?; + + let msg_type = message_type.into(); + let or_msg_type2 = or_message_type2.map_or(Viewtype::Unknown, |v| v.into()); + let or_msg_type3 = or_message_type3.map_or(Viewtype::Unknown, |v| v.into()); + + let media = get_chat_media( + &ctx, + ChatId::new(chat_id), + msg_type, + or_msg_type2, + or_msg_type3, + ) + .await?; + Ok(media.iter().map(|msg_id| msg_id.to_u32()).collect()) + } + + // --------------------------------------------- + // webxdc + // --------------------------------------------- + + async fn webxdc_send_status_update( + &self, + account_id: u32, + instance_msg_id: u32, + update_str: String, + description: String, + ) -> Result<()> { + let ctx = self.get_context(account_id).await?; + ctx.send_webxdc_status_update(MsgId::new(instance_msg_id), &update_str, &description) + .await + } + + async fn webxdc_get_status_updates( + &self, + account_id: u32, + instance_msg_id: u32, + last_known_serial: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + ctx.get_webxdc_status_updates( + MsgId::new(instance_msg_id), + StatusUpdateSerial::new(last_known_serial), + ) + .await + } + + /// Get info from a webxdc message + async fn message_get_webxdc_info( + &self, + account_id: u32, + instance_msg_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + WebxdcMessageInfo::get_for_message(&ctx, MsgId::new(instance_msg_id)).await + } + + // --------------------------------------------- + // misc prototyping functions + // that might get removed later again + // --------------------------------------------- + + /// Returns the messageid of the sent message + async fn misc_send_text_message( + &self, + account_id: u32, + text: String, + chat_id: u32, + ) -> Result { + let ctx = self.get_context(account_id).await?; + + let mut msg = Message::new(Viewtype::Text); + msg.set_text(Some(text)); + + let message_id = deltachat::chat::send_msg(&ctx, ChatId::new(chat_id), &mut msg).await?; + Ok(message_id.to_u32()) + } +} + +// Helper functions (to prevent code duplication) +async fn set_config( + ctx: &deltachat::context::Context, + key: &str, + value: Option<&str>, +) -> Result<(), anyhow::Error> { + if key.starts_with("ui.") { + ctx.set_ui_config(key, value).await + } else { + ctx.set_config(Config::from_str(key).context("unknown key")?, value) + .await + } +} + +async fn get_config( + ctx: &deltachat::context::Context, + key: &str, +) -> Result, anyhow::Error> { + if key.starts_with("ui.") { + ctx.get_ui_config(key).await + } else { + ctx.get_config(Config::from_str(key).context("unknown key")?) + .await + } +} diff --git a/deltachat-jsonrpc/src/api/types/account.rs b/deltachat-jsonrpc/src/api/types/account.rs new file mode 100644 index 000000000..8e0f80aef --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/account.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use deltachat::config::Config; +use deltachat::contact::{Contact, ContactId}; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; + +#[derive(Serialize, TypeDef)] +#[serde(tag = "type")] +pub enum Account { + #[serde(rename_all = "camelCase")] + Configured { + id: u32, + display_name: Option, + addr: Option, + // size: u32, + profile_image: Option, // TODO: This needs to be converted to work with blob http server. + color: String, + }, + #[serde(rename_all = "camelCase")] + Unconfigured { id: u32 }, +} + +impl Account { + pub async fn from_context(ctx: &deltachat::context::Context, id: u32) -> Result { + if ctx.is_configured().await? { + let display_name = ctx.get_config(Config::Displayname).await?; + let addr = ctx.get_config(Config::Addr).await?; + let profile_image = ctx.get_config(Config::Selfavatar).await?; + let color = color_int_to_hex_string( + Contact::get_by_id(ctx, ContactId::SELF).await?.get_color(), + ); + Ok(Account::Configured { + id, + display_name, + addr, + profile_image, + color, + }) + } else { + Ok(Account::Unconfigured { id }) + } + } +} diff --git a/deltachat-jsonrpc/src/api/types/chat.rs b/deltachat-jsonrpc/src/api/types/chat.rs new file mode 100644 index 000000000..d12e254c3 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/chat.rs @@ -0,0 +1,92 @@ +use anyhow::{anyhow, Result}; +use deltachat::chat::get_chat_contacts; +use deltachat::chat::{Chat, ChatId}; +use deltachat::contact::{Contact, ContactId}; +use deltachat::context::Context; +use num_traits::cast::ToPrimitive; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; +use super::contact::ContactObject; + +#[derive(Serialize, TypeDef)] +#[serde(rename_all = "camelCase")] +pub struct FullChat { + id: u32, + name: String, + is_protected: bool, + profile_image: Option, //BLOBS ? + archived: bool, + // subtitle - will be moved to frontend because it uses translation functions + chat_type: u32, + is_unpromoted: bool, + is_self_talk: bool, + contacts: Vec, + contact_ids: Vec, + color: String, + fresh_message_counter: usize, + // is_group - please check over chat.type in frontend instead + is_contact_request: bool, + is_device_chat: bool, + self_in_group: bool, + is_muted: bool, + ephemeral_timer: u32, //TODO look if there are more important properties in newer core versions + can_send: bool, +} + +impl FullChat { + pub async fn try_from_dc_chat_id(context: &Context, chat_id: u32) -> Result { + let rust_chat_id = ChatId::new(chat_id); + let chat = Chat::load_from_db(context, rust_chat_id).await?; + + let contact_ids = get_chat_contacts(context, rust_chat_id).await?; + + let mut contacts = Vec::with_capacity(contact_ids.len()); + + for contact_id in &contact_ids { + contacts.push( + ContactObject::try_from_dc_contact( + context, + Contact::load_from_db(context, *contact_id).await?, + ) + .await?, + ) + } + + let profile_image = match chat.get_profile_image(context).await? { + Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), + None => None, + }; + + let color = color_int_to_hex_string(chat.get_color(context).await?); + let fresh_message_counter = rust_chat_id.get_fresh_msg_cnt(context).await?; + let ephemeral_timer = rust_chat_id.get_ephemeral_timer(context).await?.to_u32(); + + let can_send = chat.can_send(context).await?; + + Ok(FullChat { + id: chat_id, + name: chat.name.clone(), + is_protected: chat.is_protected(), + profile_image, //BLOBS ? + archived: chat.get_visibility() == deltachat::chat::ChatVisibility::Archived, + chat_type: chat + .get_type() + .to_u32() + .ok_or_else(|| anyhow!("unknown chat type id"))?, // TODO get rid of this unwrap? + is_unpromoted: chat.is_unpromoted(), + is_self_talk: chat.is_self_talk(), + contacts, + contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(), + color, + fresh_message_counter, + is_contact_request: chat.is_contact_request(), + is_device_chat: chat.is_device_talk(), + self_in_group: contact_ids.contains(&ContactId::SELF), + is_muted: chat.is_muted(), + ephemeral_timer, + can_send, + }) + } +} diff --git a/deltachat-jsonrpc/src/api/types/chat_list.rs b/deltachat-jsonrpc/src/api/types/chat_list.rs new file mode 100644 index 000000000..39c7ea629 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/chat_list.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use deltachat::constants::*; +use deltachat::contact::ContactId; +use deltachat::{ + chat::{get_chat_contacts, ChatVisibility}, + chatlist::Chatlist, +}; +use deltachat::{ + chat::{Chat, ChatId}, + message::MsgId, +}; +use num_traits::cast::ToPrimitive; +use serde::{Deserialize, Serialize}; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; + +#[derive(Deserialize, Serialize, TypeDef)] +pub struct ChatListEntry(pub u32, pub u32); + +#[derive(Serialize, TypeDef)] +#[serde(tag = "type")] +pub enum ChatListItemFetchResult { + #[serde(rename_all = "camelCase")] + ChatListItem { + id: u32, + name: String, + avatar_path: Option, + color: String, + last_updated: Option, + summary_text1: String, + summary_text2: String, + summary_status: u32, + is_protected: bool, + is_group: bool, + fresh_message_counter: usize, + is_self_talk: bool, + is_device_talk: bool, + is_sending_location: bool, + is_self_in_group: bool, + is_archived: bool, + is_pinned: bool, + is_muted: bool, + is_contact_request: bool, + /// contact id if this is a dm chat (for view profile entry in context menu) + dm_chat_contact: Option, + }, + ArchiveLink, + #[serde(rename_all = "camelCase")] + Error { + id: u32, + error: String, + }, +} + +pub(crate) async fn get_chat_list_item_by_id( + ctx: &deltachat::context::Context, + entry: &ChatListEntry, +) -> Result { + let chat_id = ChatId::new(entry.0); + let last_msgid = match entry.1 { + 0 => None, + _ => Some(MsgId::new(entry.1)), + }; + + if chat_id.is_archived_link() { + return Ok(ChatListItemFetchResult::ArchiveLink); + } + + let chat = Chat::load_from_db(ctx, chat_id).await?; + let summary = Chatlist::get_summary2(ctx, chat_id, last_msgid, Some(&chat)).await?; + + let summary_text1 = summary.prefix.map_or_else(String::new, |s| s.to_string()); + let summary_text2 = summary.text.to_owned(); + + let visibility = chat.get_visibility(); + + let avatar_path = chat + .get_profile_image(ctx) + .await? + .map(|path| path.to_str().unwrap_or("invalid/path").to_owned()); + + let last_updated = match last_msgid { + Some(id) => { + let last_message = deltachat::message::Message::load_from_db(ctx, id).await?; + Some(last_message.get_timestamp() * 1000) + } + None => None, + }; + + let chat_contacts = get_chat_contacts(ctx, chat_id).await?; + + let self_in_group = chat_contacts.contains(&ContactId::SELF); + + let dm_chat_contact = if chat.get_type() == Chattype::Single { + chat_contacts.get(0).map(|contact_id| contact_id.to_u32()) + } else { + None + }; + + let fresh_message_counter = chat_id.get_fresh_msg_cnt(ctx).await?; + let color = color_int_to_hex_string(chat.get_color(ctx).await?); + + Ok(ChatListItemFetchResult::ChatListItem { + id: chat_id.to_u32(), + name: chat.get_name().to_owned(), + avatar_path, + color, + last_updated, + summary_text1, + summary_text2, + summary_status: summary.state.to_u32().expect("impossible"), // idea and a function to transform the constant to strings? or return string enum + is_protected: chat.is_protected(), + is_group: chat.get_type() == Chattype::Group, + fresh_message_counter, + is_self_talk: chat.is_self_talk(), + is_device_talk: chat.is_device_talk(), + is_self_in_group: self_in_group, + is_sending_location: chat.is_sending_locations(), + is_archived: visibility == ChatVisibility::Archived, + is_pinned: visibility == ChatVisibility::Pinned, + is_muted: chat.is_muted(), + is_contact_request: chat.is_contact_request(), + dm_chat_contact, + }) +} diff --git a/deltachat-jsonrpc/src/api/types/contact.rs b/deltachat-jsonrpc/src/api/types/contact.rs new file mode 100644 index 000000000..13534f9bf --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/contact.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use deltachat::contact::VerifiedStatus; +use deltachat::context::Context; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::color_int_to_hex_string; + +#[derive(Serialize, TypeDef)] +#[serde(rename = "Contact", rename_all = "camelCase")] +pub struct ContactObject { + address: String, + color: String, + auth_name: String, + status: String, + display_name: String, + id: u32, + name: String, + profile_image: Option, // BLOBS + name_and_addr: String, + is_blocked: bool, + is_verified: bool, +} + +impl ContactObject { + pub async fn try_from_dc_contact( + context: &Context, + contact: deltachat::contact::Contact, + ) -> Result { + let profile_image = match contact.get_profile_image(context).await? { + Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), + None => None, + }; + let is_verified = contact.is_verified(context).await? == VerifiedStatus::BidirectVerified; + + Ok(ContactObject { + address: contact.get_addr().to_owned(), + color: color_int_to_hex_string(contact.get_color()), + auth_name: contact.get_authname().to_owned(), + status: contact.get_status().to_owned(), + display_name: contact.get_display_name().to_owned(), + id: contact.id.to_u32(), + name: contact.get_name().to_owned(), + profile_image, //BLOBS + name_and_addr: contact.get_name_n_addr(), + is_blocked: contact.is_blocked(), + is_verified, + }) + } +} diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs new file mode 100644 index 000000000..adbb90194 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -0,0 +1,202 @@ +use anyhow::{anyhow, Result}; +use deltachat::contact::Contact; +use deltachat::context::Context; +use deltachat::message::Message; +use deltachat::message::MsgId; +use deltachat::message::Viewtype; +use num_traits::cast::ToPrimitive; +use serde::Deserialize; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::contact::ContactObject; + +#[derive(Serialize, TypeDef)] +#[serde(rename = "Message", rename_all = "camelCase")] +pub struct MessageObject { + id: u32, + chat_id: u32, + from_id: u32, + quoted_text: Option, + quoted_message_id: Option, + text: Option, + has_location: bool, + has_html: bool, + view_type: MessageViewtype, + state: u32, + + timestamp: i64, + sort_timestamp: i64, + received_timestamp: i64, + has_deviating_timestamp: bool, + + // summary - use/create another function if you need it + subject: String, + show_padlock: bool, + is_setupmessage: bool, + is_info: bool, + is_forwarded: bool, + + duration: i32, + dimensions_height: i32, + dimensions_width: i32, + + videochat_type: Option, + videochat_url: Option, + + override_sender_name: Option, + sender: ContactObject, + + setup_code_begin: Option, + + file: Option, + file_mime: Option, + file_bytes: u64, + file_name: Option, +} + +impl MessageObject { + pub async fn from_message_id(context: &Context, message_id: u32) -> Result { + let msg_id = MsgId::new(message_id); + let message = Message::load_from_db(context, msg_id).await?; + + let quoted_message_id = message + .quoted_message(context) + .await? + .map(|m| m.get_id().to_u32()); + + let sender_contact = Contact::load_from_db(context, message.get_from_id()).await?; + let sender = ContactObject::try_from_dc_contact(context, sender_contact).await?; + let file_bytes = message.get_filebytes(context).await; + let override_sender_name = message.get_override_sender_name(); + + Ok(MessageObject { + id: message_id, + chat_id: message.get_chat_id().to_u32(), + from_id: message.get_from_id().to_u32(), + quoted_text: message.quoted_text(), + quoted_message_id, + text: message.get_text(), + has_location: message.has_location(), + has_html: message.has_html(), + view_type: message.get_viewtype().into(), + state: message + .get_state() + .to_u32() + .ok_or_else(|| anyhow!("state conversion to number failed"))?, + + timestamp: message.get_timestamp(), + sort_timestamp: message.get_sort_timestamp(), + received_timestamp: message.get_received_timestamp(), + has_deviating_timestamp: message.has_deviating_timestamp(), + + subject: message.get_subject().to_owned(), + show_padlock: message.get_showpadlock(), + is_setupmessage: message.is_setupmessage(), + is_info: message.is_info(), + is_forwarded: message.is_forwarded(), + + duration: message.get_duration(), + dimensions_height: message.get_height(), + dimensions_width: message.get_width(), + + videochat_type: match message.get_videochat_type() { + Some(vct) => Some( + vct.to_u32() + .ok_or_else(|| anyhow!("state conversion to number failed"))?, + ), + None => None, + }, + videochat_url: message.get_videochat_url(), + + override_sender_name, + sender, + + setup_code_begin: message.get_setupcodebegin(context).await, + + file: match message.get_file(context) { + Some(path_buf) => path_buf.to_str().map(|s| s.to_owned()), + None => None, + }, //BLOBS + file_mime: message.get_filemime(), + file_bytes, + file_name: message.get_filename(), + }) + } +} + +#[derive(Serialize, Deserialize, TypeDef)] +#[serde(rename = "Viewtype")] +pub enum MessageViewtype { + Unknown, + + /// Text message. + Text, + + /// Image message. + /// If the image is an animated GIF, the type `Viewtype.Gif` should be used. + Image, + + /// Animated GIF message. + Gif, + + /// Message containing a sticker, similar to image. + /// If possible, the ui should display the image without borders in a transparent way. + /// A click on a sticker will offer to install the sticker set in some future. + Sticker, + + /// Message containing an Audio file. + Audio, + + /// A voice message that was directly recorded by the user. + /// For all other audio messages, the type `Viewtype.Audio` should be used. + Voice, + + /// Video messages. + Video, + + /// Message containing any file, eg. a PDF. + File, + + /// Message is an invitation to a videochat. + VideochatInvitation, + + /// Message is an webxdc instance. + Webxdc, +} + +impl From for MessageViewtype { + fn from(viewtype: Viewtype) -> Self { + match viewtype { + Viewtype::Unknown => MessageViewtype::Unknown, + Viewtype::Text => MessageViewtype::Text, + Viewtype::Image => MessageViewtype::Image, + Viewtype::Gif => MessageViewtype::Gif, + Viewtype::Sticker => MessageViewtype::Sticker, + Viewtype::Audio => MessageViewtype::Audio, + Viewtype::Voice => MessageViewtype::Voice, + Viewtype::Video => MessageViewtype::Video, + Viewtype::File => MessageViewtype::File, + Viewtype::VideochatInvitation => MessageViewtype::VideochatInvitation, + Viewtype::Webxdc => MessageViewtype::Webxdc, + } + } +} + +impl From for Viewtype { + fn from(viewtype: MessageViewtype) -> Self { + match viewtype { + MessageViewtype::Unknown => Viewtype::Unknown, + MessageViewtype::Text => Viewtype::Text, + MessageViewtype::Image => Viewtype::Image, + MessageViewtype::Gif => Viewtype::Gif, + MessageViewtype::Sticker => Viewtype::Sticker, + MessageViewtype::Audio => Viewtype::Audio, + MessageViewtype::Voice => Viewtype::Voice, + MessageViewtype::Video => Viewtype::Video, + MessageViewtype::File => Viewtype::File, + MessageViewtype::VideochatInvitation => Viewtype::VideochatInvitation, + MessageViewtype::Webxdc => Viewtype::Webxdc, + } + } +} diff --git a/deltachat-jsonrpc/src/api/types/mod.rs b/deltachat-jsonrpc/src/api/types/mod.rs new file mode 100644 index 000000000..a8edc4a27 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/mod.rs @@ -0,0 +1,229 @@ +use deltachat::qr::Qr; +use serde::Serialize; +use typescript_type_def::TypeDef; + +pub mod account; +pub mod chat; +pub mod chat_list; +pub mod contact; +pub mod message; +pub mod provider_info; +pub mod webxdc; + +pub fn color_int_to_hex_string(color: u32) -> String { + format!("{:#08x}", color).replace("0x", "#") +} + +fn maybe_empty_string_to_option(string: String) -> Option { + if string.is_empty() { + None + } else { + Some(string) + } +} + +#[derive(Serialize, TypeDef)] +#[serde(rename = "Qr", rename_all = "camelCase")] +#[serde(tag = "type")] +pub enum QrObject { + AskVerifyContact { + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + AskVerifyGroup { + grpname: String, + grpid: String, + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + FprOk { + contact_id: u32, + }, + FprMismatch { + contact_id: Option, + }, + FprWithoutAddr { + fingerprint: String, + }, + Account { + domain: String, + }, + WebrtcInstance { + domain: String, + instance_pattern: String, + }, + Addr { + contact_id: u32, + draft: Option, + }, + Url { + url: String, + }, + Text { + text: String, + }, + WithdrawVerifyContact { + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + WithdrawVerifyGroup { + grpname: String, + grpid: String, + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + ReviveVerifyContact { + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, + ReviveVerifyGroup { + grpname: String, + grpid: String, + contact_id: u32, + fingerprint: String, + invitenumber: String, + authcode: String, + }, +} + +impl From for QrObject { + fn from(qr: Qr) -> Self { + match qr { + Qr::AskVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::AskVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::AskVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::FprOk { contact_id } => { + let contact_id = contact_id.to_u32(); + QrObject::FprOk { contact_id } + } + Qr::FprMismatch { contact_id } => { + let contact_id = contact_id.map(|contact_id| contact_id.to_u32()); + QrObject::FprMismatch { contact_id } + } + Qr::FprWithoutAddr { fingerprint } => QrObject::FprWithoutAddr { fingerprint }, + Qr::Account { domain } => QrObject::Account { domain }, + Qr::WebrtcInstance { + domain, + instance_pattern, + } => QrObject::WebrtcInstance { + domain, + instance_pattern, + }, + Qr::Addr { contact_id, draft } => { + let contact_id = contact_id.to_u32(); + QrObject::Addr { contact_id, draft } + } + Qr::Url { url } => QrObject::Url { url }, + Qr::Text { text } => QrObject::Text { text }, + Qr::WithdrawVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::WithdrawVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::WithdrawVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::WithdrawVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::ReviveVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::ReviveVerifyContact { + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + Qr::ReviveVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } => { + let contact_id = contact_id.to_u32(); + let fingerprint = fingerprint.to_string(); + QrObject::ReviveVerifyGroup { + grpname, + grpid, + contact_id, + fingerprint, + invitenumber, + authcode, + } + } + } + } +} diff --git a/deltachat-jsonrpc/src/api/types/provider_info.rs b/deltachat-jsonrpc/src/api/types/provider_info.rs new file mode 100644 index 000000000..59992e05b --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/provider_info.rs @@ -0,0 +1,22 @@ +use deltachat::provider::Provider; +use num_traits::cast::ToPrimitive; +use serde::Serialize; +use typescript_type_def::TypeDef; + +#[derive(Serialize, TypeDef)] +#[serde(rename_all = "camelCase")] +pub struct ProviderInfo { + pub before_login_hint: String, + pub overview_page: String, + pub status: u32, // in reality this is an enum, but for simlicity and because it gets converted into a number anyway, we use an u32 here. +} + +impl ProviderInfo { + pub fn from_dc_type(provider: Option<&Provider>) -> Option { + provider.map(|p| ProviderInfo { + before_login_hint: p.before_login_hint.to_owned(), + overview_page: p.overview_page.to_owned(), + status: p.status.to_u32().unwrap(), + }) + } +} diff --git a/deltachat-jsonrpc/src/api/types/webxdc.rs b/deltachat-jsonrpc/src/api/types/webxdc.rs new file mode 100644 index 000000000..fe652bb43 --- /dev/null +++ b/deltachat-jsonrpc/src/api/types/webxdc.rs @@ -0,0 +1,60 @@ +use deltachat::{ + context::Context, + message::{Message, MsgId}, + webxdc::WebxdcInfo, +}; +use serde::Serialize; +use typescript_type_def::TypeDef; + +use super::maybe_empty_string_to_option; + +#[derive(Serialize, TypeDef)] +#[serde(rename = "WebxdcMessageInfo", rename_all = "camelCase")] +pub struct WebxdcMessageInfo { + /// The name of the app. + /// + /// Defaults to the filename if not set in the manifest. + name: String, + /// App icon file name. + /// Defaults to an standard icon if nothing is set in the manifest. + /// + /// To get the file, use dc_msg_get_webxdc_blob(). (not yet in jsonrpc, use rust api or cffi for it) + /// + /// App icons should should be square, + /// the implementations will add round corners etc. as needed. + icon: String, + /// if the Webxdc represents a document, then this is the name of the document + document: Option, + /// short string describing the state of the app, + /// sth. as "2 votes", "Highscore: 123", + /// can be changed by the apps + summary: Option, + /// URL where the source code of the Webxdc and other information can be found; + /// defaults to an empty string. + /// Implementations may offer an menu or a button to open this URL. + source_code_url: Option, +} + +impl WebxdcMessageInfo { + pub async fn get_for_message( + context: &Context, + instance_message_id: MsgId, + ) -> anyhow::Result { + let message = Message::load_from_db(context, instance_message_id).await?; + let WebxdcInfo { + name, + icon, + document, + summary, + source_code_url, + } = message.get_webxdc_info(context).await?; + + Ok(Self { + name, + icon, + document: maybe_empty_string_to_option(document), + summary: maybe_empty_string_to_option(summary), + source_code_url: maybe_empty_string_to_option(source_code_url), + }) + } +} diff --git a/deltachat-jsonrpc/src/lib.rs b/deltachat-jsonrpc/src/lib.rs new file mode 100644 index 000000000..344e91afb --- /dev/null +++ b/deltachat-jsonrpc/src/lib.rs @@ -0,0 +1,92 @@ +pub mod api; +pub use api::events; +pub use yerpc; + +#[cfg(test)] +mod tests { + use super::api::{Accounts, CommandApi}; + use async_channel::unbounded; + use futures::StreamExt; + use tempfile::TempDir; + use yerpc::{RpcClient, RpcSession}; + + #[tokio::test(flavor = "multi_thread")] + async fn basic_json_rpc_functionality() -> anyhow::Result<()> { + let tmp_dir = TempDir::new().unwrap().path().into(); + let accounts = Accounts::new(tmp_dir).await?; + let api = CommandApi::new(accounts); + + let (sender, mut receiver) = unbounded::(); + + let (client, mut rx) = RpcClient::new(); + let session = RpcSession::new(client, api); + tokio::spawn({ + async move { + while let Some(message) = rx.next().await { + let message = serde_json::to_string(&message)?; + sender.send(message).await?; + } + let res: Result<(), anyhow::Error> = Ok(()); + res + } + }); + + { + let request = r#"{"jsonrpc":"2.0","method":"add_account","params":[],"id":1}"#; + let response = r#"{"jsonrpc":"2.0","id":1,"result":1}"#; + session.handle_incoming(request).await; + let result = receiver.next().await; + println!("{:?}", result); + assert_eq!(result, Some(response.to_owned())); + } + { + let request = r#"{"jsonrpc":"2.0","method":"get_all_account_ids","params":[],"id":2}"#; + let response = r#"{"jsonrpc":"2.0","id":2,"result":[1]}"#; + session.handle_incoming(request).await; + let result = receiver.next().await; + println!("{:?}", result); + assert_eq!(result, Some(response.to_owned())); + } + + Ok(()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_batch_set_config() -> anyhow::Result<()> { + let tmp_dir = TempDir::new().unwrap().path().into(); + let accounts = Accounts::new(tmp_dir).await?; + let api = CommandApi::new(accounts); + + let (sender, mut receiver) = unbounded::(); + + let (client, mut rx) = RpcClient::new(); + let session = RpcSession::new(client, api); + tokio::spawn({ + async move { + while let Some(message) = rx.next().await { + let message = serde_json::to_string(&message)?; + sender.send(message).await?; + } + let res: Result<(), anyhow::Error> = Ok(()); + res + } + }); + + { + let request = r#"{"jsonrpc":"2.0","method":"add_account","params":[],"id":1}"#; + let response = r#"{"jsonrpc":"2.0","id":1,"result":1}"#; + session.handle_incoming(request).await; + let result = receiver.next().await; + assert_eq!(result, Some(response.to_owned())); + } + { + let request = r#"{"jsonrpc":"2.0","method":"batch_set_config","id":2,"params":[1,{"addr":"","mail_user":"","mail_pw":"","mail_server":"","mail_port":"","mail_security":"","imap_certificate_checks":"","send_user":"","send_pw":"","send_server":"","send_port":"","send_security":"","smtp_certificate_checks":"","socks5_enabled":"0","socks5_host":"","socks5_port":"","socks5_user":"","socks5_password":""}]}"#; + let response = r#"{"jsonrpc":"2.0","id":2,"result":null}"#; + session.handle_incoming(request).await; + let result = receiver.next().await; + assert_eq!(result, Some(response.to_owned())); + } + + Ok(()) + } +} diff --git a/deltachat-jsonrpc/src/webserver.rs b/deltachat-jsonrpc/src/webserver.rs new file mode 100644 index 000000000..3f6b62702 --- /dev/null +++ b/deltachat-jsonrpc/src/webserver.rs @@ -0,0 +1,54 @@ +use axum::{extract::ws::WebSocketUpgrade, response::Response, routing::get, Extension, Router}; +use std::net::SocketAddr; +use std::path::PathBuf; +use yerpc::axum::handle_ws_rpc; +use yerpc::{RpcClient, RpcSession}; + +mod api; +use api::events::event_to_json_rpc_notification; +use api::{Accounts, CommandApi}; + +const DEFAULT_PORT: u16 = 20808; + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), std::io::Error> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let path = std::env::var("DC_ACCOUNTS_PATH").unwrap_or_else(|_| "./accounts".to_string()); + let port = std::env::var("DC_PORT") + .map(|port| port.parse::().expect("DC_PORT must be a number")) + .unwrap_or(DEFAULT_PORT); + log::info!("Starting with accounts directory `{path}`."); + let accounts = Accounts::new(PathBuf::from(&path)).await.unwrap(); + let state = CommandApi::new(accounts); + + let app = Router::new() + .route("/ws", get(handler)) + .layer(Extension(state.clone())); + + tokio::spawn(async move { + state.accounts.read().await.start_io().await; + }); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + log::info!("JSON-RPC WebSocket server listening on {}", addr); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .unwrap(); + + Ok(()) +} + +async fn handler(ws: WebSocketUpgrade, Extension(api): Extension) -> Response { + let (client, out_receiver) = RpcClient::new(); + let session = RpcSession::new(client.clone(), api.clone()); + tokio::spawn(async move { + let events = api.accounts.read().await.get_event_emitter().await; + while let Some(event) = events.recv().await { + let event = event_to_json_rpc_notification(event); + client.send_notification("event", Some(event)).await.ok(); + } + }); + handle_ws_rpc(ws, out_receiver, session).await +} diff --git a/deltachat-jsonrpc/typescript/.gitignore b/deltachat-jsonrpc/typescript/.gitignore new file mode 100644 index 000000000..ca2372bc1 --- /dev/null +++ b/deltachat-jsonrpc/typescript/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +test_dist +coverage +yarn.lock +package-lock.json +docs +accounts diff --git a/deltachat-jsonrpc/typescript/.npmignore b/deltachat-jsonrpc/typescript/.npmignore new file mode 100644 index 000000000..3d8ebf962 --- /dev/null +++ b/deltachat-jsonrpc/typescript/.npmignore @@ -0,0 +1,6 @@ +node_modules +accounts +docs +coverage +yarn* +package-lock.json diff --git a/deltachat-jsonrpc/typescript/.prettierignore b/deltachat-jsonrpc/typescript/.prettierignore new file mode 100644 index 000000000..47da942cf --- /dev/null +++ b/deltachat-jsonrpc/typescript/.prettierignore @@ -0,0 +1,3 @@ +coverage +dist +generated \ No newline at end of file diff --git a/deltachat-jsonrpc/typescript/deltachat.ts b/deltachat-jsonrpc/typescript/deltachat.ts new file mode 100644 index 000000000..d75cd0e48 --- /dev/null +++ b/deltachat-jsonrpc/typescript/deltachat.ts @@ -0,0 +1 @@ +export * from "./src/lib.js"; diff --git a/deltachat-jsonrpc/typescript/example.html b/deltachat-jsonrpc/typescript/example.html new file mode 100644 index 000000000..1f5ca1671 --- /dev/null +++ b/deltachat-jsonrpc/typescript/example.html @@ -0,0 +1,56 @@ + + + + DeltaChat JSON-RPC example + + + + +

DeltaChat JSON-RPC example

+
+ +
+

log

+
+

+ Tip: open the dev console and use the client with + window.client +

+ + diff --git a/deltachat-jsonrpc/typescript/example/example.ts b/deltachat-jsonrpc/typescript/example/example.ts new file mode 100644 index 000000000..c3cfeead4 --- /dev/null +++ b/deltachat-jsonrpc/typescript/example/example.ts @@ -0,0 +1,109 @@ +import { DeltaChat, DeltaChatEvent } from "../deltachat.js"; + +var SELECTED_ACCOUNT = 0; + +window.addEventListener("DOMContentLoaded", (_event) => { + (window as any).selectDeltaAccount = (id: string) => { + SELECTED_ACCOUNT = Number(id); + window.dispatchEvent(new Event("account-changed")); + }; + console.log('launch run script...') + run().catch((err) => console.error("run failed", err)); +}); + +async function run() { + const $main = document.getElementById("main")!; + const $side = document.getElementById("side")!; + const $head = document.getElementById("header")!; + + const client = new DeltaChat('ws://localhost:20808/ws') + + ;(window as any).client = client.rpc; + + client.on("ALL", event => { + onIncomingEvent(event) + }) + + window.addEventListener("account-changed", async (_event: Event) => { + listChatsForSelectedAccount(); + }); + + await Promise.all([loadAccountsInHeader(), listChatsForSelectedAccount()]); + + async function loadAccountsInHeader() { + console.log('load accounts') + const accounts = await client.rpc.getAllAccounts(); + console.log('accounts loaded', accounts) + for (const account of accounts) { + if (account.type === "Configured") { + write( + $head, + ` + ${account.id}: ${account.addr!} +  ` + ); + } else { + write( + $head, + ` + ${account.id}: (unconfigured) +  ` + ) + } + } + } + + async function listChatsForSelectedAccount() { + clear($main); + const selectedAccount = SELECTED_ACCOUNT + const info = await client.rpc.getAccountInfo(selectedAccount); + if (info.type !== "Configured") { + return write($main, "Account is not configured"); + } + write($main, `

${info.addr!}

`); + const chats = await client.rpc.getChatlistEntries( + selectedAccount, + 0, + null, + null + ); + for (const [chatId, _messageId] of chats) { + const chat = await client.rpc.chatlistGetFullChatById( + selectedAccount, + chatId + ); + write($main, `

${chat.name}

`); + const messageIds = await client.rpc.messageListGetMessageIds( + selectedAccount, + chatId, + 0 + ); + const messages = await client.rpc.messageGetMessages( + selectedAccount, + messageIds + ); + for (const [_messageId, message] of Object.entries(messages)) { + write($main, `

${message.text}

`); + } + } + } + + function onIncomingEvent(event: DeltaChatEvent) { + write( + $side, + ` +

+ [${event.id} on account ${event.contextId}]
+ f1: ${JSON.stringify(event.field1)}
+ f2: ${JSON.stringify(event.field2)} +

` + ); + } +} + +function write(el: HTMLElement, html: string) { + el.innerHTML += html; +} +function clear(el: HTMLElement) { + el.innerHTML = ""; +} diff --git a/deltachat-jsonrpc/typescript/example/node-add-account.js b/deltachat-jsonrpc/typescript/example/node-add-account.js new file mode 100644 index 000000000..c553ad669 --- /dev/null +++ b/deltachat-jsonrpc/typescript/example/node-add-account.js @@ -0,0 +1,26 @@ +import { DeltaChat } from "../dist/deltachat.js"; + +run().catch(console.error); + +async function run() { + const delta = new DeltaChat('ws://localhost:20808/ws'); + delta.on("event", (event) => { + console.log("event", event.data); + }); + + const email = process.argv[2] + const password = process.argv[3] + if (!email || !password) throw new Error('USAGE: node node-add-account.js ') + console.log(`creating acccount for ${email}`) + const id = await delta.rpc.addAccount() + console.log(`created account id ${id}`) + await delta.rpc.setConfig(id, "addr", email); + await delta.rpc.setConfig(id, "mail_pw", password); + console.log('configuration updated') + await delta.rpc.configure(id) + console.log('account configured!') + + const accounts = await delta.rpc.getAllAccounts(); + console.log("accounts", accounts); + console.log("waiting for events...") +} diff --git a/deltachat-jsonrpc/typescript/example/node-demo.js b/deltachat-jsonrpc/typescript/example/node-demo.js new file mode 100644 index 000000000..dcf96a4df --- /dev/null +++ b/deltachat-jsonrpc/typescript/example/node-demo.js @@ -0,0 +1,14 @@ +import { DeltaChat } from "../dist/deltachat.js"; + +run().catch(console.error); + +async function run() { + const delta = new DeltaChat(); + delta.on("event", (event) => { + console.log("event", event.data); + }); + + const accounts = await delta.rpc.getAllAccounts(); + console.log("accounts", accounts); + console.log("waiting for events...") +} diff --git a/deltachat-jsonrpc/typescript/generated/client.ts b/deltachat-jsonrpc/typescript/generated/client.ts new file mode 100644 index 000000000..c278417da --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/client.ts @@ -0,0 +1,332 @@ +// AUTO-GENERATED by yerpc-derive + +import * as T from "./types.js" +import * as RPC from "./jsonrpc.js" + +type RequestMethod = (method: string, params?: RPC.Params) => Promise; +type NotificationMethod = (method: string, params?: RPC.Params) => void; + +interface Transport { + request: RequestMethod, + notification: NotificationMethod +} + +export class RawClient { + constructor(private _transport: Transport) {} + + /** + * Check if an email address is valid. + */ + public checkEmailValidity(email: string): Promise { + return (this._transport.request('check_email_validity', [email] as RPC.Params)) as Promise; + } + + /** + * Get general system info. + */ + public getSystemInfo(): Promise> { + return (this._transport.request('get_system_info', [] as RPC.Params)) as Promise>; + } + + + public addAccount(): Promise { + return (this._transport.request('add_account', [] as RPC.Params)) as Promise; + } + + + public removeAccount(accountId: T.U32): Promise { + return (this._transport.request('remove_account', [accountId] as RPC.Params)) as Promise; + } + + + public getAllAccountIds(): Promise<(T.U32)[]> { + return (this._transport.request('get_all_account_ids', [] as RPC.Params)) as Promise<(T.U32)[]>; + } + + /** + * Select account id for internally selected state. + * TODO: Likely this is deprecated as all methods take an account id now. + */ + public selectAccount(id: T.U32): Promise { + return (this._transport.request('select_account', [id] as RPC.Params)) as Promise; + } + + /** + * Get the selected account id of the internal state.. + * TODO: Likely this is deprecated as all methods take an account id now. + */ + public getSelectedAccountId(): Promise<(T.U32|null)> { + return (this._transport.request('get_selected_account_id', [] as RPC.Params)) as Promise<(T.U32|null)>; + } + + /** + * Get a list of all configured accounts. + */ + public getAllAccounts(): Promise<(T.Account)[]> { + return (this._transport.request('get_all_accounts', [] as RPC.Params)) as Promise<(T.Account)[]>; + } + + /** + * Get top-level info for an account. + */ + public getAccountInfo(accountId: T.U32): Promise { + return (this._transport.request('get_account_info', [accountId] as RPC.Params)) as Promise; + } + + /** + * Returns provider for the given domain. + * + * This function looks up domain in offline database. + * + * For compatibility, email address can be passed to this function + * instead of the domain. + */ + public getProviderInfo(accountId: T.U32, email: string): Promise<(T.ProviderInfo|null)> { + return (this._transport.request('get_provider_info', [accountId, email] as RPC.Params)) as Promise<(T.ProviderInfo|null)>; + } + + /** + * Checks if the context is already configured. + */ + public isConfigured(accountId: T.U32): Promise { + return (this._transport.request('is_configured', [accountId] as RPC.Params)) as Promise; + } + + /** + * Get system info for an account. + */ + public getInfo(accountId: T.U32): Promise> { + return (this._transport.request('get_info', [accountId] as RPC.Params)) as Promise>; + } + + + public setConfig(accountId: T.U32, key: string, value: (string|null)): Promise { + return (this._transport.request('set_config', [accountId, key, value] as RPC.Params)) as Promise; + } + + + public batchSetConfig(accountId: T.U32, config: Record): Promise { + return (this._transport.request('batch_set_config', [accountId, config] as RPC.Params)) as Promise; + } + + /** + * Set configuration values from a QR code. (technically from the URI that is stored in the qrcode) + * Before this function is called, dc_check_qr() should confirm the type of the + * QR code is DC_QR_ACCOUNT or DC_QR_WEBRTC_INSTANCE. + * + * Internally, the function will call dc_set_config() with the appropriate keys, + * e.g. `addr` and `mail_pw` for DC_QR_ACCOUNT + * or `webrtc_instance` for DC_QR_WEBRTC_INSTANCE. + */ + public setConfigFromQr(accountId: T.U32, qrContent: string): Promise { + return (this._transport.request('set_config_from_qr', [accountId, qrContent] as RPC.Params)) as Promise; + } + + + public checkQr(accountId: T.U32, qrContent: string): Promise { + return (this._transport.request('check_qr', [accountId, qrContent] as RPC.Params)) as Promise; + } + + + public getConfig(accountId: T.U32, key: string): Promise<(string|null)> { + return (this._transport.request('get_config', [accountId, key] as RPC.Params)) as Promise<(string|null)>; + } + + + public batchGetConfig(accountId: T.U32, keys: (string)[]): Promise> { + return (this._transport.request('batch_get_config', [accountId, keys] as RPC.Params)) as Promise>; + } + + /** + * Configures this account with the currently set parameters. + * Setup the credential config before calling this. + */ + public configure(accountId: T.U32): Promise { + return (this._transport.request('configure', [accountId] as RPC.Params)) as Promise; + } + + /** + * Signal an ongoing process to stop. + */ + public stopOngoingProcess(accountId: T.U32): Promise { + return (this._transport.request('stop_ongoing_process', [accountId] as RPC.Params)) as Promise; + } + + /** + * Returns the message IDs of all _fresh_ messages of any chat. + * Typically used for implementing notification summaries + * or badge counters e.g. on the app icon. + * The list is already sorted and starts with the most recent fresh message. + * + * Messages belonging to muted chats or to the contact requests are not returned; + * these messages should not be notified + * and also badge counters should not include these messages. + * + * To get the number of fresh messages for a single chat, muted or not, + * use `get_fresh_msg_cnt()`. + */ + public getFreshMsgs(accountId: T.U32): Promise<(T.U32)[]> { + return (this._transport.request('get_fresh_msgs', [accountId] as RPC.Params)) as Promise<(T.U32)[]>; + } + + /** + * Get the number of _fresh_ messages in a chat. + * Typically used to implement a badge with a number in the chatlist. + * + * If the specified chat is muted, + * the UI should show the badge counter "less obtrusive", + * e.g. using "gray" instead of "red" color. + */ + public getFreshMsgCnt(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('get_fresh_msg_cnt', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public autocryptInitiateKeyTransfer(accountId: T.U32): Promise { + return (this._transport.request('autocrypt_initiate_key_transfer', [accountId] as RPC.Params)) as Promise; + } + + + public autocryptContinueKeyTransfer(accountId: T.U32, messageId: T.U32, setupCode: string): Promise { + return (this._transport.request('autocrypt_continue_key_transfer', [accountId, messageId, setupCode] as RPC.Params)) as Promise; + } + + + public getChatlistEntries(accountId: T.U32, listFlags: (T.U32|null), queryString: (string|null), queryContactId: (T.U32|null)): Promise<(T.ChatListEntry)[]> { + return (this._transport.request('get_chatlist_entries', [accountId, listFlags, queryString, queryContactId] as RPC.Params)) as Promise<(T.ChatListEntry)[]>; + } + + + public getChatlistItemsByEntries(accountId: T.U32, entries: (T.ChatListEntry)[]): Promise> { + return (this._transport.request('get_chatlist_items_by_entries', [accountId, entries] as RPC.Params)) as Promise>; + } + + + public chatlistGetFullChatById(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('chatlist_get_full_chat_by_id', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public acceptChat(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('accept_chat', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public blockChat(accountId: T.U32, chatId: T.U32): Promise { + return (this._transport.request('block_chat', [accountId, chatId] as RPC.Params)) as Promise; + } + + + public addDeviceMessage(accountId: T.U32, label: string, text: string): Promise { + return (this._transport.request('add_device_message', [accountId, label, text] as RPC.Params)) as Promise; + } + + + public messageListGetMessageIds(accountId: T.U32, chatId: T.U32, flags: T.U32): Promise<(T.U32)[]> { + return (this._transport.request('message_list_get_message_ids', [accountId, chatId, flags] as RPC.Params)) as Promise<(T.U32)[]>; + } + + + public messageGetMessage(accountId: T.U32, messageId: T.U32): Promise { + return (this._transport.request('message_get_message', [accountId, messageId] as RPC.Params)) as Promise; + } + + + public messageGetMessages(accountId: T.U32, messageIds: (T.U32)[]): Promise> { + return (this._transport.request('message_get_messages', [accountId, messageIds] as RPC.Params)) as Promise>; + } + + /** + * Get a single contact options by ID. + */ + public contactsGetContact(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_get_contact', [accountId, contactId] as RPC.Params)) as Promise; + } + + /** + * Add a single contact as a result of an explicit user action. + * + * Returns contact id of the created or existing contact + */ + public contactsCreateContact(accountId: T.U32, email: string, name: (string|null)): Promise { + return (this._transport.request('contacts_create_contact', [accountId, email, name] as RPC.Params)) as Promise; + } + + /** + * Returns contact id of the created or existing DM chat with that contact + */ + public contactsCreateChatByContactId(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_create_chat_by_contact_id', [accountId, contactId] as RPC.Params)) as Promise; + } + + + public contactsBlock(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_block', [accountId, contactId] as RPC.Params)) as Promise; + } + + + public contactsUnblock(accountId: T.U32, contactId: T.U32): Promise { + return (this._transport.request('contacts_unblock', [accountId, contactId] as RPC.Params)) as Promise; + } + + + public contactsGetBlocked(accountId: T.U32): Promise<(T.Contact)[]> { + return (this._transport.request('contacts_get_blocked', [accountId] as RPC.Params)) as Promise<(T.Contact)[]>; + } + + + public contactsGetContactIds(accountId: T.U32, listFlags: T.U32, query: (string|null)): Promise<(T.U32)[]> { + return (this._transport.request('contacts_get_contact_ids', [accountId, listFlags, query] as RPC.Params)) as Promise<(T.U32)[]>; + } + + /** + * Get a list of contacts. + * (formerly called getContacts2 in desktop) + */ + public contactsGetContacts(accountId: T.U32, listFlags: T.U32, query: (string|null)): Promise<(T.Contact)[]> { + return (this._transport.request('contacts_get_contacts', [accountId, listFlags, query] as RPC.Params)) as Promise<(T.Contact)[]>; + } + + + public contactsGetContactsByIds(accountId: T.U32, ids: (T.U32)[]): Promise> { + return (this._transport.request('contacts_get_contacts_by_ids', [accountId, ids] as RPC.Params)) as Promise>; + } + + /** + * Returns all message IDs of the given types in a chat. + * Typically used to show a gallery. + * + * The list is already sorted and starts with the oldest message. + * Clients should not try to re-sort the list as this would be an expensive action + * and would result in inconsistencies between clients. + */ + public chatGetMedia(accountId: T.U32, chatId: T.U32, messageType: T.Viewtype, orMessageType2: (T.Viewtype|null), orMessageType3: (T.Viewtype|null)): Promise<(T.U32)[]> { + return (this._transport.request('chat_get_media', [accountId, chatId, messageType, orMessageType2, orMessageType3] as RPC.Params)) as Promise<(T.U32)[]>; + } + + + public webxdcSendStatusUpdate(accountId: T.U32, instanceMsgId: T.U32, updateStr: string, description: string): Promise { + return (this._transport.request('webxdc_send_status_update', [accountId, instanceMsgId, updateStr, description] as RPC.Params)) as Promise; + } + + + public webxdcGetStatusUpdates(accountId: T.U32, instanceMsgId: T.U32, lastKnownSerial: T.U32): Promise { + return (this._transport.request('webxdc_get_status_updates', [accountId, instanceMsgId, lastKnownSerial] as RPC.Params)) as Promise; + } + + /** + * Get info from a webxdc message + */ + public messageGetWebxdcInfo(accountId: T.U32, instanceMsgId: T.U32): Promise { + return (this._transport.request('message_get_webxdc_info', [accountId, instanceMsgId] as RPC.Params)) as Promise; + } + + /** + * Returns the messageid of the sent message + */ + public miscSendTextMessage(accountId: T.U32, text: string, chatId: T.U32): Promise { + return (this._transport.request('misc_send_text_message', [accountId, text, chatId] as RPC.Params)) as Promise; + } + + +} diff --git a/deltachat-jsonrpc/typescript/generated/events.ts b/deltachat-jsonrpc/typescript/generated/events.ts new file mode 100644 index 000000000..c4a5d7481 --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/events.ts @@ -0,0 +1,3 @@ +// AUTO-GENERATED by typescript-type-def + +export type EventTypeName=("Info"|"SmtpConnected"|"ImapConnected"|"SmtpMessageSent"|"ImapMessageDeleted"|"ImapMessageMoved"|"NewBlobFile"|"DeletedBlobFile"|"Warning"|"Error"|"ErrorSelfNotInGroup"|"MsgsChanged"|"IncomingMsg"|"MsgsNoticed"|"MsgDelivered"|"MsgFailed"|"MsgRead"|"ChatModified"|"ChatEphemeralTimerModified"|"ContactsChanged"|"LocationChanged"|"ConfigureProgress"|"ImexProgress"|"ImexFileWritten"|"SecurejoinInviterProgress"|"SecurejoinJoinerProgress"|"ConnectivityChanged"|"SelfavatarChanged"|"WebxdcStatusUpdate"); diff --git a/deltachat-jsonrpc/typescript/generated/jsonrpc.ts b/deltachat-jsonrpc/typescript/generated/jsonrpc.ts new file mode 100644 index 000000000..0c5a51e82 --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/jsonrpc.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED by typescript-type-def + +export type JSONValue=(null|boolean|number|string|(JSONValue)[]|{[key:string]:JSONValue;}); +export type Params=((JSONValue)[]|Record); +export type U32=number; +export type Request={"jsonrpc":"2.0";"method":string;"params"?:Params;"id"?:U32;}; +export type I32=number; +export type Error={"code":I32;"message":string;"data"?:JSONValue;}; +export type Response={"jsonrpc":"2.0";"id":(U32|null);"result"?:JSONValue;"error"?:Error;}; +export type Message=(Request|Response); diff --git a/deltachat-jsonrpc/typescript/generated/types.ts b/deltachat-jsonrpc/typescript/generated/types.ts new file mode 100644 index 000000000..076541d45 --- /dev/null +++ b/deltachat-jsonrpc/typescript/generated/types.ts @@ -0,0 +1,385 @@ +// AUTO-GENERATED by typescript-type-def + +export type U32 = number; +export type Account = + | ({ type: "Configured" } & { + id: U32; + displayName: string | null; + addr: string | null; + profileImage: string | null; + color: string; + }) + | ({ type: "Unconfigured" } & { id: U32 }); +export type ProviderInfo = { + beforeLoginHint: string; + overviewPage: string; + status: U32; +}; +export type Qr = + | ({ type: "askVerifyContact" } & { + contact_id: U32; + fingerprint: string; + invitenumber: string; + authcode: string; + }) + | ({ type: "askVerifyGroup" } & { + grpname: string; + grpid: string; + contact_id: U32; + fingerprint: string; + invitenumber: string; + authcode: string; + }) + | ({ type: "fprOk" } & { contact_id: U32 }) + | ({ type: "fprMismatch" } & { contact_id: U32 | null }) + | ({ type: "fprWithoutAddr" } & { fingerprint: string }) + | ({ type: "account" } & { domain: string }) + | ({ type: "webrtcInstance" } & { domain: string; instance_pattern: string }) + | ({ type: "addr" } & { contact_id: U32 }) + | ({ type: "url" } & { url: string }) + | ({ type: "text" } & { text: string }) + | ({ type: "withdrawVerifyContact" } & { + contact_id: U32; + fingerprint: string; + invitenumber: string; + authcode: string; + }) + | ({ type: "withdrawVerifyGroup" } & { + grpname: string; + grpid: string; + contact_id: U32; + fingerprint: string; + invitenumber: string; + authcode: string; + }) + | ({ type: "reviveVerifyContact" } & { + contact_id: U32; + fingerprint: string; + invitenumber: string; + authcode: string; + }) + | ({ type: "reviveVerifyGroup" } & { + grpname: string; + grpid: string; + contact_id: U32; + fingerprint: string; + invitenumber: string; + authcode: string; + }); +export type Usize = number; +export type ChatListEntry = [U32, U32]; +export type I64 = number; +export type ChatListItemFetchResult = + | ({ type: "ChatListItem" } & { + id: U32; + name: string; + avatarPath: string | null; + color: string; + lastUpdated: I64 | null; + summaryText1: string; + summaryText2: string; + summaryStatus: U32; + isProtected: boolean; + isGroup: boolean; + freshMessageCounter: Usize; + isSelfTalk: boolean; + isDeviceTalk: boolean; + isSendingLocation: boolean; + isSelfInGroup: boolean; + isArchived: boolean; + isPinned: boolean; + isMuted: boolean; + isContactRequest: boolean; + /** + * contact id if this is a dm chat (for view profile entry in context menu) + */ + dmChatContact: U32 | null; + }) + | { type: "ArchiveLink" } + | ({ type: "Error" } & { id: U32; error: string }); +export type Contact = { + address: string; + color: string; + authName: string; + status: string; + displayName: string; + id: U32; + name: string; + profileImage: string | null; + nameAndAddr: string; + isBlocked: boolean; + isVerified: boolean; +}; +export type FullChat = { + id: U32; + name: string; + isProtected: boolean; + profileImage: string | null; + archived: boolean; + chatType: U32; + isUnpromoted: boolean; + isSelfTalk: boolean; + contacts: Contact[]; + contactIds: U32[]; + color: string; + freshMessageCounter: Usize; + isContactRequest: boolean; + isDeviceChat: boolean; + selfInGroup: boolean; + isMuted: boolean; + ephemeralTimer: U32; + canSend: boolean; +}; +export type Viewtype = + | "Unknown" + /** + * Text message. + */ + | "Text" + /** + * Image message. + * If the image is an animated GIF, the type `Viewtype.Gif` should be used. + */ + | "Image" + /** + * Animated GIF message. + */ + | "Gif" + /** + * Message containing a sticker, similar to image. + * If possible, the ui should display the image without borders in a transparent way. + * A click on a sticker will offer to install the sticker set in some future. + */ + | "Sticker" + /** + * Message containing an Audio file. + */ + | "Audio" + /** + * A voice message that was directly recorded by the user. + * For all other audio messages, the type `Viewtype.Audio` should be used. + */ + | "Voice" + /** + * Video messages. + */ + | "Video" + /** + * Message containing any file, eg. a PDF. + */ + | "File" + /** + * Message is an invitation to a videochat. + */ + | "VideochatInvitation" + /** + * Message is an webxdc instance. + */ + | "Webxdc"; +export type I32 = number; +export type U64 = number; +export type Message = { + id: U32; + chatId: U32; + fromId: U32; + quotedText: string | null; + quotedMessageId: U32 | null; + text: string | null; + hasLocation: boolean; + hasHtml: boolean; + viewType: Viewtype; + state: U32; + timestamp: I64; + sortTimestamp: I64; + receivedTimestamp: I64; + hasDeviatingTimestamp: boolean; + subject: string; + showPadlock: boolean; + isSetupmessage: boolean; + isInfo: boolean; + isForwarded: boolean; + duration: I32; + dimensionsHeight: I32; + dimensionsWidth: I32; + videochatType: U32 | null; + videochatUrl: string | null; + overrideSenderName: string | null; + sender: Contact; + setupCodeBegin: string | null; + file: string | null; + fileMime: string | null; + fileBytes: U64; + fileName: string | null; +}; +export type WebxdcMessageInfo = { + /** + * The name of the app. + * + * Defaults to the filename if not set in the manifest. + */ + name: string; + /** + * App icon file name. + * Defaults to an standard icon if nothing is set in the manifest. + * + * To get the file, use dc_msg_get_webxdc_blob(). (not yet in jsonrpc, use rust api or cffi for it) + * + * App icons should should be square, + * the implementations will add round corners etc. as needed. + */ + icon: string; + /** + * if the Webxdc represents a document, then this is the name of the document + */ + document: string | null; + /** + * short string describing the state of the app, + * sth. as "2 votes", "Highscore: 123", + * can be changed by the apps + */ + summary: string | null; + /** + * URL where the source code of the Webxdc and other information can be found; + * defaults to an empty string. + * Implementations may offer an menu or a button to open this URL. + */ + sourceCodeUrl: string | null; +}; +export type __AllTyps = [ + string, + boolean, + Record, + U32, + U32, + null, + U32[], + U32, + null, + U32 | null, + Account[], + U32, + Account, + U32, + string, + ProviderInfo | null, + U32, + boolean, + U32, + Record, + U32, + string, + string | null, + null, + U32, + Record, + null, + U32, + string, + null, + U32, + string, + Qr, + U32, + string, + string | null, + U32, + string[], + Record, + U32, + null, + U32, + null, + U32, + U32[], + U32, + U32, + Usize, + U32, + string, + U32, + U32, + string, + null, + U32, + U32 | null, + string | null, + U32 | null, + ChatListEntry[], + U32, + ChatListEntry[], + Record, + U32, + U32, + FullChat, + U32, + U32, + null, + U32, + U32, + null, + U32, + string, + string, + U32, + U32, + U32, + U32, + U32[], + U32, + U32, + Message, + U32, + U32[], + Record, + U32, + U32, + Contact, + U32, + string, + string | null, + U32, + U32, + U32, + U32, + U32, + U32, + null, + U32, + U32, + null, + U32, + Contact[], + U32, + U32, + string | null, + U32[], + U32, + U32, + string | null, + Contact[], + U32, + U32[], + Record, + U32, + U32, + Viewtype, + Viewtype | null, + Viewtype | null, + U32[], + U32, + U32, + string, + string, + null, + U32, + U32, + U32, + string, + U32, + U32, + WebxdcMessageInfo, + U32, + string, + U32, + U32 +]; diff --git a/deltachat-jsonrpc/typescript/package.json b/deltachat-jsonrpc/typescript/package.json new file mode 100644 index 000000000..3ca53eb60 --- /dev/null +++ b/deltachat-jsonrpc/typescript/package.json @@ -0,0 +1,51 @@ +{ + "name": "deltachat-jsonrpc-client", + "version": "0.1.0", + "main": "dist/deltachat.js", + "types": "dist/deltachat.d.ts", + "type": "module", + "author": "Delta Chat Developers (ML) ", + "license": "MPL-2.0", + "scripts": { + "prettier:check": "prettier --check **.ts", + "prettier:fix": "prettier --write **.ts", + "generate-bindings": "cargo test", + "build": "run-s generate-bindings build:tsc build:bundle", + "build:tsc": "tsc", + "build:bundle": "esbuild --format=esm --bundle dist/deltachat.js --outfile=dist/deltachat.bundle.js", + "example": "run-s build example:build example:start", + "example:build": "esbuild --bundle dist/example/example.js --outfile=dist/example.bundle.js", + "example:start": "http-server .", + "example:dev": "esbuild example/example.ts --bundle --outfile=dist/example.bundle.js --servedir=.", + "test": "run-s test:prepare test:run-coverage test:report-coverage", + "test:prepare": "cargo build --features webserver --bin deltachat-jsonrpc-server", + "test:run": "mocha dist/test", + "test:run-coverage": "COVERAGE=1 NODE_OPTIONS=--enable-source-maps c8 --include 'dist/*' -r text -r html -r json mocha dist/test", + "test:report-coverage": "node report_api_coverage.mjs", + "docs": "typedoc --out docs deltachat.ts" + }, + "dependencies": { + "isomorphic-ws": "^4.0.1", + "tiny-emitter": "git+https://github.com/Simon-Laux/tiny-emitter.git", + "yerpc": "^0.3.3" + }, + "devDependencies": { + "@types/chai": "^4.2.21", + "@types/chai-as-promised": "^7.1.5", + "@types/mocha": "^9.0.0", + "@types/node-fetch": "^2.5.7", + "@types/ws": "^7.2.4", + "c8": "^7.10.0", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "esbuild": "^0.14.11", + "http-server": "^14.1.1", + "mocha": "^9.1.1", + "node-fetch": "^2.6.1", + "npm-run-all": "^4.1.5", + "prettier": "^2.6.2", + "typedoc": "^0.23.2", + "typescript": "^4.5.5", + "ws": "^8.5.0" + } +} diff --git a/deltachat-jsonrpc/typescript/report_api_coverage.mjs b/deltachat-jsonrpc/typescript/report_api_coverage.mjs new file mode 100644 index 000000000..f80c63c2e --- /dev/null +++ b/deltachat-jsonrpc/typescript/report_api_coverage.mjs @@ -0,0 +1,28 @@ +import { readFileSync } from "fs"; +// only checks for the coverge of the api functions in bindings.ts for now +const generatedFile = "typescript/generated/client.ts"; +const json = JSON.parse(readFileSync("./coverage/coverage-final.json")); +const jsonCoverage = + json[Object.keys(json).find((k) => k.includes(generatedFile))]; +const fnMap = Object.keys(jsonCoverage.fnMap).map( + (key) => jsonCoverage.fnMap[key] +); +const htmlCoverage = readFileSync( + "./coverage/" + generatedFile + ".html", + "utf8" +); +const uncoveredLines = htmlCoverage + .split("\n") + .filter((line) => line.includes(`"function not covered"`)); +const uncoveredFunctions = uncoveredLines.map( + (line) => />([\w_]+)\(/.exec(line)[1] +); +console.log( + "\nUncovered api functions:\n" + + uncoveredFunctions + .map((uF) => fnMap.find(({ name }) => name === uF)) + .map( + ({ name, line }) => `.${name.padEnd(40)} (${generatedFile}:${line})` + ) + .join("\n") +); diff --git a/deltachat-jsonrpc/typescript/src/client.ts b/deltachat-jsonrpc/typescript/src/client.ts new file mode 100644 index 000000000..cddcaeab8 --- /dev/null +++ b/deltachat-jsonrpc/typescript/src/client.ts @@ -0,0 +1,77 @@ +import * as T from "../generated/types.js"; +import * as RPC from "../generated/jsonrpc.js"; +import { RawClient } from "../generated/client.js"; +import { EventTypeName } from "../generated/events.js"; +import { WebsocketTransport, BaseTransport, Request } from "yerpc"; +import { TinyEmitter } from "tiny-emitter"; + +export type DeltaChatEvent = { + id: EventTypeName; + contextId: number; + field1: any; + field2: any; +}; +export type Events = Record< + EventTypeName | "ALL", + (event: DeltaChatEvent) => void +>; + +export class BaseDeltaChat< + Transport extends BaseTransport +> extends TinyEmitter { + rpc: RawClient; + account?: T.Account; + private contextEmitters: TinyEmitter[] = []; + constructor(public transport: Transport) { + super(); + this.rpc = new RawClient(this.transport); + this.transport.on("request", (request: Request) => { + const method = request.method; + if (method === "event") { + const event = request.params! as DeltaChatEvent; + this.emit(event.id, event); + this.emit("ALL", event); + + if (this.contextEmitters[event.contextId]) { + this.contextEmitters[event.contextId].emit(event.id, event); + this.contextEmitters[event.contextId].emit("ALL", event); + } + } + }); + } + + async listAccounts(): Promise { + return await this.rpc.getAllAccounts(); + } + + getContextEvents(account_id: number) { + if (this.contextEmitters[account_id]) { + return this.contextEmitters[account_id]; + } else { + this.contextEmitters[account_id] = new TinyEmitter(); + return this.contextEmitters[account_id]; + } + } +} + +export type Opts = { + url: string; +}; + +export const DEFAULT_OPTS: Opts = { + url: "ws://localhost:20808/ws", +}; +export class DeltaChat extends BaseDeltaChat { + opts: Opts; + close() { + this.transport.close(); + } + constructor(opts?: Opts | string) { + if (typeof opts === "string") opts = { url: opts }; + if (opts) opts = { ...DEFAULT_OPTS, ...opts }; + else opts = { ...DEFAULT_OPTS }; + const transport = new WebsocketTransport(opts.url) + super(transport); + this.opts = opts; + } +} diff --git a/deltachat-jsonrpc/typescript/src/lib.ts b/deltachat-jsonrpc/typescript/src/lib.ts new file mode 100644 index 000000000..153c1d6a2 --- /dev/null +++ b/deltachat-jsonrpc/typescript/src/lib.ts @@ -0,0 +1,6 @@ +export * as RPC from "../generated/jsonrpc.js"; +export * as T from "../generated/types.js"; +export * from "../generated/events.js"; +export { RawClient } from "../generated/client.js"; +export * from "./client.js"; +export * as yerpc from "yerpc"; diff --git a/deltachat-jsonrpc/typescript/test/basic.ts b/deltachat-jsonrpc/typescript/test/basic.ts new file mode 100644 index 000000000..ea300643f --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/basic.ts @@ -0,0 +1,154 @@ +import { strictEqual } from "assert"; +import chai, { assert, expect } from "chai"; +import chaiAsPromised from "chai-as-promised"; +chai.use(chaiAsPromised); +import { DeltaChat } from "../deltachat.js"; + +import { + RpcServerHandle, + startServer, +} from "./test_base.js"; + +describe("basic tests", () => { + let serverHandle: RpcServerHandle; + let dc: DeltaChat; + + before(async () => { + serverHandle = await startServer(); + // make sure server is up by the time we continue + await new Promise((res) => setTimeout(res, 100)); + dc = new DeltaChat(serverHandle.url) + // dc.on("ALL", (event) => { + //console.log("event", event); + // }); + }); + + after(async () => { + dc && dc.close(); + await serverHandle.close(); + }); + + it("check email address validity", async () => { + const validAddresses = [ + "email@example.com", + "36aa165ae3406424e0c61af17700f397cad3fe8ab83d682d0bddf3338a5dd52e@yggmail@yggmail", + ]; + const invalidAddresses = ["email@", "example.com", "emai221"]; + + expect( + await Promise.all( + validAddresses.map((email) => dc.rpc.checkEmailValidity(email)) + ) + ).to.not.contain(false); + + expect( + await Promise.all( + invalidAddresses.map((email) => dc.rpc.checkEmailValidity(email)) + ) + ).to.not.contain(true); + }); + + it("system info", async () => { + const systemInfo = await dc.rpc.getSystemInfo(); + expect(systemInfo).to.contain.keys([ + "arch", + "num_cpus", + "deltachat_core_version", + "sqlite_version", + ]); + }); + + describe("account managment", () => { + it("should create account", async () => { + const res = await dc.rpc.addAccount(); + assert((await dc.rpc.getAllAccountIds()).length === 1); + }); + + it("should remove the account again", async () => { + await dc.rpc.removeAccount((await dc.rpc.getAllAccountIds())[0]); + assert((await dc.rpc.getAllAccountIds()).length === 0); + }); + + it("should create multiple accounts", async () => { + await dc.rpc.addAccount(); + await dc.rpc.addAccount(); + await dc.rpc.addAccount(); + await dc.rpc.addAccount(); + assert((await dc.rpc.getAllAccountIds()).length === 4); + }); + }); + + describe("contact managment", function () { + let accountId: number; + before(async () => { + accountId = await dc.rpc.addAccount(); + }); + it("should block and unblock contact", async function () { + const contactId = await dc.rpc.contactsCreateContact( + accountId, + "example@delta.chat", + null + ); + expect((await dc.rpc.contactsGetContact(accountId, contactId)).isBlocked).to.be + .false; + await dc.rpc.contactsBlock(accountId, contactId); + expect((await dc.rpc.contactsGetContact(accountId, contactId)).isBlocked).to.be + .true; + expect(await dc.rpc.contactsGetBlocked(accountId)).to.have.length(1); + await dc.rpc.contactsUnblock(accountId, contactId); + expect((await dc.rpc.contactsGetContact(accountId, contactId)).isBlocked).to.be + .false; + expect(await dc.rpc.contactsGetBlocked(accountId)).to.have.length(0); + }); + }); + + describe("configuration", function () { + let accountId: number; + before(async () => { + accountId = await dc.rpc.addAccount(); + }); + + it("set and retrive", async function () { + await dc.rpc.setConfig(accountId, "addr", "valid@email"); + assert((await dc.rpc.getConfig(accountId, "addr")) == "valid@email"); + }); + it("set invalid key should throw", async function () { + await expect(dc.rpc.setConfig(accountId, "invalid_key", "some value")).to.be + .eventually.rejected; + }); + it("get invalid key should throw", async function () { + await expect(dc.rpc.getConfig(accountId, "invalid_key")).to.be.eventually + .rejected; + }); + it("set and retrive ui.*", async function () { + await dc.rpc.setConfig(accountId, "ui.chat_bg", "color:red"); + assert((await dc.rpc.getConfig(accountId, "ui.chat_bg")) == "color:red"); + }); + it("set and retrive (batch)", async function () { + const config = { addr: "valid@email", mail_pw: "1234" }; + await dc.rpc.batchSetConfig(accountId, config); + const retrieved = await dc.rpc.batchGetConfig(accountId, Object.keys(config)); + expect(retrieved).to.deep.equal(config); + }); + it("set and retrive ui.* (batch)", async function () { + const config = { + "ui.chat_bg": "color:green", + "ui.enter_key_sends": "true", + }; + await dc.rpc.batchSetConfig(accountId, config); + const retrieved = await dc.rpc.batchGetConfig(accountId, Object.keys(config)); + expect(retrieved).to.deep.equal(config); + }); + it("set and retrive mixed(ui and core) (batch)", async function () { + const config = { + "ui.chat_bg": "color:yellow", + "ui.enter_key_sends": "false", + addr: "valid2@email", + mail_pw: "123456", + }; + await dc.rpc.batchSetConfig(accountId, config); + const retrieved = await dc.rpc.batchGetConfig(accountId, Object.keys(config)); + expect(retrieved).to.deep.equal(config); + }); + }); +}); diff --git a/deltachat-jsonrpc/typescript/test/online.ts b/deltachat-jsonrpc/typescript/test/online.ts new file mode 100644 index 000000000..ba8b50673 --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/online.ts @@ -0,0 +1,202 @@ +import { assert, expect } from "chai"; +import { DeltaChat, DeltaChatEvent, EventTypeName } from "../deltachat.js"; +import { + RpcServerHandle, + createTempUser, + startServer, +} from "./test_base.js"; + +const EVENT_TIMEOUT = 20000 + +describe("online tests", function () { + let serverHandle: RpcServerHandle; + let dc: DeltaChat; + let account1: { email: string; password: string }; + let account2: { email: string; password: string }; + let accountId1: number, accountId2: number; + + before(async function () { + this.timeout(12000) + if (!process.env.DCC_NEW_TMP_EMAIL) { + if (process.env.COVERAGE && !process.env.COVERAGE_OFFLINE) { + console.error( + "CAN NOT RUN COVERAGE correctly: Missing DCC_NEW_TMP_EMAIL environment variable!\n\n", + "You can set COVERAGE_OFFLINE=1 to circumvent this check and skip the online tests, but those coverage results will be wrong, because some functions can only be tested in the online test" + ); + process.exit(1); + } + console.log( + "Missing DCC_NEW_TMP_EMAIL environment variable!, skip intergration tests" + ); + this.skip(); + } + serverHandle = await startServer(); + dc = new DeltaChat(serverHandle.url) + + dc.on("ALL", ({ id, contextId }) => { + if (id !== "Info") console.log(contextId, id); + }); + + account1 = await createTempUser(process.env.DCC_NEW_TMP_EMAIL); + if (!account1 || !account1.email || !account1.password) { + console.log( + "We didn't got back an account from the api, skip intergration tests" + ); + this.skip(); + } + + account2 = await createTempUser(process.env.DCC_NEW_TMP_EMAIL); + if (!account2 || !account2.email || !account2.password) { + console.log( + "We didn't got back an account2 from the api, skip intergration tests" + ); + this.skip(); + } + }); + + after(async () => { + dc && dc.close(); + serverHandle && (await serverHandle.close()); + }); + + let accountsConfigured = false; + + it("configure test accounts", async function () { + this.timeout(40000); + + accountId1 = await dc.rpc.addAccount(); + await dc.rpc.setConfig(accountId1, "addr", account1.email); + await dc.rpc.setConfig(accountId1, "mail_pw", account1.password); + await dc.rpc.configure(accountId1); + + accountId2 = await dc.rpc.addAccount(); + await dc.rpc.batchSetConfig(accountId2, { + addr: account2.email, + mail_pw: account2.password, + }); + await dc.rpc.configure(accountId2) + accountsConfigured = true; + }); + + it("send and recieve text message", async function () { + if (!accountsConfigured) { + this.skip(); + } + this.timeout(15000); + + const contactId = await dc.rpc.contactsCreateContact( + accountId1, + account2.email, + null + ); + const chatId = await dc.rpc.contactsCreateChatByContactId(accountId1, contactId); + const eventPromise = Promise.race([ + waitForEvent(dc, "MsgsChanged", accountId2), + waitForEvent(dc, "IncomingMsg", accountId2), + ]); + + await dc.rpc.miscSendTextMessage(accountId1, "Hello", chatId); + const { field1: chatIdOnAccountB } = await eventPromise; + await dc.rpc.acceptChat(accountId2, chatIdOnAccountB); + const messageList = await dc.rpc.messageListGetMessageIds( + accountId2, + chatIdOnAccountB, + 0 + ); + + expect(messageList).have.length(1); + const message = await dc.rpc.messageGetMessage(accountId2, messageList[0]); + expect(message.text).equal("Hello"); + }); + + it("send and recieve text message roundtrip, encrypted on answer onwards", async function () { + if (!accountsConfigured) { + this.skip(); + } + this.timeout(10000); + + // send message from A to B + const contactId = await dc.rpc.contactsCreateContact( + accountId1, + account2.email, + null + ); + const chatId = await dc.rpc.contactsCreateChatByContactId(accountId1, contactId); + const eventPromise = Promise.race([ + waitForEvent(dc, "MsgsChanged", accountId2), + waitForEvent(dc, "IncomingMsg", accountId2), + ]); + dc.rpc.miscSendTextMessage(accountId1, "Hello2", chatId); + // wait for message from A + console.log("wait for message from A"); + + const event = await eventPromise; + const { field1: chatIdOnAccountB } = event; + + await dc.rpc.acceptChat(accountId2, chatIdOnAccountB); + const messageList = await dc.rpc.messageListGetMessageIds( + accountId2, + chatIdOnAccountB, + 0 + ); + const message = await dc.rpc.messageGetMessage( + accountId2, + messageList.reverse()[0] + ); + expect(message.text).equal("Hello2"); + // Send message back from B to A + const eventPromise2 = Promise.race([ + waitForEvent(dc, "MsgsChanged", accountId1), + waitForEvent(dc, "IncomingMsg", accountId1), + ]); + dc.rpc.miscSendTextMessage(accountId2, "super secret message", chatId); + // Check if answer arives at A and if it is encrypted + await eventPromise2; + + const messageId = ( + await dc.rpc.messageListGetMessageIds(accountId1, chatId, 0) + ).reverse()[0]; + const message2 = await dc.rpc.messageGetMessage(accountId1, messageId); + expect(message2.text).equal("super secret message"); + expect(message2.showPadlock).equal(true); + }); + + it("get provider info for example.com", async () => { + const acc = await dc.rpc.addAccount(); + const info = await dc.rpc.getProviderInfo(acc, "example.com"); + expect(info).to.be.not.null; + expect(info?.overviewPage).to.equal( + "https://providers.delta.chat/example-com" + ); + expect(info?.status).to.equal(3); + }); + + it("get provider info - domain and email should give same result", async () => { + const acc = await dc.rpc.addAccount(); + const info_domain = await dc.rpc.getProviderInfo(acc, "example.com"); + const info_email = await dc.rpc.getProviderInfo(acc, "hi@example.com"); + expect(info_email).to.deep.equal(info_domain); + }); +}); + +async function waitForEvent( + dc: DeltaChat, + eventType: EventTypeName, + accountId: number, + timeout: number = EVENT_TIMEOUT +): Promise { + return new Promise((resolve, reject) => { + const rejectTimeout = setTimeout( + () => reject(new Error('Timeout reached before event came in')), + timeout + ) + const callback = (event: DeltaChatEvent) => { + if (event.contextId == accountId) { + dc.off(eventType, callback); + clearTimeout(rejectTimeout) + resolve(event); + } + }; + dc.on(eventType, callback); + }); +} diff --git a/deltachat-jsonrpc/typescript/test/test_base.ts b/deltachat-jsonrpc/typescript/test/test_base.ts new file mode 100644 index 000000000..71c79b235 --- /dev/null +++ b/deltachat-jsonrpc/typescript/test/test_base.ts @@ -0,0 +1,94 @@ +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { mkdtemp, rm } from "fs/promises"; +import { existsSync } from "fs"; +import { spawn, exec } from "child_process"; +import fetch from "node-fetch"; + +export const RPC_SERVER_PORT = 20808; + +export type RpcServerHandle = { + url: string, + close: () => Promise +} + +export async function startServer(port: number = RPC_SERVER_PORT): Promise { + const tmpDir = await mkdtemp(join(tmpdir(), "deltachat-jsonrpc-test")); + + const pathToServerBinary = resolve(join(await getTargetDir(), "debug/deltachat-jsonrpc-server")); + console.log('using server binary: ' + pathToServerBinary); + + if (!existsSync(pathToServerBinary)) { + throw new Error( + "server executable does not exist, you need to build it first" + + "\nserver executable not found at " + + pathToServerBinary + ); + } + + const server = spawn(pathToServerBinary, { + cwd: tmpDir, + env: { + RUST_LOG: process.env.RUST_LOG || "info", + DC_PORT: '' + port + }, + }); + let shouldClose = false; + + server.on("exit", () => { + if (shouldClose) { + return; + } + throw new Error("Server quit"); + }); + + server.stderr.pipe(process.stderr); + server.stdout.pipe(process.stdout) + + const url = `ws://localhost:${port}/ws` + + return { + url, + close: async () => { + shouldClose = true; + if (!server.kill()) { + console.log("server termination failed"); + } + await rm(tmpDir, { recursive: true }); + }, + }; +} + +export async function createTempUser(url: string) { + const response = await fetch(url, { + method: "POST", + headers: { + "cache-control": "no-cache", + }, + }); + if (!response.ok) throw new Error('Received invalid response') + return response.json(); +} + +function getTargetDir(): Promise { + return new Promise((resolve, reject) => { + exec( + "cargo metadata --no-deps --format-version 1", + (error, stdout, _stderr) => { + if (error) { + console.log("error", error); + reject(error); + } else { + try { + const json = JSON.parse(stdout); + resolve(json.target_directory); + } catch (error) { + console.log("json error", error); + reject(error); + } + } + } + ); + }); +} + diff --git a/deltachat-jsonrpc/typescript/tsconfig.json b/deltachat-jsonrpc/typescript/tsconfig.json new file mode 100644 index 000000000..bbb699cf4 --- /dev/null +++ b/deltachat-jsonrpc/typescript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "alwaysStrict": true, + "strict": true, + "sourceMap": true, + "strictNullChecks": true, + "rootDir": ".", + "outDir": "dist", + "lib": ["ES2017", "dom"], + "target": "ES2017", + "module": "es2020", + "declaration": true, + "esModuleInterop": true, + "moduleResolution": "node", + "noImplicitAny": true, + "isolatedModules": true + }, + "include": ["*.ts", "example/*.ts", "test/*.ts"], + "compileOnSave": false +} diff --git a/node/lib/deltachat.ts b/node/lib/deltachat.ts index 152e4baaf..7a3cf79db 100644 --- a/node/lib/deltachat.ts +++ b/node/lib/deltachat.ts @@ -19,10 +19,11 @@ interface NativeAccount {} export class AccountManager extends EventEmitter { dcn_accounts: NativeAccount accountDir: string + jsonRpcStarted = false constructor(cwd: string, os = 'deltachat-node') { - debug('DeltaChat constructor') super() + debug('DeltaChat constructor') this.accountDir = cwd this.dcn_accounts = binding.dcn_accounts_new(os, this.accountDir) @@ -114,6 +115,31 @@ export class AccountManager extends EventEmitter { debug('Started event handler') } + startJsonRpcHandler(callback: ((response: string) => void) | null) { + if (this.dcn_accounts === null) { + throw new Error('dcn_account is null') + } + if (!callback) { + throw new Error('no callback set') + } + if (this.jsonRpcStarted) { + throw new Error('jsonrpc was started already') + } + + binding.dcn_accounts_start_jsonrpc(this.dcn_accounts, callback.bind(this)) + debug('Started JSON-RPC handler') + this.jsonRpcStarted = true + } + + jsonRpcRequest(message: string) { + if (!this.jsonRpcStarted) { + throw new Error( + 'jsonrpc is not active, start it with startJsonRpcHandler first' + ) + } + binding.dcn_json_rpc_request(this.dcn_accounts, message) + } + startIO() { binding.dcn_accounts_start_io(this.dcn_accounts) } diff --git a/node/scripts/rebuild-core.js b/node/scripts/rebuild-core.js index 80dedcce5..9ff794773 100644 --- a/node/scripts/rebuild-core.js +++ b/node/scripts/rebuild-core.js @@ -9,7 +9,7 @@ const buildArgs = [ 'build', '--release', '--features', - 'vendored', + 'vendored,jsonrpc', '-p', 'deltachat_ffi' ] diff --git a/node/segfault.js b/node/segfault.js new file mode 100644 index 000000000..bc7415144 --- /dev/null +++ b/node/segfault.js @@ -0,0 +1,28 @@ +const { default: dc } = require('./dist') + +const ac = new dc('test1233490') + +console.log("[1]"); + +ac.startJsonRpcHandler(console.log) + +console.log("[2]"); +console.log( + ac.jsonRpcRequest( + JSON.stringify({ + jsonrpc: '2.0', + method: 'get_all_account_ids', + params: [], + id: 2, + }) + ) +) + +console.log("[3]"); + +setTimeout(() => { + console.log("[4]"); + ac.close() // This segfaults -> TODO Findout why? + + console.log('still living') +}, 1000) \ No newline at end of file diff --git a/node/segfault2.js b/node/segfault2.js new file mode 100644 index 000000000..28040b51b --- /dev/null +++ b/node/segfault2.js @@ -0,0 +1,50 @@ +const { default: dc } = require('./dist') + +const ac = new dc('test1233490') + +console.log('[1]') + +ac.startJsonRpcHandler(console.log) + +console.log('[2]') +console.log( + ac.jsonRpcRequest( + JSON.stringify({ + jsonrpc: '2.0', + method: 'batch_set_config', + id: 3, + params: [ + 69, + { + addr: '', + mail_user: '', + mail_pw: '', + mail_server: '', + mail_port: '', + mail_security: '', + imap_certificate_checks: '', + send_user: '', + send_pw: '', + send_server: '', + send_port: '', + send_security: '', + smtp_certificate_checks: '', + socks5_enabled: '0', + socks5_host: '', + socks5_port: '', + socks5_user: '', + socks5_password: '', + }, + ], + }) + ) +) + +console.log('[3]') + +setTimeout(() => { + console.log('[4]') + ac.close() // This segfaults -> TODO Findout why? + + console.log('still living') +}, 1000) diff --git a/node/src/module.c b/node/src/module.c index 2444ff28d..61ae675b9 100644 --- a/node/src/module.c +++ b/node/src/module.c @@ -34,6 +34,9 @@ typedef struct dcn_accounts_t { dc_accounts_t* dc_accounts; napi_threadsafe_function threadsafe_event_handler; uv_thread_t event_handler_thread; + napi_threadsafe_function threadsafe_jsonrpc_handler; + uv_thread_t jsonrpc_thread; + dc_jsonrpc_instance_t* jsonrpc_instance; int gc; } dcn_accounts_t; @@ -2936,6 +2939,12 @@ NAPI_METHOD(dcn_accounts_unref) { uv_thread_join(&dcn_accounts->event_handler_thread); dcn_accounts->event_handler_thread = 0; } + if (dcn_accounts->jsonrpc_instance) { + dc_jsonrpc_request(dcn_accounts->jsonrpc_instance, "{}"); + uv_thread_join(&dcn_accounts->jsonrpc_thread); + dc_jsonrpc_unref(dcn_accounts->jsonrpc_instance); + dcn_accounts->jsonrpc_instance = NULL; + } dc_accounts_unref(dcn_accounts->dc_accounts); dcn_accounts->dc_accounts = NULL; @@ -3094,8 +3103,6 @@ static void accounts_event_handler_thread_func(void* arg) { dcn_accounts_t* dcn_accounts = (dcn_accounts_t*)arg; - - TRACE("event_handler_thread_func starting"); dc_accounts_event_emitter_t * dc_accounts_event_emitter = dc_accounts_get_event_emitter(dcn_accounts->dc_accounts); @@ -3185,7 +3192,7 @@ static void call_accounts_js_event_handler(napi_env env, napi_value js_callback, if (status != napi_ok) { napi_throw_error(env, NULL, "Unable to create argv[3] for event_handler arguments"); } - free(data2_string); + dc_str_unref(data2_string); } else { status = napi_create_int32(env, dc_event_get_data2_int(dc_event), &argv[3]); if (status != napi_ok) { @@ -3246,6 +3253,124 @@ NAPI_METHOD(dcn_accounts_start_event_handler) { NAPI_RETURN_UNDEFINED(); } +// JSON RPC + +static void accounts_jsonrpc_thread_func(void* arg) +{ + dcn_accounts_t* dcn_accounts = (dcn_accounts_t*)arg; + TRACE("accounts_jsonrpc_thread_func starting"); + char* response; + while (true) { + response = dc_jsonrpc_next_response(dcn_accounts->jsonrpc_instance); + if (response == NULL) { + // done or broken + break; + } + + if (!dcn_accounts->threadsafe_jsonrpc_handler) { + TRACE("threadsafe_jsonrpc_handler not set, bailing"); + break; + } + // Don't process events if we're being garbage collected! + if (dcn_accounts->gc == 1) { + TRACE("dc_accounts has been destroyed, bailing"); + break; + } + + napi_status status = napi_call_threadsafe_function(dcn_accounts->threadsafe_jsonrpc_handler, response, napi_tsfn_blocking); + + if (status == napi_closing) { + TRACE("JS function got released, bailing"); + break; + } + } + TRACE("accounts_jsonrpc_thread_func ended"); + napi_release_threadsafe_function(dcn_accounts->threadsafe_jsonrpc_handler, napi_tsfn_release); +} + +static void call_accounts_js_jsonrpc_handler(napi_env env, napi_value js_callback, void* _context, void* data) +{ + char* response = (char*)data; + napi_value global; + napi_status status = napi_get_global(env, &global); + if (status != napi_ok) { + napi_throw_error(env, NULL, "Unable to get global"); + } + + napi_value argv[1]; + if (response != 0) { + status = napi_create_string_utf8(env, response, NAPI_AUTO_LENGTH, &argv[0]); + } else { + status = napi_create_string_utf8(env, "", NAPI_AUTO_LENGTH, &argv[0]); + } + if (status != napi_ok) { + napi_throw_error(env, NULL, "Unable to create argv for js jsonrpc_handler arguments"); + } + dc_str_unref(response); + + TRACE("calling back into js"); + napi_value result; + status = napi_call_function( + env, + global, + js_callback, + 1, + argv, + &result); + if (status != napi_ok) { + TRACE("Unable to call jsonrpc_handler callback2"); + const napi_extended_error_info* error_result; + NAPI_STATUS_THROWS(napi_get_last_error_info(env, &error_result)); + } +} + +NAPI_METHOD(dcn_accounts_start_jsonrpc) { + NAPI_ARGV(2); + NAPI_DCN_ACCOUNTS(); + napi_value callback = argv[1]; + + TRACE("calling.."); + napi_value async_resource_name; + NAPI_STATUS_THROWS(napi_create_string_utf8(env, "dc_accounts_jsonrpc_callback", NAPI_AUTO_LENGTH, &async_resource_name)); + + TRACE("creating threadsafe function.."); + + NAPI_STATUS_THROWS(napi_create_threadsafe_function( + env, + callback, + 0, + async_resource_name, + 1000, // max_queue_size + 1, + NULL, + NULL, + NULL, + call_accounts_js_jsonrpc_handler, + &dcn_accounts->threadsafe_jsonrpc_handler)); + TRACE("done"); + + dcn_accounts->gc = 0; + dcn_accounts->jsonrpc_instance = dc_jsonrpc_init(dcn_accounts->dc_accounts); + + TRACE("creating uv thread.."); + uv_thread_create(&dcn_accounts->jsonrpc_thread, accounts_jsonrpc_thread_func, dcn_accounts); + + NAPI_RETURN_UNDEFINED(); +} + +NAPI_METHOD(dcn_json_rpc_request) { + NAPI_ARGV(2); + NAPI_DCN_ACCOUNTS(); + if (!dcn_accounts->jsonrpc_instance) { + const char* msg = "dcn_accounts->jsonrpc_instance is null, have you called dcn_accounts_start_jsonrpc()?"; + NAPI_STATUS_THROWS(napi_throw_type_error(env, NULL, msg)); + } + NAPI_ARGV_UTF8_MALLOC(request, 1); + dc_jsonrpc_request(dcn_accounts->jsonrpc_instance, request); + free(request); + NAPI_RETURN_UNDEFINED(); +} + NAPI_INIT() { /** @@ -3517,4 +3642,9 @@ NAPI_INIT() { NAPI_EXPORT_FUNCTION(dcn_send_webxdc_status_update); NAPI_EXPORT_FUNCTION(dcn_get_webxdc_status_updates); NAPI_EXPORT_FUNCTION(dcn_msg_get_webxdc_blob); + + + /** jsonrpc **/ + NAPI_EXPORT_FUNCTION(dcn_accounts_start_jsonrpc); + NAPI_EXPORT_FUNCTION(dcn_json_rpc_request); } diff --git a/node/src/napi-macros-extensions.h b/node/src/napi-macros-extensions.h index badb63969..968bc02ca 100644 --- a/node/src/napi-macros-extensions.h +++ b/node/src/napi-macros-extensions.h @@ -23,7 +23,7 @@ dcn_accounts_t* dcn_accounts; \ NAPI_STATUS_THROWS(napi_get_value_external(env, argv[0], (void**)&dcn_accounts)); \ if (!dcn_accounts) { \ - const char* msg = "Provided dnc_acounts is null"; \ + const char* msg = "Provided dcn_acounts is null"; \ NAPI_STATUS_THROWS(napi_throw_type_error(env, NULL, msg)); \ } \ if (!dcn_accounts->dc_accounts) { \ diff --git a/node/test/test.js b/node/test/test.js index 30393272c..ee9aa3bad 100644 --- a/node/test/test.js +++ b/node/test/test.js @@ -2,7 +2,7 @@ import DeltaChat, { Message } from '../dist' import binding from '../binding' -import { strictEqual } from 'assert' +import { deepEqual, deepStrictEqual, strictEqual } from 'assert' import chai, { expect } from 'chai' import chaiAsPromised from 'chai-as-promised' import { EventId2EventName, C } from '../dist/constants' @@ -84,6 +84,95 @@ describe('static tests', function () { }) }) +describe('JSON RPC', function () { + it('smoketest', async function () { + const { dc } = DeltaChat.newTemporary() + let promise_resolve + const promise = new Promise((res, _rej) => { + promise_resolve = res + }) + dc.startJsonRpcHandler(promise_resolve) + dc.jsonRpcRequest( + JSON.stringify({ + jsonrpc: '2.0', + method: 'get_all_account_ids', + params: [], + id: 2, + }) + ) + deepStrictEqual( + { + jsonrpc: '2.0', + id: 2, + result: [1], + }, + JSON.parse(await promise) + ) + dc.close() + }) + + it('basic test', async function () { + const { dc } = DeltaChat.newTemporary() + + const promises = {} + dc.startJsonRpcHandler((msg) => { + const response = JSON.parse(msg) + promises[response.id](response) + delete promises[response.id] + }) + const call = (request) => { + dc.jsonRpcRequest(JSON.stringify(request)) + return new Promise((res, _rej) => { + promises[request.id] = res + }) + } + + deepStrictEqual( + { + jsonrpc: '2.0', + id: 2, + result: [1], + }, + await call({ + jsonrpc: '2.0', + method: 'get_all_account_ids', + params: [], + id: 2, + }) + ) + + deepStrictEqual( + { + jsonrpc: '2.0', + id: 3, + result: 2, + }, + await call({ + jsonrpc: '2.0', + method: 'add_account', + params: [], + id: 3, + }) + ) + + deepStrictEqual( + { + jsonrpc: '2.0', + id: 4, + result: [1, 2], + }, + await call({ + jsonrpc: '2.0', + method: 'get_all_account_ids', + params: [], + id: 4, + }) + ) + + dc.close() + }) +}) + describe('Basic offline Tests', function () { it('opens a context', async function () { const { dc, context } = DeltaChat.newTemporary() diff --git a/package.json b/package.json index fb223a42c..f4a8311fd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@deltachat/jsonrpc-client": "file:deltachat-jsonrpc/typescript", "debug": "^4.1.1", "napi-macros": "^2.0.0", "node-gyp-build": "^4.1.0" diff --git a/scripts/set_core_version.py b/scripts/set_core_version.py index 791c5fc3a..753e3177e 100755 --- a/scripts/set_core_version.py +++ b/scripts/set_core_version.py @@ -63,7 +63,7 @@ def main(): parser = ArgumentParser(prog="set_core_version") parser.add_argument("newversion") - toml_list = ["Cargo.toml", "deltachat-ffi/Cargo.toml"] + toml_list = ["Cargo.toml", "deltachat-ffi/Cargo.toml", "deltachat-jsonrpc/Cargo.toml"] try: opts = parser.parse_args() except SystemExit: