diff --git a/.fallowrc.json b/.fallowrc.json index 022e0cc..ba50fb5 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -3,6 +3,7 @@ "entry": [ "src/index.{ts,tsx,js,jsx}", "src/main.{ts,tsx,js,jsx}", + "apps/pwa/src/serviceWorker.ts", "apps/tauri/render-version.ts", "packages/**/*.test.ts" ], diff --git a/.forgejo/workflows/dependency-builds.yml b/.forgejo/workflows/dependency-builds.yml index f8ec1cd..2ae48fe 100644 --- a/.forgejo/workflows/dependency-builds.yml +++ b/.forgejo/workflows/dependency-builds.yml @@ -13,7 +13,6 @@ jobs: name: Build web runs-on: nixos steps: - - run: nix profile add nixpkgs#nodejs_24 - uses: https://data.forgejo.org/actions/checkout@v4 with: persist-credentials: false @@ -27,7 +26,6 @@ jobs: name: Build desktop runs-on: nixos steps: - - run: nix profile add nixpkgs#nodejs_24 - uses: https://data.forgejo.org/actions/checkout@v4 with: persist-credentials: false @@ -36,12 +34,23 @@ jobs: - run: nix develop .#electron --command pnpm run build:packages - run: nix develop .#electron --command pnpm run build:desktop - mobile: + native-mtp: if: ${{ github.actor == 'rasensprenger' }} - name: Build mobile + name: Test native MTP runs-on: nixos steps: - run: nix profile add nixpkgs#nodejs_24 + - uses: https://data.forgejo.org/actions/checkout@v4 + with: + persist-credentials: false + - run: git submodule update --init --recursive + - run: nix develop .#electron --command bash -lc 'cd apps/tauri/src-tauri && cargo test' + + mobile: + if: ${{ github.actor == 'rasensprenger' }} + name: Build mobile + runs-on: nixos + steps: - uses: https://data.forgejo.org/actions/checkout@v4 with: persist-credentials: false diff --git a/.forgejo/workflows/deploy-dev.yml b/.forgejo/workflows/deploy-dev.yml index a09f69b..c340cbf 100644 --- a/.forgejo/workflows/deploy-dev.yml +++ b/.forgejo/workflows/deploy-dev.yml @@ -9,9 +9,6 @@ jobs: build-web: runs-on: nixos steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -36,9 +33,6 @@ jobs: build-mobile: runs-on: nixos steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -59,8 +53,6 @@ jobs: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} run: | - nix profile add nixpkgs#gnused - set -euo pipefail if [ -z "$KEYSTORE_BASE64" ]; then @@ -125,9 +117,6 @@ jobs: matrix: target: [linux] steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -178,9 +167,6 @@ jobs: runs-on: nixos needs: [build-web, build-mobile, build-desktop] steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 with: diff --git a/.forgejo/workflows/deploy-prod.yml b/.forgejo/workflows/deploy-prod.yml index 5fe2ba9..dd7a5f9 100644 --- a/.forgejo/workflows/deploy-prod.yml +++ b/.forgejo/workflows/deploy-prod.yml @@ -10,9 +10,6 @@ jobs: build-web: runs-on: nixos steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -37,9 +34,6 @@ jobs: build-mobile: runs-on: nixos steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -60,8 +54,6 @@ jobs: KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} run: | - nix profile add nixpkgs#gnused - set -euo pipefail if [ -z "$KEYSTORE_BASE64" ]; then @@ -126,9 +118,6 @@ jobs: matrix: target: [linux] steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 @@ -177,9 +166,6 @@ jobs: runs-on: nixos needs: [build-web, build-mobile, build-desktop] steps: - - name: Install node - run: nix profile add nixpkgs#nodejs_24 - - name: Check out repo uses: https://data.forgejo.org/actions/checkout@v4 diff --git a/apps/electron/build/icons/128x128.png b/apps/electron/build/icons/128x128.png deleted file mode 100644 index 357e1c2..0000000 Binary files a/apps/electron/build/icons/128x128.png and /dev/null differ diff --git a/apps/electron/build/icons/128x128@2x.png b/apps/electron/build/icons/128x128@2x.png deleted file mode 100644 index be55e24..0000000 Binary files a/apps/electron/build/icons/128x128@2x.png and /dev/null differ diff --git a/apps/electron/build/icons/32x32.png b/apps/electron/build/icons/32x32.png deleted file mode 100644 index bc4fba4..0000000 Binary files a/apps/electron/build/icons/32x32.png and /dev/null differ diff --git a/apps/electron/build/icons/64x64.png b/apps/electron/build/icons/64x64.png deleted file mode 100644 index 567f4ad..0000000 Binary files a/apps/electron/build/icons/64x64.png and /dev/null differ diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns deleted file mode 100644 index a7f0a1e..0000000 Binary files a/apps/electron/build/icons/icon.icns and /dev/null differ diff --git a/apps/electron/build/icons/icon.ico b/apps/electron/build/icons/icon.ico deleted file mode 100644 index c5c68f8..0000000 Binary files a/apps/electron/build/icons/icon.ico and /dev/null differ diff --git a/apps/electron/build/icons/icon.png b/apps/electron/build/icons/icon.png deleted file mode 100644 index dad3edb..0000000 Binary files a/apps/electron/build/icons/icon.png and /dev/null differ diff --git a/apps/pwa/src/serviceWorker.ts b/apps/pwa/src/serviceWorker.ts index de341bb..554ceeb 100644 --- a/apps/pwa/src/serviceWorker.ts +++ b/apps/pwa/src/serviceWorker.ts @@ -34,19 +34,22 @@ function isPushPayload(value: unknown): value is PushPayload { const message = payload.message as Partial | undefined; const secret = payload.secret as Partial | undefined; + const stringValues = [ + payload.sender, + message?.content, + secret?.chatId, + secret?.secretId, + secret?.encryptedSecret, + secret?.kemCiphertext, + secret?.wrappingScheme, + ]; return ( payload.version === 1 && typeof payload.senderId === "number" && Number.isSafeInteger(payload.senderId) && payload.senderId > 0 && - typeof payload.sender === "string" && - typeof message?.content === "string" && - typeof secret?.chatId === "string" && - typeof secret.secretId === "string" && - typeof secret.version === "number" && - typeof secret.encryptedSecret === "string" && - typeof secret.kemCiphertext === "string" && - typeof secret.wrappingScheme === "string" + typeof secret?.version === "number" && + stringValues.every((item) => typeof item === "string") ); } diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index b1b1c0b..d404d61 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -674,12 +674,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -889,7 +883,6 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -974,25 +967,14 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1057,7 +1039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid 0.10.2", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -1204,38 +1186,14 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8 0.10.2", - "signature 2.2.0", -] - [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8 0.11.0", - "signature 3.0.0", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", + "pkcs8", + "signature", ] [[package]] @@ -1245,10 +1203,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", - "ed25519 3.0.0", + "ed25519", "serde", "sha2 0.11.0", - "signature 3.0.0", + "signature", "subtle", "zeroize", ] @@ -2737,14 +2695,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid 0.10.2", + "const-oid", "crypto-common 0.2.2", "ctutils", "hybrid-array", "module-lattice", - "pkcs8 0.11.0", + "pkcs8", "shake", - "signature 3.0.0", + "signature", ] [[package]] @@ -2786,72 +2744,50 @@ dependencies = [ [[package]] name = "mtp" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "mtp-client", - "mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", + "mtp-codec", + "mtp-common", + "mtp-crypto", "mtp-host", - "mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", + "mtp-transport", + "mtp-type-map", "mtp-webserver", ] [[package]] name = "mtp-client" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ - "mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", + "mtp-codec", + "mtp-common", + "mtp-crypto", + "mtp-transport", "rand 0.10.2", "tokio", ] [[package]] name = "mtp-codec" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "base64 0.23.1", "byteorder", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", + "mtp-common", + "mtp-crypto", + "mtp-type-map", "rand 0.10.2", -] - -[[package]] -name = "mtp-codec" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" -dependencies = [ - "base64 0.23.1", - "byteorder", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)", - "mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)", - "rand 0.10.2", -] - -[[package]] -name = "mtp-common" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" -dependencies = [ - "quinn", - "rustls", "thiserror 2.0.19", - "wtransport", ] [[package]] name = "mtp-common" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "quinn", "rustls", @@ -2861,18 +2797,18 @@ dependencies = [ [[package]] name = "mtp-crypto" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ - "base64 0.23.1", + "base64 0.22.1", "chacha20poly1305", - "ed25519-dalek 2.2.0", + "ed25519-dalek", "getrandom 0.4.3", "hkdf", "ml-dsa", "mlkem-tls", "rand 0.10.2", - "rand_core 0.10.1", + "rand_core 0.6.4", "rustls", "serde", "sha2 0.11.0", @@ -2882,89 +2818,45 @@ dependencies = [ ] [[package]] -name = "mtp-crypto" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" +name = "mtp-host" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ - "base64 0.23.1", - "chacha20poly1305", - "ed25519-dalek 3.0.0", - "getrandom 0.4.3", - "hkdf", - "ml-dsa", + "mtp-codec", + "mtp-common", + "mtp-crypto", + "mtp-transport", "rand 0.10.2", - "rand_core 0.10.1", - "rustls", - "sha2 0.11.0", "thiserror 2.0.19", "tokio", + "tracing", + "wtransport", +] + +[[package]] +name = "mtp-transport" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +dependencies = [ + "async-trait", + "mtp-codec", + "mtp-common", + "mtp-crypto", + "rand 0.10.2", + "rcgen", + "rustls", + "rustls-native-certs", + "sha2 0.11.0", + "tokio", + "tracing", + "wtransport", "zeroize", ] -[[package]] -name = "mtp-host" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" -dependencies = [ - "mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "rand 0.10.2", - "tokio", - "tracing", - "wtransport", -] - -[[package]] -name = "mtp-transport" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" -dependencies = [ - "async-trait", - "mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "rcgen", - "rustls", - "rustls-native-certs", - "sha2 0.11.0", - "tokio", - "tracing", - "wtransport", -] - -[[package]] -name = "mtp-transport" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" -dependencies = [ - "async-trait", - "mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)", - "rcgen", - "rustls", - "rustls-native-certs", - "sha2 0.11.0", - "tokio", - "tracing", - "wtransport", -] - [[package]] name = "mtp-type-map" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" -dependencies = [ - "serde", - "serde_yaml", -] - -[[package]] -name = "mtp-type-map" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "serde", "serde_yaml", @@ -2972,8 +2864,8 @@ dependencies = [ [[package]] name = "mtp-webserver" -version = "0.2.0" -source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" +version = "0.3.0" +source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" dependencies = [ "async-trait", "bytes", @@ -2984,13 +2876,12 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", - "mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", + "mtp-codec", + "mtp-common", + "mtp-crypto", "mtp-host", - "mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", + "mtp-transport", "quinn", - "rand 0.10.2", "rustls", "thiserror 2.0.19", "tokio", @@ -3492,15 +3383,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -3586,24 +3468,14 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.1", - "spki 0.8.0", + "der", + "spki", ] [[package]] @@ -4592,15 +4464,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "signature" version = "3.0.0" @@ -4709,16 +4572,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "spki" version = "0.8.0" @@ -4726,7 +4579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.1", + "der", ] [[package]] @@ -5289,7 +5142,6 @@ dependencies = [ "base64 0.22.1", "jni 0.22.4", "mtp", - "mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)", "reqwest", "serde", "serde_json", diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index 08a66d5..2a68e95 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -24,8 +24,7 @@ serde_json = "1" base64 = "0.22" reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } -mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "7182272e3edb0079d7b74b45b2f62a298d2fee22", features = ["client", "crypto"] } -mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" } +mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] } webpki-root-certs = "1" tauri-plugin-deep-link = "2" tauri-plugin-notification = "2" @@ -36,6 +35,11 @@ version = "2" features = [] default-features = true +[target.'cfg(not(target_os = "android"))'.dependencies.tauri] +version = "2" +features = [] +default-features = true + [target.'cfg(target_os = "android")'.dependencies] jni = "0.22" diff --git a/apps/tauri/src-tauri/src/main.rs b/apps/tauri/src-tauri/src/main.rs index e78a26a..272583a 100644 --- a/apps/tauri/src-tauri/src/main.rs +++ b/apps/tauri/src-tauri/src/main.rs @@ -1,9 +1,6 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use lib::log; - fn main() { mobile_lib::run(); - log("Test test 123") } diff --git a/apps/tauri/src-tauri/src/mtp_backend.rs b/apps/tauri/src-tauri/src/mtp_backend.rs index b343337..14365b8 100644 --- a/apps/tauri/src-tauri/src/mtp_backend.rs +++ b/apps/tauri/src-tauri/src/mtp_backend.rs @@ -2,7 +2,7 @@ use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, OnceLock, RwLock, }; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::{ engine::general_purpose::{STANDARD, STANDARD_NO_PAD}, @@ -16,6 +16,7 @@ use mtp::crypto::{ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use tauri::{AppHandle, Emitter}; +use tokio::sync::mpsc; const EVENT_NAME: &str = "mtp://event"; const DISCONNECTED: u8 = 0; @@ -24,6 +25,9 @@ const CONNECTED: u8 = 2; const CHAT_SECRET_SALT: &[u8] = b"tensamin-chat-secret-v1"; const CHAT_MESSAGE_SALT: &[u8] = b"tensamin-chat-message-v1"; const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"; +const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_BUFFERED_INITIAL_FRAMES: usize = 1_000; +const NOTIFICATION_QUEUE_CAPACITY: usize = 32; #[cfg(target_os = "android")] const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\ MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\ @@ -95,7 +99,7 @@ enum MtpEvent { pub struct MtpManager { runtime: tokio::runtime::Runtime, config: RwLock>, - connection: RwLock>>, + connection: RwLock>>, snapshot: RwLock, generation: AtomicU64, enabled: AtomicBool, @@ -104,6 +108,46 @@ pub struct MtpManager { start_lock: Mutex<()>, } +struct RequestIdAllocator { + next: AtomicU64, +} + +impl RequestIdAllocator { + fn new() -> Self { + Self { + next: AtomicU64::new(1), + } + } + + fn next(&self) -> Result { + let value = self.next.fetch_add(1, Ordering::Relaxed); + u32::try_from(value) + .map_err(|_| "MTP request ID space exhausted for this connection".to_string()) + } +} + +struct ManagedConnection { + mtp: Arc, + request_ids: RequestIdAllocator, +} + +impl ManagedConnection { + async fn next_request_id(&self) -> Result { + let id = self.request_ids.next(); + if id.is_err() { + self.mtp.sender.close().await; + } + id + } +} + +struct PreparedConnection { + connection: MTPConnection, + request_ids: RequestIdAllocator, + initial_state: Value, + buffered_frames: Vec, +} + static MANAGER: OnceLock = OnceLock::new(); pub fn manager() -> &'static MtpManager { @@ -149,7 +193,7 @@ impl MtpManager { .take() { self.runtime - .spawn(async move { connection.sender.close().await }); + .spawn(async move { connection.mtp.sender.close().await }); } self.set_snapshot(MtpSnapshot { generation, @@ -171,7 +215,7 @@ impl MtpManager { .take() { self.runtime - .spawn(async move { connection.sender.close().await }); + .spawn(async move { connection.mtp.sender.close().await }); } self.set_snapshot(MtpSnapshot { generation: self.generation.load(Ordering::SeqCst), @@ -231,20 +275,16 @@ impl MtpManager { self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation } - async fn request( - &self, - type_name: &str, - data: Value, - id: Option, - ) -> Result { + async fn request(&self, type_name: &str, data: Value) -> Result { let connection = self .connection .read() .map_err(|_| "MTP connection lock is unavailable")? .clone() .ok_or_else(|| "MTP is not connected".to_string())?; - let request = json_to_frame(type_name, data, id)?; + let request = json_to_frame(type_name, data, connection.next_request_id().await?)?; let response = connection + .mtp .request(&request, None) .await .map_err(|error| error.to_string())?; @@ -261,9 +301,12 @@ async fn supervise(generation: u64) { manager.log(2, "Starting native MTP connection", None); android_status("Connecting"); match connect(&config).await { - Ok((connection, state)) => { + Ok(prepared) => { delay = Duration::from_secs(1); - let connection = Arc::new(connection); + let connection = Arc::new(ManagedConnection { + mtp: Arc::new(prepared.connection), + request_ids: prepared.request_ids, + }); let stale = { let _guard = manager.start_lock.lock().expect("start lock poisoned"); let mut current = manager @@ -279,22 +322,47 @@ async fn supervise(generation: u64) { generation, ready_state: CONNECTED, identified: true, - state: Some(state), + state: Some(prepared.initial_state), error: None, }); false } }; if stale { - connection.sender.close().await; + connection.mtp.sender.close().await; break; } android_status("Connected"); manager.log(2, "Native MTP connection established", None); + let (notification_tx, mut notification_rx) = + mpsc::channel(NOTIFICATION_QUEUE_CAPACITY); + let notification_connection = connection.clone(); + let notification_config = config.clone(); + let notification_worker = tokio::spawn(async move { + while let Some(frame) = notification_rx.recv().await { + if !manager().is_current(generation) { + break; + } + if let Err(error) = notify_message( + ¬ification_config, + notification_connection.clone(), + &frame, + ) + .await + { + eprintln!("failed to create background message notification: {error}"); + } + } + }); + + for frame in prepared.buffered_frames { + handle_push(generation, ¬ification_tx, frame).await; + } + while manager.is_current(generation) { - match connection.receive().await { - Ok(frame) => handle_push(generation, connection.clone(), frame).await, + match connection.mtp.receive().await { + Ok(frame) => handle_push(generation, ¬ification_tx, frame).await, Err(error) => { let _guard = manager.start_lock.lock().expect("start lock poisoned"); if manager.is_current(generation) { @@ -310,6 +378,13 @@ async fn supervise(generation: u64) { } } } + drop(notification_tx); + notification_worker.abort(); + if let Err(error) = notification_worker.await { + if !error.is_cancelled() { + eprintln!("background notification worker failed: {error}"); + } + } let mut current = manager .connection .write() @@ -339,12 +414,21 @@ async fn supervise(generation: u64) { break; } android_status("Reconnecting"); - tokio::time::sleep(delay).await; + tokio::time::sleep(jittered_retry_delay(delay)).await; delay = (delay * 2).min(Duration::from_secs(60)); } } -async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> { +fn jittered_retry_delay(delay: Duration) -> Duration { + let entropy = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.subsec_nanos()) + .unwrap_or_default(); + let percent = 80 + entropy % 41; + delay.mul_f64(percent as f64 / 100.0) +} + +async fn connect(config: &MtpConfig) -> Result { let (url, public_key) = resolve_endpoint(config) .await .map_err(|error| format!("endpoint discovery failed: {error}"))?; @@ -381,39 +465,10 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> { .map_err(|error| format!("transport authentication failed: {error}"))?; manager().log(2, "Native MTP authentication completed", None); - let connected = CommunicationValue::new(CommunicationType::ClientConnected) - .add_typed_default( - DataType::SessionId, - DataValue::UnsignedNumber(current_millis() as u128), - ) - .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0)) - .add_typed_default(DataType::CacheValid, DataValue::BoolFalse) - .add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0)); - let state = tokio::time::timeout( - Duration::from_secs(30), - connection.request(&connected, None), - ) - .await - .map_err(|_| "initial state synchronization timed out".to_string())? - .map_err(|error| format!("initial state synchronization failed: {error}"))?; - if !state.is_type(CommunicationType::ClientStateSync) { - return Err(format!( - "expected ClientStateSync, received {}", - state.get_type_name().unwrap_or("unknown") - )); - } - let session_id = state - .get_data(DataType::SessionId) - .as_number() - .ok_or("ClientStateSync omitted SessionId")?; - let version = state - .get_data(DataType::VersionNumber) - .as_number() - .ok_or("ClientStateSync omitted VersionNumber")?; - let ack = CommunicationValue::new(CommunicationType::ClientStateAck) - .add_typed_default(DataType::SessionId, number_to_data(session_id)) - .add_typed_default(DataType::VersionNumber, number_to_data(version)); - let response = tokio::time::timeout(Duration::from_secs(30), connection.request(&ack, None)) + let (state, buffered_frames) = await_initial_state(&connection).await?; + let request_ids = RequestIdAllocator::new(); + let (initial_state, ack) = prepare_initial_state_ack(&state, &request_ids)?; + let response = tokio::time::timeout(INITIAL_SYNC_TIMEOUT, connection.request(&ack, None)) .await .map_err(|_| "state acknowledgement timed out".to_string())? .map_err(|error| format!("state acknowledgement failed: {error}"))?; @@ -423,7 +478,182 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> { { return Err(format!("ClientStateAck failed: {response}")); } - Ok((connection, frame_data_to_json(&state)?)) + Ok(PreparedConnection { + connection, + request_ids, + initial_state, + buffered_frames, + }) +} + +fn prepare_initial_state_ack( + state: &CommunicationValue, + request_ids: &RequestIdAllocator, +) -> Result<(Value, CommunicationValue), String> { + let initial_state = frame_data_to_json(state)?; + validate_client_state_sync(&initial_state)?; + let data = initial_state + .as_object() + .ok_or("ClientStateSync payload is not an object")?; + let session_id = required_integer(data, "SessionId")?; + let version = required_integer(data, "VersionNumber")?; + let ack = CommunicationValue::new(CommunicationType::ClientStateAck) + .with_id(request_ids.next()?) + .add_typed_default(DataType::SessionId, number_to_data(session_id)) + .add_typed_default(DataType::VersionNumber, number_to_data(version)); + Ok((initial_state, ack)) +} + +fn validate_client_state_sync(state: &Value) -> Result<(), String> { + let data = state + .as_object() + .ok_or("ClientStateSync payload is not an object")?; + if required_integer(data, "SessionId")? <= 0 { + return Err("ClientStateSync SessionId must be a positive integer".into()); + } + for field in ["VersionNumber", "CacheSchemaVersion"] { + if required_integer(data, field)? < 0 { + return Err(format!("ClientStateSync {field} must be nonnegative")); + } + } + match data.get("SyncMode").and_then(Value::as_str) { + Some("full" | "delta") => {} + _ => return Err("ClientStateSync SyncMode must be 'full' or 'delta'".into()), + } + for field in ["Contacts", "Communities", "Calls", "Messages"] { + if !data.get(field).is_some_and(Value::is_array) { + return Err(format!("ClientStateSync {field} must be an array")); + } + } + for field in ["DeletedMessageIds", "DeletedContactIds"] { + if let Some(value) = data.get(field) { + let values = value + .as_array() + .ok_or_else(|| format!("ClientStateSync {field} must be an array"))?; + if values.iter().any(|value| !value.is_number()) { + return Err(format!("ClientStateSync {field} must contain numbers")); + } + } + } + validate_object_array(data, "Communities", |_| Ok(()))?; + validate_object_array(data, "Contacts", validate_contact)?; + validate_object_array(data, "Calls", validate_call)?; + validate_object_array(data, "Messages", validate_message)?; + Ok(()) +} + +fn validate_object_array( + data: &Map, + field: &str, + validate: impl Fn(&Map) -> Result<(), String>, +) -> Result<(), String> { + let values = data + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| format!("ClientStateSync {field} must be an array"))?; + for value in values { + let object = value + .as_object() + .ok_or_else(|| format!("ClientStateSync {field} entries must be objects"))?; + validate(object)?; + } + Ok(()) +} + +fn validate_contact(contact: &Map) -> Result<(), String> { + if !contact.get("UserId").is_some_and(Value::is_number) { + return Err("ClientStateSync contact omitted numeric UserId".into()); + } + if let Some(messages) = contact.get("Messages") { + let messages = messages + .as_array() + .ok_or("ClientStateSync contact Messages must be an array")?; + for message in messages { + validate_message( + message + .as_object() + .ok_or("ClientStateSync contact message must be an object")?, + )?; + } + } + Ok(()) +} + +fn validate_call(call: &Map) -> Result<(), String> { + if !call.get("CallId").is_some_and(Value::is_string) { + return Err("ClientStateSync call omitted string CallId".into()); + } + let members = call + .get("CallMembers") + .and_then(Value::as_array) + .ok_or("ClientStateSync call omitted CallMembers array")?; + if members.iter().any(|member| !member.is_number()) { + return Err("ClientStateSync CallMembers must contain numbers".into()); + } + Ok(()) +} + +fn validate_message(message: &Map) -> Result<(), String> { + for field in ["SenderId", "SendTime"] { + if !message.get(field).is_some_and(Value::is_number) { + return Err(format!("ClientStateSync message omitted numeric {field}")); + } + } + let content = message + .get("Content") + .and_then(Value::as_str) + .ok_or("ClientStateSync message omitted string Content")?; + STANDARD + .decode(content) + .or_else(|_| STANDARD_NO_PAD.decode(content)) + .map_err(|_| "ClientStateSync message Content must be base64".to_string())?; + if let Some(state) = message.get("MessageState") { + match state.as_str() { + Some("read" | "received" | "sent" | "sending" | "awaiting") => {} + _ => return Err("ClientStateSync message has invalid MessageState".into()), + } + } + Ok(()) +} + +fn required_integer(data: &Map, field: &str) -> Result { + let value = data + .get(field) + .ok_or_else(|| format!("ClientStateSync omitted {field}"))?; + if let Some(value) = value.as_i64() { + return Ok(value as i128); + } + value + .as_u64() + .map(|value| value as i128) + .ok_or_else(|| format!("ClientStateSync {field} must be an integer")) +} + +async fn await_initial_state( + connection: &MTPConnection, +) -> Result<(CommunicationValue, Vec), String> { + let mut buffered = Vec::new(); + let deadline = tokio::time::Instant::now() + INITIAL_SYNC_TIMEOUT; + + loop { + let frame = tokio::time::timeout_at(deadline, connection.receive()) + .await + .map_err(|_| "initial state synchronization timed out".to_string())? + .map_err(|error| format!("initial state synchronization failed: {error}"))?; + + if frame.is_type(CommunicationType::ErrorNoIota) { + return Err("No Iota is currently connected".into()); + } + + if frame.is_type(CommunicationType::ClientStateSync) { + return Ok((frame, buffered)); + } + + if buffered.len() == MAX_BUFFERED_INITIAL_FRAMES { + return Err("initial state synchronization buffered too many frames".into()); + } + buffered.push(frame); + } } async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> { @@ -465,7 +695,11 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String )) } -async fn handle_push(generation: u64, connection: Arc, frame: CommunicationValue) { +async fn handle_push( + generation: u64, + notification_tx: &mpsc::Sender, + frame: CommunicationValue, +) { let manager = manager(); if let Ok(message) = frame_to_json(&frame) { manager.emit(MtpEvent::Message { @@ -478,7 +712,7 @@ async fn handle_push(generation: u64, connection: Arc, frame: Com { if let Some(partner_id) = frame .get_data(DataType::ChatPartnerId) - .as_number() + .and_then(DataValue::as_number) .and_then(|value| u64::try_from(value).ok()) { if let Err(error) = android_cancel_notification(partner_id) { @@ -487,40 +721,39 @@ async fn handle_push(generation: u64, connection: Arc, frame: Com } } if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) { - if let Err(error) = notify_message(connection, &frame).await { - eprintln!("failed to create background message notification: {error}"); + if notification_tx.try_send(frame).is_err() { + eprintln!("background message notification queue is full"); } } } async fn notify_message( - connection: Arc, + config: &MtpConfig, + connection: Arc, frame: &CommunicationValue, ) -> Result<(), String> { let sender_id = frame .get_data(DataType::SenderId) - .as_number() + .and_then(DataValue::as_number) .and_then(|value| u64::try_from(value).ok()) .ok_or("MessageLive omitted SenderId")?; - let message = frame.get_data(DataType::Message); - let content = container_value(message, DataType::Content) + let message = frame + .get_data(DataType::Message) + .ok_or("MessageLive omitted Message")?; + let content = container_value(message, DataType::AppContent) .and_then(DataValue::as_str) .ok_or("MessageLive omitted Content")?; - let config = manager() - .config - .read() - .expect("config lock poisoned") - .clone() - .ok_or("missing config")?; let keyring_bytes = decode_browser_base64(&config.keyring)?; let keyring = Keyring::from_bytes(&keyring_bytes).map_err(|error| error.to_string())?; let chat_id = derive_chat_id(config.user_id, sender_id); let secret_id = format!("chat:{chat_id}:main"); let secret_request = CommunicationValue::new(CommunicationType::GetChatSecret) + .with_id(connection.next_request_id().await?) .add_typed_default(DataType::UserId, DataValue::Str(config.user_id.to_string())) .add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone())) .add_typed_default(DataType::SecretId, DataValue::Str(secret_id.clone())); let secret = connection + .mtp .request(&secret_request, None) .await .map_err(|error| error.to_string())?; @@ -532,7 +765,7 @@ async fn notify_message( } let version = secret .get_data(DataType::VersionNumber) - .as_number() + .and_then(DataValue::as_number) .ok_or("missing secret version")?; let encrypted_secret = secret .get_bytes(DataType::EncryptedSecret) @@ -560,11 +793,14 @@ async fn notify_message( .decrypt(&ciphertext, b"") .map_err(|error| error.to_string())?; - let user_request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( - DataType::UserId, - DataValue::UnsignedNumber(sender_id as u128), - ); + let user_request = CommunicationValue::new(CommunicationType::GetUserData) + .with_id(connection.next_request_id().await?) + .add_typed_default( + DataType::UserId, + DataValue::UnsignedNumber(sender_id as u128), + ); let user = connection + .mtp .request(&user_request, None) .await .map_err(|error| error.to_string())?; @@ -652,24 +888,42 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> { value.get_field(id) } -fn json_to_frame( - type_name: &str, - data: Value, - id: Option, -) -> Result { +fn wire_field_name(name: &str) -> &str { + match name { + "Content" => "AppContent", + "CreatedAt" => "AppCreatedAt", + "MessageId" => "AppMessageId", + _ => name, + } +} + +fn application_field_name(name: &str) -> &str { + match name { + "AppContent" => "Content", + "AppCreatedAt" => "CreatedAt", + "AppMessageId" => "MessageId", + _ => name, + } +} + +fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result { let comm_type = CommunicationType::from_name(type_name) .ok_or_else(|| format!("unknown communication type: {type_name}"))?; - let mut frame = CommunicationValue::new(comm_type); - if let Some(id) = id { - frame = frame.with_id(id); - } + let mut frame = CommunicationValue::new(comm_type).with_id(id); let Value::Object(fields) = data else { return Err("MTP request data must be an object".into()); }; + let mut translated_fields = std::collections::HashSet::::with_capacity(fields.len()); for (name, value) in fields { + let wire_name = wire_field_name(&name).to_owned(); + if !translated_fields.insert(wire_name.clone()) { + return Err(format!( + "duplicate MTP field after translation: {wire_name}" + )); + } let data_type = - DataType::from_name(&name).ok_or_else(|| format!("unknown data type: {name}"))?; - frame = frame.add_typed_default(data_type, json_to_data(&name, value)?); + DataType::from_name(&wire_name).ok_or_else(|| format!("unknown data type: {name}"))?; + frame = frame.add_typed_default(data_type, json_to_data(&wire_name, value)?); } Ok(frame) } @@ -707,13 +961,21 @@ fn json_to_data(field: &str, value: Value) -> Result { ), Value::Object(fields) => { let mut entries = Vec::with_capacity(fields.len()); + let mut translated_fields = + std::collections::HashSet::::with_capacity(fields.len()); for (name, value) in fields { - let data_type = DataType::from_name(&name) + let wire_name = wire_field_name(&name).to_owned(); + if !translated_fields.insert(wire_name.clone()) { + return Err(format!( + "duplicate MTP field after translation: {wire_name}" + )); + } + let data_type = DataType::from_name(&wire_name) .ok_or_else(|| format!("unknown nested data type: {name}"))?; let id = data_type .try_to_id(&TypeMap::latest()) .ok_or_else(|| format!("unmapped data type: {name}"))?; - entries.push((id, json_to_data(&name, value)?)); + entries.push((id, json_to_data(&wire_name, value)?)); } DataValue::Container(entries) } @@ -737,8 +999,8 @@ fn is_bytes_field(field: &str) -> bool { fn frame_to_json(frame: &CommunicationValue) -> Result { let mut result = Map::new(); - if frame.get_id() != 0 { - result.insert("id".into(), Value::from(frame.get_id())); + if let Some(id) = frame.id() { + result.insert("id".into(), Value::from(id)); } result.insert( "type".into(), @@ -751,11 +1013,20 @@ fn frame_to_json(frame: &CommunicationValue) -> Result { fn frame_data_to_json(frame: &CommunicationValue) -> Result { let map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); let mut result = Map::new(); - for (id, value) in frame.data() { + let entries = frame + .data() + .ok_or("MTP frame payload is not a data container")?; + for (id, value) in entries { let name = map .data_type_name(id.0) .ok_or_else(|| format!("unknown data type id: {}", id.0))?; - result.insert(name.to_owned(), data_to_json(value, &map)?); + let application_name = application_field_name(name); + if result.contains_key(application_name) { + return Err(format!( + "duplicate MTP field after translation: {application_name}" + )); + } + result.insert(application_name.to_owned(), data_to_json(value, &map)?); } Ok(Value::Object(result)) } @@ -786,7 +1057,13 @@ fn data_to_json(value: &DataValue, map: &TypeMap) -> Result { let name = map .data_type_name(id.0) .ok_or_else(|| format!("unknown nested data type id: {}", id.0))?; - object.insert(name.to_owned(), data_to_json(value, map)?); + let application_name = application_field_name(name); + if object.contains_key(application_name) { + return Err(format!( + "duplicate MTP field after translation: {application_name}" + )); + } + object.insert(application_name.to_owned(), data_to_json(value, map)?); } Value::Object(object) } @@ -801,16 +1078,15 @@ fn number_to_json(value: i128) -> Result { .map_err(|_| "number exceeds JSON range".into()) } -fn current_millis() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - #[cfg(test)] mod tests { - use super::{decode_browser_base64, decode_sdk_bytes}; + use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; + use serde_json::json; + + use super::{ + container_value, decode_browser_base64, decode_sdk_bytes, frame_to_json, + jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator, + }; #[test] fn browser_base64_accepts_file_whitespace_and_missing_padding() { @@ -823,11 +1099,148 @@ mod tests { assert_eq!(decode_sdk_bytes("0x01:02-ff").unwrap(), [1, 2, 255]); assert_eq!(decode_sdk_bytes("AQI=").unwrap(), [1, 2]); } + + #[test] + fn request_ids_are_nonzero_and_monotonic() { + let ids = RequestIdAllocator::new(); + + assert_eq!(ids.next().unwrap(), 1); + assert_eq!(ids.next().unwrap(), 2); + } + + #[test] + fn retry_jitter_stays_within_policy_bounds() { + let delay = jittered_retry_delay(std::time::Duration::from_secs(10)); + + assert!(delay >= std::time::Duration::from_secs(8)); + assert!(delay <= std::time::Duration::from_secs(12)); + } + + #[test] + fn json_content_uses_app_content_wire_type() { + let frame = json_to_frame( + "MessageEdit", + json!({ + "Content": "ciphertext", + "ChatPartnerId": 42, + "SendTime": 10, + }), + 1, + ) + .unwrap(); + + assert_eq!( + frame + .get_data(DataType::AppContent) + .and_then(DataValue::as_str), + Some("ciphertext") + ); + } + + #[test] + fn nested_json_content_uses_app_content_wire_type() { + let frame = json_to_frame( + "MessageEdit", + json!({ + "Message": { "Content": "ciphertext" }, + }), + 1, + ) + .unwrap(); + let message = frame.get_data(DataType::Message).unwrap(); + + assert_eq!( + container_value(message, DataType::AppContent).and_then(DataValue::as_str), + Some("ciphertext") + ); + } + + #[test] + fn app_content_is_exposed_as_content_to_frontend() { + let frame = CommunicationValue::new(CommunicationType::MessageEditLive) + .with_id(1) + .add_typed_default(DataType::AppContent, DataValue::Str("ciphertext".into())); + + let json = frame_to_json(&frame).unwrap(); + + assert_eq!(json["data"]["Content"], "ciphertext"); + assert!(json["data"].get("AppContent").is_none()); + } + + #[test] + fn translated_field_collisions_are_rejected() { + assert!(json_to_frame( + "MessageEdit", + json!({ "Content": "a", "AppContent": "b" }), + 1, + ) + .is_err()); + } + + fn valid_initial_state() -> CommunicationValue { + CommunicationValue::new(CommunicationType::ClientStateSync) + .add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1)) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0)) + .add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0)) + .add_typed_default(DataType::SyncMode, DataValue::Str("full".into())) + .add_typed_default(DataType::Contacts, DataValue::Array(vec![])) + .add_typed_default(DataType::Communities, DataValue::Array(vec![])) + .add_typed_default(DataType::Calls, DataValue::Array(vec![])) + .add_typed_default(DataType::Messages, DataValue::Array(vec![])) + } + + #[test] + fn valid_initial_state_is_prepared_before_ack() { + let ids = RequestIdAllocator::new(); + let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap(); + + assert_eq!(state["SyncMode"], "full"); + assert!(ack.is_type(CommunicationType::ClientStateAck)); + assert_eq!(ack.id(), Some(1)); + } + + #[test] + fn malformed_initial_state_does_not_prepare_ack() { + let ids = RequestIdAllocator::new(); + let malformed = valid_initial_state() + .add_typed_default(DataType::SyncMode, DataValue::Str("invalid".into())); + + assert!(prepare_initial_state_ack(&malformed, &ids).is_err()); + assert_eq!( + ids.next().unwrap(), + 1, + "no acknowledgement ID was allocated" + ); + } + + #[test] + fn malformed_nested_initial_state_does_not_prepare_ack() { + let ids = RequestIdAllocator::new(); + let malformed = CommunicationValue::new(CommunicationType::ClientStateSync) + .add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1)) + .add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0)) + .add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0)) + .add_typed_default(DataType::SyncMode, DataValue::Str("full".into())) + .add_typed_default( + DataType::Contacts, + DataValue::Array(vec![DataValue::Container(vec![])]), + ) + .add_typed_default(DataType::Communities, DataValue::Array(vec![])) + .add_typed_default(DataType::Calls, DataValue::Array(vec![])) + .add_typed_default(DataType::Messages, DataValue::Array(vec![])); + + assert!(prepare_initial_state_ack(&malformed, &ids).is_err()); + assert_eq!( + ids.next().unwrap(), + 1, + "no acknowledgement ID was allocated" + ); + } } #[tauri::command] -pub async fn mtp_request(type_name: String, data: Value, id: Option) -> Result { - manager().request(&type_name, data, id).await +pub async fn mtp_request(type_name: String, data: Value) -> Result { + manager().request(&type_name, data).await } #[tauri::command] diff --git a/apps/web/package.json b/apps/web/package.json index f608fcb..a6b3951 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "esbuild": "^0.28.1", "eslint": "^10.8.0", "globals": "^17.9.0", + "mtp": "*", "typescript": "~6.0.3", "typescript-eslint": "^8.66.0", "vite": "^8.2.1" diff --git a/mtp-type-maps b/mtp-type-maps index a297dcc..a70085b 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit a297dcce60bc6e84696c6a16f5fd510beb2ca643 +Subproject commit a70085b0c41844745449c9913a361f5bb9bee529 diff --git a/package.json b/package.json index 05856f5..1975028 100644 --- a/package.json +++ b/package.json @@ -25,26 +25,27 @@ "start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile", "dev:desktop": "cd apps/electron && pnpm run dev", "build:desktop": "cd apps/electron && pnpm run package", - "delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts" + "delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts", + "update-submodules": "git submodule update --remote --force --recursive" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^26.1.2", + "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/parser": "^8.66.0", - "eslint": "^10.8.0", + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^10.8.1", "eslint-plugin-react-hooks": "^7.1.1", - "fallow": "^3.14.0", - "globals": "^17.9.0", + "fallow": "^3.17.0", + "globals": "^17.11.0", "prettier": "^3.9.6", "typescript": "^6.0.3", - "typescript-eslint": "^8.66.0", - "vitest": "^4.1.10" + "typescript-eslint": "^8.67.0", + "vitest": "^4.1.11" }, "dependencies": { - "@methanium/ui": "*", - "mtp": "*", - "sonner": "^2.0.7" + "@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz", + "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz", + "sonner": "^2.0.8" } } diff --git a/packages/call/package.json b/packages/call/package.json index d4f7c10..7171f50 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -28,6 +28,7 @@ "deepfilternet3-noise-filter": "1.3.0", "livekit-client": "^2.21.0", "lucide-react": "^1.29.0", + "mtp": "*", "react": "^19.2.8", "react-dom": "^19.2.8", "recharts": "^3.10.1", diff --git a/packages/call/src/mediaShare/index.ts b/packages/call/src/mediaShare/index.ts index 6d2c47f..9706d4d 100644 --- a/packages/call/src/mediaShare/index.ts +++ b/packages/call/src/mediaShare/index.ts @@ -19,7 +19,6 @@ export function getMediaShareAdapter(): MediaShareAdapter { export type { MediaShareAdapter, - MediaShareCapabilities, MediaShareKind, MediaShareRequest, MediaShareSession, diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 62580df..2b40fdf 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -15,6 +15,7 @@ "build": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "mtp": "*", "react": "^19.2.8", "react-dom": "^19.2.8" } diff --git a/packages/crypto/src/callSecret.ts b/packages/crypto/src/callSecret.ts index 97ef318..556d131 100644 --- a/packages/crypto/src/callSecret.ts +++ b/packages/crypto/src/callSecret.ts @@ -13,11 +13,13 @@ export function deriveCallSecretId(callId: string): string { } export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { - return crypto.keyringToKeys(keyring).kemPublicKey; + return crypto.keyringToKeys({ value: keyring, encoding: "base64" }) + .kemPublicKey; } export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { - return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; + return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" }) + .kemPublicKey; } export async function wrapCallSecret(args: { @@ -72,7 +74,10 @@ export async function unwrapCallSecret(args: { ); } - const ownKeys = crypto.keyringToKeys(args.keyring); + const ownKeys = crypto.keyringToKeys({ + value: args.keyring, + encoding: "base64", + }); const sharedSecret = crypto.decapsulate( ownKeys.kemSecretKey, args.kemCiphertext, diff --git a/packages/crypto/src/chatSecret.ts b/packages/crypto/src/chatSecret.ts index e6e836c..cbac59a 100644 --- a/packages/crypto/src/chatSecret.ts +++ b/packages/crypto/src/chatSecret.ts @@ -23,11 +23,13 @@ export function randomChatSecret(): Uint8Array { } export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { - return crypto.keyringToKeys(keyring).kemPublicKey; + return crypto.keyringToKeys({ value: keyring, encoding: "base64" }) + .kemPublicKey; } export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { - return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; + return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" }) + .kemPublicKey; } export async function wrapChatSecret(args: { @@ -79,7 +81,10 @@ export async function unwrapChatSecret(args: { ); } - const ownKeys = crypto.keyringToKeys(args.keyring); + const ownKeys = crypto.keyringToKeys({ + value: args.keyring, + encoding: "base64", + }); const sharedSecret = crypto.decapsulate( ownKeys.kemSecretKey, args.kemCiphertext, diff --git a/packages/markdown/package.json b/packages/markdown/package.json new file mode 100644 index 0000000..247d715 --- /dev/null +++ b/packages/markdown/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tensamin/markdown", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + "./text": "./src/text.tsx", + "./input": "./src/input.tsx", + "./emoji": "./src/emoji.tsx" + }, + "scripts": { + "format": "pnpm exec prettier --write .", + "lint": "eslint src", + "build": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.8", + "@methanium/ui": "*", + "@twemoji/api": "^17.0.3", + "emojibase-data": "^17.0.0", + "lucide-react": "^1.30.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + } +} diff --git a/packages/markdown/src/emoji.tsx b/packages/markdown/src/emoji.tsx new file mode 100644 index 0000000..c0eb397 --- /dev/null +++ b/packages/markdown/src/emoji.tsx @@ -0,0 +1,50 @@ +import twemoji from "@twemoji/api"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui"; +import { resolveEmoji } from "./emojiData"; + +export { + emojis, + findEmojiShortcodes, + normalizeShortcode, + resolveEmoji, + searchEmojis, +} from "./emojiData"; +export type { EmojiDefinition } from "./emojiData"; + +export function getEmojiUrl(shortcode: string): string | undefined { + const emoji = resolveEmoji(shortcode); + return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined; +} + +export default function Emoji({ + className = "h-6 w-6", + shortcode, + tooltip = true, +}: { + className?: string; + shortcode: string; + tooltip?: boolean; +}) { + const emoji = resolveEmoji(shortcode); + if (!emoji) return {shortcode}; + + const image = ( + {emoji.shortcode} + ); + + if (!tooltip) return image; + + return ( + + + {emoji.shortcode} + + ); +} diff --git a/packages/markdown/src/emojiData.ts b/packages/markdown/src/emojiData.ts new file mode 100644 index 0000000..158b5b0 --- /dev/null +++ b/packages/markdown/src/emojiData.ts @@ -0,0 +1,94 @@ +import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json"; + +export type EmojiDefinition = { + aliases: readonly string[]; + hexcode: string; + name: string; + shortcode: string; +}; + +function normalizeName(value: string) { + return value + .trim() + .replace(/^:+|:+$/g, "") + .toLowerCase(); +} + +export const emojis: readonly EmojiDefinition[] = Object.entries( + shortcodeData as Record, +).map(([hexcode, value]) => { + const aliases = Array.isArray(value) ? value : [value]; + const name = aliases[0]; + + return { + aliases, + hexcode: hexcode.toLowerCase().replaceAll("_", "-"), + name, + shortcode: `:${name}:`, + }; +}); + +const emojiByName = new Map(); +for (const emoji of emojis) { + for (const alias of emoji.aliases) { + emojiByName.set(normalizeName(alias), emoji); + } +} + +export function resolveEmoji(value: string): EmojiDefinition | undefined { + return emojiByName.get(normalizeName(value)); +} + +export function normalizeShortcode(value: string): string | undefined { + return resolveEmoji(value)?.shortcode; +} + +export function findEmojiShortcodes(value: string) { + const matches: Array<{ + emoji: EmojiDefinition; + from: number; + to: number; + }> = []; + let searchFrom = 0; + + while (searchFrom < value.length) { + const from = value.indexOf(":", searchFrom); + if (from === -1) break; + + const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i); + if (!candidate) { + searchFrom = from + 1; + continue; + } + + const emoji = resolveEmoji(candidate[1]); + if (!emoji) { + // The closing colon may also open the next valid shortcode. + searchFrom = from + candidate[0].length - 1; + continue; + } + + const to = from + candidate[0].length; + matches.push({ emoji, from, to }); + searchFrom = to; + } + + return matches; +} + +export function searchEmojis(query: string): EmojiDefinition[] { + const normalizedQuery = normalizeName(query); + if (!normalizedQuery) return [...emojis]; + + return emojis + .map((emoji) => { + const names = emoji.aliases.map(normalizeName); + const exact = names.includes(normalizedQuery); + const prefix = names.some((name) => name.startsWith(normalizedQuery)); + const contains = names.some((name) => name.includes(normalizedQuery)); + return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 }; + }) + .filter(({ rank }) => rank < 3) + .sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name)) + .map(({ emoji }) => emoji); +} diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx new file mode 100644 index 0000000..e16c84d --- /dev/null +++ b/packages/markdown/src/input.tsx @@ -0,0 +1,850 @@ +import { markdown } from "@codemirror/lang-markdown"; +import { syntaxTree } from "@codemirror/language"; +import { + acceptCompletion, + autocompletion, + completionStatus, + pickedCompletion, + startCompletion, + type Completion, + type CompletionContext, + type CompletionResult, +} from "@codemirror/autocomplete"; +import { + EditorState, + EditorSelection, + Annotation, + Compartment, + Prec, + Transaction, + type Extension, + type Range, + type SelectionRange, +} from "@codemirror/state"; +import { + Decoration, + EditorView, + keymap, + placeholder, + ViewPlugin, + WidgetType, + type DecorationSet, + type KeyBinding, + type ViewUpdate, +} from "@codemirror/view"; +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands"; +import { useEffect, useRef } from "react"; +import type { CSSProperties } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { collectInlineRanges, ensureMarkdownStyles } from "./markdown"; +import Emoji, { + findEmojiShortcodes, + getEmojiUrl, + resolveEmoji, + searchEmojis, +} from "./emoji"; + +export const MAX_RENDERED_EMOJI_OPTIONS = 100; + +export type InputController = { + focus: () => void; + hasFocus: () => boolean; + insertText: (text: string) => void; +}; + +export type InputProps = { + ref?: HTMLDivElement; + placeholder?: string; + value: string; + setValue: (value: string) => void; + onSubmit?: () => void; + invertEnterBehavior?: boolean; + styled?: boolean; + fontSize?: CSSProperties["fontSize"]; + paddingX?: CSSProperties["padding"]; + paddingY?: CSSProperties["padding"]; + className?: string; + emojiFrequencies?: Readonly>; + onEmojiSelect?: (shortcode: string) => void; + autoFocus?: boolean; + onControllerChange?: (controller: InputController | null) => void; +}; + +function toCssLength(value: CSSProperties["padding"]): string | undefined { + if (value === undefined) { + return undefined; + } + + return typeof value === "number" ? `${value}px` : value; +} + +function toCssPadding( + vertical: CSSProperties["padding"], + horizontal: CSSProperties["padding"], + styled: boolean, +): string { + const defaultVertical = styled ? "0.25rem" : "0"; + const defaultHorizontal = styled ? "0.625rem" : "0"; + + return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`; +} + +const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" }); +const strongDecoration = Decoration.mark({ class: "tm-md-strong" }); +const emDecoration = Decoration.mark({ class: "tm-md-em" }); +const delDecoration = Decoration.mark({ class: "tm-md-del" }); +const codeDecoration = Decoration.mark({ class: "tm-md-code" }); +const linkDecoration = Decoration.mark({ class: "tm-md-link" }); +const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" }); +const externalValueSync = Annotation.define(); +const widgetRoots = new WeakMap(); + +type EmojiRange = { + from: number; + shortcode: string; + to: number; + url: string; +}; + +class EmojiWidget extends WidgetType { + readonly shortcode: string; + readonly url: string; + + constructor(shortcode: string, url: string) { + super(); + this.shortcode = shortcode; + this.url = url; + } + + eq(other: EmojiWidget) { + return other.shortcode === this.shortcode && other.url === this.url; + } + + toDOM() { + const container = document.createElement("span"); + const root = createRoot(container); + root.render( + , + ); + widgetRoots.set(container, root); + return container; + } + + destroy(dom: HTMLElement) { + widgetRoots.get(dom)?.unmount(); + widgetRoots.delete(dom); + } + + ignoreEvent() { + return true; + } +} + +function codeRanges(state: EditorState) { + const ranges: Array<{ from: number; to: number }> = []; + + syntaxTree(state).iterate({ + enter(node) { + if ( + node.name === "InlineCode" || + node.name === "FencedCode" || + node.name === "CodeBlock" + ) { + ranges.push({ from: node.from, to: node.to }); + return false; + } + }, + }); + + return ranges; +} + +export function findEmojiRanges(state: EditorState): EmojiRange[] { + const document = state.doc.toString(); + const excluded = codeRanges(state); + const ranges: EmojiRange[] = []; + + for (const match of findEmojiShortcodes(document)) { + const { from, to } = match; + const inCode = excluded.some((range) => from < range.to && to > range.from); + const emoji = inCode ? undefined : match.emoji; + const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined; + + if (emoji && url) { + ranges.push({ from, shortcode: emoji.shortcode, to, url }); + } + } + + return ranges; +} + +class EmojiPluginValue { + decorations: DecorationSet; + ranges: EmojiRange[]; + + constructor(view: EditorView) { + this.ranges = findEmojiRanges(view.state); + this.decorations = this.buildDecorations(); + } + + update(update: ViewUpdate) { + if ( + update.docChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.ranges = findEmojiRanges(update.state); + this.decorations = this.buildDecorations(); + } + } + + private buildDecorations() { + return Decoration.set( + this.ranges.map((range) => + Decoration.replace({ + inclusive: false, + widget: new EmojiWidget(range.shortcode, range.url), + }).range(range.from, range.to), + ), + true, + ); + } +} + +const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, { + decorations: (instance) => instance.decorations, + provide: (plugin) => + EditorView.atomicRanges.of( + (view) => view.plugin(plugin)?.decorations ?? Decoration.none, + ), +}); + +function deleteEmoji(view: EditorView, direction: "backward" | "forward") { + const ranges = view.plugin(emojiDecorations)?.ranges ?? []; + const deletions: Array<{ from: number; to: number }> = []; + + for (const selection of view.state.selection.ranges) { + if (selection.empty) { + const emoji = ranges.find((range) => + direction === "backward" + ? selection.from > range.from && selection.from <= range.to + : selection.from >= range.from && selection.from < range.to, + ); + if (emoji) deletions.push({ from: emoji.from, to: emoji.to }); + continue; + } + + let from = selection.from; + let to = selection.to; + let changed = false; + + for (const emoji of ranges) { + if (from < emoji.to && to > emoji.from) { + from = Math.min(from, emoji.from); + to = Math.max(to, emoji.to); + changed = true; + } + } + + if (changed) deletions.push({ from, to }); + } + + if (deletions.length === 0) return false; + + const merged = deletions + .sort((a, b) => a.from - b.from) + .reduce>((result, deletion) => { + const previous = result.at(-1); + if (previous && deletion.from <= previous.to) { + previous.to = Math.max(previous.to, deletion.to); + } else { + result.push({ ...deletion }); + } + return result; + }, []); + + view.dispatch({ + changes: merged.map((range) => ({ from: range.from, to: range.to })), + selection: EditorSelection.cursor(merged[0].from), + scrollIntoView: true, + userEvent: direction === "backward" ? "delete.backward" : "delete.forward", + }); + return true; +} + +/** + * Builds markdown styling decorations every time the document or cursor selection changes. + * Token delimiters are hidden unless the cursor is currently intersecting that token range. + */ +const markdownDecorations = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = buildDecorations(update.view); + } + } + }, + { + decorations: (instance: { decorations: DecorationSet }) => + instance.decorations, + }, +); + +/** + * Executes Input. + * @param props Parameter props. + * @returns unknown. + */ +export default function Input(props: InputProps) { + ensureMarkdownStyles(); + + const shellClassName = props.styled + ? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40" + : ""; + + const elementRef = useRef(null); + const viewRef = useRef(undefined); + const setValueRef = useRef(props.setValue); + const onSubmitRef = useRef(props.onSubmit); + const onEmojiSelectRef = useRef( + props.onEmojiSelect, + ); + const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior)); + const completionCompartmentRef = useRef(null); + completionCompartmentRef.current ??= new Compartment(); + const completionCompartment = completionCompartmentRef.current; + + useEffect(() => { + setValueRef.current = props.setValue; + onSubmitRef.current = props.onSubmit; + onEmojiSelectRef.current = props.onEmojiSelect; + invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior); + }, [ + props.onEmojiSelect, + props.onSubmit, + props.invertEnterBehavior, + props.setValue, + ]); + + useEffect(() => { + if (!elementRef.current) return; + + const state = EditorState.create({ + doc: props.value, + extensions: createEditorExtensions( + (value) => { + setValueRef.current(value); + }, + () => props.placeholder, + () => invertEnterBehaviorRef.current, + () => onSubmitRef.current?.(), + completionCompartment, + props.emojiFrequencies, + (shortcode) => onEmojiSelectRef.current?.(shortcode), + ), + }); + + viewRef.current = new EditorView({ + state, + parent: elementRef.current, + }); + props.onControllerChange?.({ + focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }), + hasFocus: () => viewRef.current?.hasFocus ?? false, + insertText: (text) => { + const editor = viewRef.current; + if (!editor) return; + editor.dispatch({ + ...editor.state.replaceSelection(text), + annotations: Transaction.userEvent.of("input.type"), + scrollIntoView: true, + }); + }, + }); + if (props.autoFocus) viewRef.current.focus(); + + return () => { + props.onControllerChange?.(null); + viewRef.current?.destroy(); + viewRef.current = undefined; + }; + // Run once to initialize/destroy the editor instance. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const editor = viewRef.current; + if (!editor) return; + + const next = props.value; + const current = editor.state.doc.toString(); + + if (next === current) return; + + editor.dispatch({ + changes: { + from: 0, + to: current.length, + insert: next, + }, + annotations: [ + externalValueSync.of(true), + Transaction.addToHistory.of(false), + ], + filter: false, + }); + }, [props.value]); + + useEffect(() => { + const editor = viewRef.current; + const compartment = completionCompartmentRef.current; + if (!editor || !compartment) return; + + const wasActive = completionStatus(editor.state) === "active"; + editor.dispatch({ + effects: compartment.reconfigure( + createEmojiAutocomplete(props.emojiFrequencies, (shortcode) => + onEmojiSelectRef.current?.(shortcode), + ), + ), + }); + if (wasActive) startCompletion(editor); + }, [props.emojiFrequencies]); + + return ( +
+ ); +} + +/** + * Executes createEditorExtensions. + * @param onChange Parameter onChange. + * @param getPlaceholder Parameter getPlaceholder. + * @param getInvertEnterBehavior Parameter getInvertEnterBehavior. + * @param onSubmit Parameter onSubmit. + * @returns Extension[]. + */ +function createEditorExtensions( + onChange: (value: string) => void, + getPlaceholder: () => string | undefined, + getInvertEnterBehavior: () => boolean, + onSubmit: () => void, + completionCompartment: Compartment, + emojiFrequencies: Readonly> | undefined, + onEmojiSelect: (shortcode: string) => void, +): Extension[] { + const editorKeymap = [ + ...defaultKeymap, + ...historyKeymap, + indentWithTab, + ] as unknown as readonly KeyBinding[]; + + const customEnterKeymap = keymap.of([ + { + key: "Shift-Enter", + run: (view) => { + if (completionStatus(view.state) === "active") { + return acceptCompletion(view); + } + if (!getInvertEnterBehavior()) { + return false; + } + + onSubmit(); + return true; + }, + }, + { + key: "Enter", + run: (view) => { + if (completionStatus(view.state) === "active") { + return acceptCompletion(view); + } + if (getInvertEnterBehavior()) { + return false; + } + + onSubmit(); + return true; + }, + }, + ]); + const completionTabKeymap = keymap.of([ + { + key: "Tab", + run: (view) => + completionStatus(view.state) === "active" + ? acceptCompletion(view) + : false, + }, + ]); + const emojiDeletionKeymap = keymap.of([ + { + key: "Backspace", + run: (view) => deleteEmoji(view, "backward"), + }, + { + key: "Delete", + run: (view) => deleteEmoji(view, "forward"), + }, + ]); + + return [ + history(), + markdown(), + completionCompartment.of( + createEmojiAutocomplete(emojiFrequencies, onEmojiSelect), + ), + emojiDecorations, + keymap.of(editorKeymap), + Prec.highest(completionTabKeymap), + Prec.highest(emojiDeletionKeymap), + Prec.highest(customEnterKeymap), + EditorView.lineWrapping, + placeholder(getPlaceholder() ?? ""), + EditorView.updateListener.of((update: ViewUpdate) => { + if (!update.docChanged) return; + if ( + update.transactions.some( + (transaction) => transaction.annotation(externalValueSync) === true, + ) + ) { + return; + } + onChange(update.state.doc.toString()); + }), + EditorView.theme({ + "&": { + fontSize: "inherit", + }, + "&.cm-editor": { + width: "100%", + }, + }), + EditorView.editorAttributes.of({ + class: "tm-md-editor", + spellcheck: "true", + "aria-label": "Markdown input", + }), + markdownDecorations, + ]; +} + +function createEmojiAutocomplete( + frequencies: Readonly> | undefined, + onEmojiSelect: (shortcode: string) => void, +) { + return autocompletion({ + activateOnTyping: true, + addToOptions: [ + { + position: 20, + render(completion) { + const container = document.createElement("span"); + createRoot(container).render( + , + ); + return container; + }, + }, + ], + maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS, + override: [createEmojiCompletionSource(frequencies, onEmojiSelect)], + }); +} + +function normalizedFrequencies( + frequencies: Readonly> | undefined, +) { + const normalized = new Map(); + for (const [value, frequency] of Object.entries(frequencies ?? {})) { + const shortcode = resolveEmoji(value)?.shortcode; + if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue; + normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency); + } + return normalized; +} + +export function createEmojiCompletionSource( + frequencies?: Readonly>, + onEmojiSelect: (shortcode: string) => void = () => undefined, +) { + const normalized = normalizedFrequencies(frequencies); + const maxFrequency = Math.max(0, ...normalized.values()); + + return (context: CompletionContext): CompletionResult | null => { + const token = context.matchBefore(/:[a-z0-9_+-]*$/i); + if (!token) return null; + if ( + codeRanges(context.state).some( + (range) => token.from < range.to && token.to > range.from, + ) + ) { + return null; + } + + const characterBefore = context.state.sliceDoc( + Math.max(0, token.from - 1), + token.from, + ); + if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null; + + const query = token.text.slice(1).toLowerCase(); + const options: Completion[] = searchEmojis(query) + .map((emoji) => { + const aliases = emoji.aliases.map((alias) => alias.toLowerCase()); + const matchedAlias = + aliases.find((alias) => alias === query) ?? + aliases.find((alias) => alias.startsWith(query)) ?? + aliases.find((alias) => alias.includes(query)) ?? + emoji.name; + const relevance = !query + ? 0 + : matchedAlias === query + ? 80 + : matchedAlias.startsWith(query) + ? 40 + : 0; + const frequency = normalized.get(emoji.shortcode) ?? 0; + const usage = + maxFrequency > 0 + ? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency) + : 0; + + return { + apply(view, completion, from, to) { + view.dispatch({ + annotations: pickedCompletion.of(completion), + changes: { from, insert: `${emoji.shortcode} `, to }, + selection: EditorSelection.cursor( + from + emoji.shortcode.length + 1, + ), + }); + onEmojiSelect(emoji.shortcode); + }, + boost: relevance + usage, + displayLabel: emoji.shortcode, + label: `:${matchedAlias}:`, + type: "text", + frequency, + relevance, + } satisfies Completion & { frequency: number; relevance: number }; + }) + .sort( + (a, b) => + b.relevance - a.relevance || + b.frequency - a.frequency || + (a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label), + ); + + return { + from: token.from, + options, + validFor: /^:[a-z0-9_+-]*$/i, + }; + }; +} + +export const emojiCompletionSource = createEmojiCompletionSource(); + +/** + * Executes buildDecorations. + * @param view Parameter view. + * @returns DecorationSet. + */ +function buildDecorations(view: EditorView): DecorationSet { + const builder: Range[] = []; + const selections = view.state.selection.ranges.map( + (range: SelectionRange) => ({ + from: range.from, + to: range.to, + }), + ); + + let codeFenceOpen = false; + + for ( + let lineNumber = 1; + lineNumber <= view.state.doc.lines; + lineNumber += 1 + ) { + const line = view.state.doc.line(lineNumber); + const text = line.text; + const lineFrom = line.from; + const trimmed = text.trim(); + + const fence = text.match(/^```\s*([^`]*)$/); + if (fence) { + const ticksStart = lineFrom + text.indexOf("```"); + const ticksEnd = ticksStart + 3; + addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd }); + if (trimmed.length > 3) { + addHiddenToken(builder, selections, { + from: ticksEnd, + to: line.to, + }); + } + codeFenceOpen = !codeFenceOpen; + continue; + } + + if (codeFenceOpen) { + builder.push(codeLineDecoration.range(lineFrom)); + continue; + } + + const heading = text.match(/^(#{1,6})\s+/); + if (heading) { + const markerLength = heading[0].length; + addHiddenToken(builder, selections, { + from: lineFrom, + to: lineFrom + markerLength, + }); + + const level = heading[1].length; + const headingClass = Decoration.mark({ + class: `tm-md-heading tm-md-h${String(level)}`, + }); + const contentFrom = lineFrom + markerLength; + if (contentFrom < line.to) { + builder.push(headingClass.range(contentFrom, line.to)); + } + } + + const quote = text.match(/^>\s?/); + if (quote) { + addHiddenToken(builder, selections, { + from: lineFrom, + to: lineFrom + quote[0].length, + }); + } + + const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/); + if (unordered) { + const markerStart = lineFrom + unordered[1].length; + const markerEnd = markerStart + unordered[2].length + 1; + addHiddenToken(builder, selections, { from: markerStart, to: markerEnd }); + + const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/); + if (checkbox) { + const checkboxStart = lineFrom + unordered[0].lastIndexOf("["); + addHiddenToken(builder, selections, { + from: checkboxStart, + to: checkboxStart + checkbox[0].length, + }); + } + } + + const ordered = text.match(/^(\s*)(\d+\.)\s+/); + if (ordered) { + const markerStart = lineFrom + ordered[1].length; + addHiddenToken(builder, selections, { + from: markerStart, + to: markerStart + ordered[2].length + 1, + }); + } + + if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) { + if (lineFrom < line.to) { + builder.push( + Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to), + ); + } + continue; + } + + const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test( + text, + ); + if (tableSeparator) { + if (lineFrom < line.to) { + builder.push( + Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to), + ); + } + continue; + } + + const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom); + + for (const range of styleRanges) { + if (range.from >= range.to) continue; + + if (range.className === "tm-md-strong") { + builder.push(strongDecoration.range(range.from, range.to)); + } else if (range.className === "tm-md-em") { + builder.push(emDecoration.range(range.from, range.to)); + } else if (range.className === "tm-md-del") { + builder.push(delDecoration.range(range.from, range.to)); + } else if (range.className === "tm-md-code") { + builder.push(codeDecoration.range(range.from, range.to)); + } else if (range.className === "tm-md-link") { + builder.push(linkDecoration.range(range.from, range.to)); + } + } + + for (const token of tokenRanges) { + addHiddenToken(builder, selections, token); + } + } + + return Decoration.set(builder, true); +} + +/** + * Keeps markdown syntax visible only when user selection intersects the token. + * This preserves cursor predictability and cross-token selection while still hiding syntax during reading. + */ +function addHiddenToken( + builder: Range[], + selections: ReadonlyArray<{ from: number; to: number }>, + token: { + from: number; + to: number; + }, +): void { + if (token.from >= token.to) return; + + const overlapsSelection = selections.some((selection) => { + const selectionFrom = Math.min(selection.from, selection.to); + const selectionTo = Math.max(selection.from, selection.to); + + if (selectionFrom === selectionTo) { + return selectionFrom >= token.from && selectionFrom <= token.to; + } + + return selectionFrom < token.to && selectionTo > token.from; + }); + + if (overlapsSelection) return; + builder.push(hiddenTokenDecoration.range(token.from, token.to)); +} diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx new file mode 100644 index 0000000..cd2a210 --- /dev/null +++ b/packages/markdown/src/markdown.tsx @@ -0,0 +1,815 @@ +import { + Fragment, + useEffect, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import Emoji from "./emoji"; +import { findEmojiShortcodes } from "./emojiData"; +import { Check } from "lucide-react"; + +type InlineNode = + | { type: "text"; value: string } + | { type: "emoji"; shortcode: string } + | { type: "strong"; value: string } + | { type: "em"; value: string } + | { type: "del"; value: string } + | { type: "code"; value: string } + | { type: "link"; label: string; href: string } + | { type: "image"; alt: string; src: string }; + +type InlineDecorationRange = { + from: number; + to: number; + className: string; +}; + +type InlineTokenRange = { + from: number; + to: number; +}; + +type ListItem = { + text: string; + checked: boolean | null; +}; + +type TableBlock = { + type: "table"; + headers: string[]; + rows: string[][]; +}; + +type MarkdownBlock = + | { + type: "paragraph"; + text: string; + } + | { + type: "heading"; + level: number; + text: string; + } + | { + type: "hr"; + } + | { + type: "blockquote"; + text: string; + } + | { + type: "code"; + language: string; + code: string; + } + | { + type: "list"; + ordered: boolean; + items: ListItem[]; + } + | TableBlock; + +const INLINE_TOKEN_REGEX = + /!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(? + + + ); +} + +function CopyableCode({ + block = false, + language, + value, +}: { + block?: boolean; + language?: string; + value: string; +}) { + const [copied, setCopied] = useState(false); + const copiedTimer = useRef | undefined>( + undefined, + ); + + useEffect( + () => () => { + clearTimeout(copiedTimer.current); + }, + [], + ); + + async function copy() { + await navigator.clipboard.writeText(value); + setCopied(true); + clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 1200); + } + + const code = ( + void copy()} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void copy(); + }} + > + {value} + + ); + + if (block) { + return ( +
+
{code}
+ +
+ ); + } + + return ( + <> + {code} + + + ); +} + +/** + * Executes parseInlineNodes. + * @param input Parameter input. + * @returns InlineNode[]. + */ +export function parseInlineNodes(input: string): InlineNode[] { + const nodes: InlineNode[] = []; + + let cursor = 0; + let match = INLINE_TOKEN_REGEX.exec(input); + + while (match) { + const index = match.index; + const raw = match[0]; + + if (index > cursor) { + nodes.push(...parseEmojiText(input.slice(cursor, index))); + } + + if (match[1] !== undefined && match[2] !== undefined) { + nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) }); + } else if (match[3] !== undefined && match[4] !== undefined) { + nodes.push({ + type: "link", + label: match[3], + href: normalizeUrl(match[4]), + }); + } else if (match[5] !== undefined) { + nodes.push({ type: "code", value: match[5] }); + } else if (match[6] !== undefined) { + nodes.push({ type: "del", value: match[6] }); + } else if (match[7] !== undefined || match[8] !== undefined) { + nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" }); + } else if (match[9] !== undefined || match[10] !== undefined) { + nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" }); + } else { + nodes.push(...parseEmojiText(raw)); + } + + cursor = index + raw.length; + match = INLINE_TOKEN_REGEX.exec(input); + } + + if (cursor < input.length) { + nodes.push(...parseEmojiText(input.slice(cursor))); + } + + INLINE_TOKEN_REGEX.lastIndex = 0; + return nodes; +} + +export function parseEmojiText(input: string): InlineNode[] { + const nodes: InlineNode[] = []; + let cursor = 0; + + for (const match of findEmojiShortcodes(input)) { + if (match.from > cursor) { + nodes.push({ type: "text", value: input.slice(cursor, match.from) }); + } + + nodes.push({ type: "emoji", shortcode: match.emoji.shortcode }); + cursor = match.to; + } + + if (cursor < input.length) { + nodes.push({ type: "text", value: input.slice(cursor) }); + } + + return nodes; +} + +/** + * Executes collectInlineRanges. + * @param input Parameter input. + * @param offset Parameter offset. + * @returns { + styleRanges: InlineDecorationRange[]; + tokenRanges: InlineTokenRange[]; +}. + */ +export function collectInlineRanges( + input: string, + offset = 0, +): { + styleRanges: InlineDecorationRange[]; + tokenRanges: InlineTokenRange[]; +} { + const styleRanges: InlineDecorationRange[] = []; + const tokenRanges: InlineTokenRange[] = []; + + let match = INLINE_TOKEN_REGEX.exec(input); + + while (match) { + const raw = match[0]; + const start = offset + match.index; + const end = start + raw.length; + + if (match[1] !== undefined && match[2] !== undefined) { + const openLength = 2; + const closeLength = raw.endsWith(")") ? 1 : 0; + + const imageEnd = start + openLength + match[1].length; + tokenRanges.push({ from: start, to: start + openLength }); + tokenRanges.push({ from: imageEnd, to: imageEnd + 1 }); + + const srcStart = imageEnd + 1; + const srcEnd = end - closeLength; + tokenRanges.push({ from: srcStart, to: srcStart + 1 }); + tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength }); + } else if (match[3] !== undefined && match[4] !== undefined) { + const label = match[3]; + const labelStart = start + 1; + const labelEnd = labelStart + label.length; + + tokenRanges.push({ from: start, to: start + 1 }); + tokenRanges.push({ from: labelEnd, to: labelEnd + 1 }); + tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 }); + tokenRanges.push({ from: end - 1, to: end }); + + styleRanges.push({ + from: labelStart, + to: labelEnd, + className: "tm-md-link", + }); + } else if (match[5] !== undefined) { + const codeStart = start + 1; + const codeEnd = end - 1; + + tokenRanges.push({ from: start, to: start + 1 }); + tokenRanges.push({ from: end - 1, to: end }); + styleRanges.push({ + from: codeStart, + to: codeEnd, + className: "tm-md-code", + }); + } else if (match[6] !== undefined) { + const contentStart = start + 2; + const contentEnd = end - 2; + + tokenRanges.push({ from: start, to: start + 2 }); + tokenRanges.push({ from: end - 2, to: end }); + styleRanges.push({ + from: contentStart, + to: contentEnd, + className: "tm-md-del", + }); + } else if (match[7] !== undefined || match[8] !== undefined) { + const contentStart = start + 2; + const contentEnd = end - 2; + + tokenRanges.push({ from: start, to: start + 2 }); + tokenRanges.push({ from: end - 2, to: end }); + styleRanges.push({ + from: contentStart, + to: contentEnd, + className: "tm-md-strong", + }); + } else if (match[9] !== undefined || match[10] !== undefined) { + const contentStart = start + 1; + const contentEnd = end - 1; + + tokenRanges.push({ from: start, to: start + 1 }); + tokenRanges.push({ from: end - 1, to: end }); + styleRanges.push({ + from: contentStart, + to: contentEnd, + className: "tm-md-em", + }); + } + + match = INLINE_TOKEN_REGEX.exec(input); + } + + INLINE_TOKEN_REGEX.lastIndex = 0; + return { styleRanges, tokenRanges }; +} + +/** + * Executes parseMarkdownBlocks. + * @param markdown Parameter markdown. + * @returns MarkdownBlock[]. + */ +export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const blocks: MarkdownBlock[] = []; + + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + + if (!line.trim()) { + index += 1; + continue; + } + + const codeFence = line.match(/^```\s*([^`]*)$/); + if (codeFence) { + const language = (codeFence[1] ?? "").trim(); + const codeLines: string[] = []; + index += 1; + + while (index < lines.length && !/^```\s*$/.test(lines[index])) { + codeLines.push(lines[index]); + index += 1; + } + + if (index < lines.length) { + index += 1; + } + + blocks.push({ type: "code", language, code: codeLines.join("\n") }); + continue; + } + + if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) { + blocks.push({ type: "hr" }); + index += 1; + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + blocks.push({ + type: "heading", + level: heading[1].length, + text: heading[2], + }); + index += 1; + continue; + } + + const quote = line.match(/^>\s?(.*)$/); + if (quote) { + const quoteLines: string[] = [quote[1]]; + index += 1; + + while (index < lines.length) { + const next = lines[index].match(/^>\s?(.*)$/); + if (!next) break; + quoteLines.push(next[1]); + index += 1; + } + + blocks.push({ type: "blockquote", text: quoteLines.join("\n") }); + continue; + } + + const tableCandidate = readTable(lines, index); + if (tableCandidate) { + blocks.push(tableCandidate.block); + index = tableCandidate.nextIndex; + continue; + } + + const unordered = line.match(/^\s*[-*+]\s+(.*)$/); + const ordered = line.match(/^\s*\d+\.\s+(.*)$/); + if (unordered || ordered) { + const orderedList = Boolean(ordered); + const items: ListItem[] = []; + + while (index < lines.length) { + const current = lines[index]; + const match = orderedList + ? current.match(/^\s*\d+\.\s+(.*)$/) + : current.match(/^\s*[-*+]\s+(.*)$/); + + if (!match) break; + + const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/); + if (task) { + items.push({ + text: task[2], + checked: task[1].toLowerCase() === "x", + }); + } else { + items.push({ text: match[1], checked: null }); + } + + index += 1; + } + + blocks.push({ type: "list", ordered: orderedList, items }); + continue; + } + + const paragraphLines = [line]; + index += 1; + + while ( + index < lines.length && + lines[index].trim() && + !/^(#{1,6})\s+/.test(lines[index]) && + !/^```\s*/.test(lines[index]) && + !/^>\s?/.test(lines[index]) && + !/^\s*[-*+]\s+/.test(lines[index]) && + !/^\s*\d+\.\s+/.test(lines[index]) && + !/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim()) + ) { + paragraphLines.push(lines[index]); + index += 1; + } + + blocks.push({ type: "paragraph", text: paragraphLines.join("\n") }); + } + + return blocks; +} + +/** + * Executes renderInline. + * @param nodes Parameter nodes. + * @returns React.ReactNode[]. + */ +function renderInline(nodes: InlineNode[]): ReactNode[] { + return nodes.map((node, index) => { + if (node.type === "text") { + return node.value; + } + + if (node.type === "emoji") { + return ( + + ); + } + + if (node.type === "strong") { + return ( + + {renderInline(parseEmojiText(node.value))} + + ); + } + + if (node.type === "em") { + return ( + + {renderInline(parseEmojiText(node.value))} + + ); + } + + if (node.type === "del") { + return ( + + {renderInline(parseEmojiText(node.value))} + + ); + } + + if (node.type === "code") { + return ; + } + + if (node.type === "link") { + return ( + + {renderInline(parseEmojiText(node.label))} + + ); + } + + return ( + {node.alt} + ); + }); +} + +/** + * Executes renderBlocks. + * @param blocks Parameter blocks. + * @returns React.ReactElement. + */ +export function renderBlocks(blocks: MarkdownBlock[]): ReactElement { + return ( + <> + {blocks.map((block, blockIndex) => { + if (block.type === "heading") { + const className = `tm-md-heading tm-md-h${String(block.level)}`; + if (block.level === 1) + return ( +

+ {renderInline(parseInlineNodes(block.text))} +

+ ); + if (block.level === 2) + return ( +

+ {renderInline(parseInlineNodes(block.text))} +

+ ); + if (block.level === 3) + return ( +

+ {renderInline(parseInlineNodes(block.text))} +

+ ); + if (block.level === 4) + return ( +

+ {renderInline(parseInlineNodes(block.text))} +

+ ); + if (block.level === 5) + return ( +
+ {renderInline(parseInlineNodes(block.text))} +
+ ); + return ( +
+ {renderInline(parseInlineNodes(block.text))} +
+ ); + } + + if (block.type === "blockquote") { + return ( +
+ {block.text.split("\n").map((line, lineIndex) => ( +

{renderInline(parseInlineNodes(line))}

+ ))} +
+ ); + } + + if (block.type === "code") { + return ( + + ); + } + + if (block.type === "list") { + const Tag = block.ordered ? "ol" : "ul"; + return ( + + {block.items.map((item, itemIndex) => ( +
  • + {item.checked !== null ? ( + + ) : null} + {renderInline(parseInlineNodes(item.text))} +
  • + ))} +
    + ); + } + + if (block.type === "table") { + return ( +
    + + + + {block.headers.map((header, headerIndex) => ( + + ))} + + + + {block.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
    + {renderInline(parseInlineNodes(header))} +
    + {renderInline(parseInlineNodes(cell))} +
    +
    + ); + } + + if (block.type === "hr") { + return
    ; + } + + return ( +

    + {block.text.split("\n").map((line, lineIndex) => ( + + {lineIndex > 0 ?
    : null} + {renderInline(parseInlineNodes(line))} +
    + ))} +

    + ); + })} + + ); +} + +/** + * Executes normalizeUrl. + * @param input Parameter input. + * @returns string. + */ +function normalizeUrl(input: string): string { + const value = input.trim(); + if (/^(https?:|mailto:|tel:|\/)/i.test(value)) { + return value; + } + + return "#"; +} + +/** + * Executes splitTableRow. + * @param row Parameter row. + * @returns string[]. + */ +function splitTableRow(row: string): string[] { + const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, ""); + return cleaned.split("|").map((cell) => cell.trim()); +} + +/** + * Executes readTable. + * @param lines Parameter lines. + * @param index Parameter index. + * @returns { block: TableBlock; nextIndex: number } | null. + */ +function readTable( + lines: string[], + index: number, +): { block: TableBlock; nextIndex: number } | null { + const header = lines[index] ?? ""; + const separator = lines[index + 1] ?? ""; + + if (!header.includes("|") || !separator.includes("|")) { + return null; + } + + const separatorCells = splitTableRow(separator); + const isSeparator = separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell)); + if (!isSeparator) { + return null; + } + + const headers = splitTableRow(header); + const rows: string[][] = []; + let cursor = index + 2; + + while (cursor < lines.length && lines[cursor].includes("|")) { + rows.push(splitTableRow(lines[cursor])); + cursor += 1; + } + + return { + block: { type: "table", headers, rows }, + nextIndex: cursor, + }; +} + +const markdownStyles = ` +.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; } +.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; } +.tm-md-h1 { font-size: 1.65rem; } +.tm-md-h2 { font-size: 1.45rem; } +.tm-md-h3 { font-size: 1.25rem; } +.tm-md-h4 { font-size: 1.1rem; } +.tm-md-h5 { font-size: 1rem; } +.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; } +.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; } +.tm-md-blockquote p { margin: 0.2rem 0; } +.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; } +.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; } +.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); } +.tm-md-codeblock { display: block; } +.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.tm-md-strong { font-weight: 700; } +.tm-md-em { font-style: italic; } +.tm-md-del { text-decoration: line-through; } +.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; } +.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; } +.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; } +.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; } +.tm-md-li { margin: 0.2rem 0; } +.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; } +.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; } +.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; } +.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; } +.tm-md-table th { background: var(--muted); font-weight: 600; } +.tm-md-hr { margin: 0.55rem 0; } + +.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); } +.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; } +.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; } +.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); } +.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; } +.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); } +.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; } +.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; } +.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; } +.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; } +.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; } +.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; } +.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); } +.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); } +.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover, +.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); } +.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; } +.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; } +.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; } +.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; } +`; + +/** + * Executes ensureMarkdownStyles. + * @param none This function has no parameters. + * @returns void. + */ +export function ensureMarkdownStyles(): void { + if (typeof document === "undefined") return; + + const styleId = "tensamin-markdown-styles"; + let style = document.getElementById(styleId) as HTMLStyleElement | null; + + if (!style) { + style = document.createElement("style"); + style.id = styleId; + document.head.appendChild(style); + } + + if (style.textContent !== markdownStyles) { + style.textContent = markdownStyles; + } +} diff --git a/packages/markdown/src/text.tsx b/packages/markdown/src/text.tsx new file mode 100644 index 0000000..ca65fe0 --- /dev/null +++ b/packages/markdown/src/text.tsx @@ -0,0 +1,30 @@ +import { useMemo, type CSSProperties } from "react"; + +import { + ensureMarkdownStyles, + parseMarkdownBlocks, + renderBlocks, +} from "./markdown"; + +export type TextProps = { + value: string; + fontSize?: CSSProperties["fontSize"]; +}; + +/** + * Executes Text. + * @param props Parameter props. + * @returns unknown. + */ +export default function Text(props: TextProps) { + ensureMarkdownStyles(); + + const blocks = useMemo(() => parseMarkdownBlocks(props.value), [props.value]); + const renderedBlocks = useMemo(() => renderBlocks(blocks), [blocks]); + + return ( +
    + {renderedBlocks} +
    + ); +} diff --git a/packages/markdown/todo.md b/packages/markdown/todo.md new file mode 100644 index 0000000..b59da98 --- /dev/null +++ b/packages/markdown/todo.md @@ -0,0 +1 @@ +- Improve the Input box diff --git a/packages/markdown/tsconfig.json b/packages/markdown/tsconfig.json new file mode 100644 index 0000000..9714625 --- /dev/null +++ b/packages/markdown/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/mtp/package.json b/packages/mtp/package.json index dfd973c..e036029 100644 --- a/packages/mtp/package.json +++ b/packages/mtp/package.json @@ -9,15 +9,19 @@ "scripts": { "format": "pnpm exec prettier --write .", "lint": "eslint src --ext .ts,.tsx", - "build": "tsc -p tsconfig.json --noEmit" + "test": "vitest run", + "build": "pnpm run test && tsc -p tsconfig.json --noEmit" }, "dependencies": { "@methanium/ui": "*", "@tauri-apps/api": "^2.11.1", + "@tensamin/crypto": "workspace:*", "@tensamin/shared": "workspace:*", "@tensamin/storage": "workspace:*", "mtp": "*", - "react": "^19.2.8" + "react": "^19.2.8", + "react-dom": "^19.2.8", + "zod": "^4.4.3" }, "devDependencies": { "eslint": "^10.8.0" diff --git a/packages/mtp/src/context.test.tsx b/packages/mtp/src/context.test.tsx new file mode 100644 index 0000000..b15043a --- /dev/null +++ b/packages/mtp/src/context.test.tsx @@ -0,0 +1,220 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RequestIdAllocator } from "./requestIds"; + +vi.mock("@methanium/ui", () => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + }, +})); + +vi.mock("@tensamin/shared/log", () => ({ log: vi.fn() })); +vi.mock("@tensamin/storage/context", () => ({ + useStorage: () => ({ load: vi.fn() }), +})); + +const { completeInitialSynchronization, isPushType, validateResponse } = + await import("./context"); + +const validState = { + SessionId: 7, + VersionNumber: 2, + CacheSchemaVersion: 0, + SyncMode: "full", + Contacts: [], + Communities: [], + Calls: [], + Messages: [], + DeletedMessageIds: [], + DeletedContactIds: [], +}; + +function mockInitialSyncClient( + state: { type: string; data: unknown } = { + type: "ClientStateSync", + data: validState, + }, + acknowledgement: unknown = { type: "ClientStateAck", data: {} }, +) { + const handlers = new Map void>(); + const request = vi.fn().mockResolvedValue(acknowledgement); + const client = { + auth: vi.fn(async () => { + handlers.get(state.type)?.(state as never); + }), + subscribe: vi.fn((type: string, handler: (message: never) => void) => { + handlers.set(type, handler); + return () => handlers.delete(type); + }), + request, + disconnect: vi.fn(), + } as unknown as Parameters[0]; + return { client, handlers, request }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("MTP protocol dispatch", () => { + it("preserves protocol errors for the request layer", () => { + const error = validateResponse("GetStates", { + id: 12, + type: "ErrorInternal", + data: { ErrorType: "temporary" }, + }); + expect(error.type).toBe("ErrorInternal"); + expect(error.id).toBe(12); + }); + + it("recognizes initial and live presence pushes", () => { + expect(isPushType("GetStates")).toBe(true); + expect(isPushType("ClientChanged")).toBe(true); + expect(isPushType("UnknownMessage")).toBe(false); + }); +}); + +describe("browser initial synchronization", () => { + it("validates state, sends a nonzero acknowledgement, then resolves", async () => { + const { client, request } = mockInitialSyncClient(); + + const state = await completeInitialSynchronization( + client, + new RequestIdAllocator(), + new AbortController().signal, + ); + + expect(state.data).toEqual(validState); + expect(request).toHaveBeenCalledWith( + "ClientStateAck", + { SessionId: 7, VersionNumber: 2 }, + { id: 1 }, + ); + }); + + it("does not acknowledge malformed state", async () => { + const { client, request } = mockInitialSyncClient({ + type: "ClientStateSync", + data: { ...validState, SyncMode: "invalid" }, + }); + + await expect( + completeInitialSynchronization( + client, + new RequestIdAllocator(), + new AbortController().signal, + ), + ).rejects.toThrow("Response validation failed"); + expect(request).not.toHaveBeenCalled(); + }); + + it("rejects ErrorNoIota during initial synchronization", async () => { + const { client, request } = mockInitialSyncClient({ + type: "ErrorNoIota", + data: {}, + }); + + await expect( + completeInitialSynchronization( + client, + new RequestIdAllocator(), + new AbortController().signal, + ), + ).rejects.toThrow("No Iota is currently connected"); + expect(request).not.toHaveBeenCalled(); + }); + + it("rejects an acknowledgement protocol error", async () => { + const { client } = mockInitialSyncClient(undefined, { + type: "ErrorInvalidData", + data: {}, + }); + + await expect( + completeInitialSynchronization( + client, + new RequestIdAllocator(), + new AbortController().signal, + ), + ).rejects.toThrow("State acknowledgement failed: ErrorInvalidData"); + }); + + it("times out a missing acknowledgement", async () => { + vi.useFakeTimers(); + const { client } = mockInitialSyncClient(); + vi.mocked(client.request).mockReturnValue(new Promise(() => {})); + const result = completeInitialSynchronization( + client, + new RequestIdAllocator(), + new AbortController().signal, + 100, + 10, + ); + const assertion = expect(result).rejects.toThrow( + "State acknowledgement timed out", + ); + + await vi.advanceTimersByTimeAsync(10); + await assertion; + }); + + it("stops immediately when the connection attempt is cancelled", async () => { + const { client } = mockInitialSyncClient(); + vi.mocked(client.auth).mockImplementation(() => new Promise(() => {})); + const controller = new AbortController(); + const result = completeInitialSynchronization( + client, + new RequestIdAllocator(), + controller.signal, + ); + + controller.abort(new Error("MTP connection lost")); + await expect(result).rejects.toThrow("MTP connection lost"); + }); + + it("times out authentication while waiting for initial state", async () => { + vi.useFakeTimers(); + const { client, handlers } = mockInitialSyncClient(); + vi.mocked(client.auth).mockImplementation(() => { + handlers.get("ClientStateSync")?.({ + type: "ClientStateSync", + data: validState, + } as never); + return new Promise(() => {}); + }); + const result = completeInitialSynchronization( + client, + new RequestIdAllocator(), + new AbortController().signal, + 100, + 10, + ); + const assertion = expect(result).rejects.toThrow( + "MTP authentication timed out", + ); + + await vi.advanceTimersByTimeAsync(100); + await assertion; + }); + + it("uses a fresh request ID namespace for each connection", async () => { + const first = mockInitialSyncClient(); + const second = mockInitialSyncClient(); + + await completeInitialSynchronization( + first.client, + new RequestIdAllocator(), + new AbortController().signal, + ); + await completeInitialSynchronization( + second.client, + new RequestIdAllocator(), + new AbortController().signal, + ); + + expect(first.request.mock.calls[0]?.[2]).toEqual({ id: 1 }); + expect(second.request.mock.calls[0]?.[2]).toEqual({ id: 1 }); + }); +}); diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 288acbf..53c1862 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -10,18 +10,9 @@ import { } from "react"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { - base64ToBytes, - ConnectionState, - MTPClient, - MTPProxyConnection, - type MTPFrame, - type MTPKeyMaterialInput, - type MTPProxyAdapter, - type MTPRequestOptions, - type MTPRequestFunction, - type MTPResponseFrame, -} from "mtp"; +import { MTPClient } from "mtp"; +import { type z } from "zod"; +import { ConnectionState } from "mtp"; import createAsyncQueue from "@tensamin/shared/asyncQueue"; import { toast as sonnerToast } from "@methanium/ui"; @@ -33,15 +24,47 @@ import { type MTP as Schemas, } from "@tensamin/shared/data"; import { log } from "@tensamin/shared/log"; +import { ProtocolError } from "@tensamin/shared/errors"; import { useStorage } from "@tensamin/storage/context"; -import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values"; +import { fromWireMessage, toWireData } from "./protocolFields"; +import { RequestIdAllocator } from "./requestIds"; +import { + DISCOVERY_TIMEOUT, + INITIAL_SYNC_TIMEOUT, + RECONNECT_JITTER, + RECONNECT_LONG_INTERVAL, + RECONNECT_RESET, + RECONNECT_TRIES, + RETRY_INTERVAL, + STATE_ACK_TIMEOUT, +} from "./values"; + +type BrowserMtpClient = Awaited>; + +function base64ToUint8Array(b64: string) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + + for (let i = 0; i < bin.length; i++) { + out[i] = bin.charCodeAt(i); + } + + return out; +} export type ProtocolMessage< T extends keyof Schemas & string = keyof Schemas & string, -> = MTPResponseFrame; +> = { + id?: number; + type: T | string; + data: z.infer; +}; -export type BoundSendFn = MTPRequestFunction; +export type BoundSendFn = ( + type: T, + data?: z.infer, +) => Promise>; export type PushHandler = (message: ProtocolMessage) => void | Promise; @@ -57,19 +80,33 @@ const PUSH_TYPES = [ "ErrorNoIota", ] as const; -function encodedPublicKey(value: string): MTPKeyMaterialInput { - const trimmed = value.trim(); - const hex = trimmed.replace(/^0x/i, ""); - return { - value: /^[0-9a-f]+$/i.test(hex) ? hex : trimmed, - encoding: /^[0-9a-f]+$/i.test(hex) ? "hex" : "base64", - }; -} - export function isPushType(type: string): boolean { return (PUSH_TYPES as readonly string[]).includes(type); } +function normalizeMtpMessage(message: T): T { + return fromWireMessage(message); +} + +async function requestWithId( + client: BrowserMtpClient, + ids: RequestIdAllocator, + type: string, + data: Record, +) { + let id: number; + try { + id = ids.allocate(); + } catch (error) { + client.disconnect(); + throw error; + } + + return client.request(type, toWireData(data) as Record, { + id, + }); +} + function removeMissingContacts( contacts: Contacts, message: ProtocolMessage, @@ -112,6 +149,167 @@ type ContextType = { const MTPContext = createContext(undefined); +function getProtocolErrorDetails(error: unknown) { + if (typeof error !== "object" || error === null || !("type" in error)) { + return null; + } + + const protocolError = error as { + id?: unknown; + type?: unknown; + data?: unknown; + }; + return { + id: protocolError.id, + type: protocolError.type, + data: protocolError.data, + }; +} + +// Zod schema validation +export function validateResponse( + type: T, + message: { id?: number; type: string; data: unknown }, +): ProtocolMessage { + if (message.type.startsWith("Error")) { + return message as ProtocolMessage; + } + + const schema = + schemas[message.type as keyof Schemas & string]?.response ?? + schemas[type]?.response; + if (!schema) { + return message as ProtocolMessage; + } + + const parsed = schema.safeParse(message.data); + if (!parsed.success) { + throw new Error( + `Response validation failed for ${type}: ${parsed.error.message}`, + ); + } + + return { + id: message.id, + type: message.type, + data: parsed.data, + } as ProtocolMessage; +} + +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error("Initial state synchronization was cancelled"); +} + +function withDeadline( + promise: Promise, + timeoutMs: number, + timeoutMessage: string, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(abortError(signal)); + return; + } + let settled = false; + const finish = (complete: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", onAbort); + complete(); + }; + const timeout = setTimeout( + () => finish(() => reject(new Error(timeoutMessage))), + timeoutMs, + ); + const onAbort = () => finish(() => reject(abortError(signal))); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => finish(() => resolve(value)), + (error: unknown) => finish(() => reject(error)), + ); + }); +} + +export async function completeInitialSynchronization( + client: BrowserMtpClient, + ids: RequestIdAllocator, + signal: AbortSignal, + syncTimeoutMs = INITIAL_SYNC_TIMEOUT, + ackTimeoutMs = STATE_ACK_TIMEOUT, +): Promise> { + const stateSync = new Promise>( + (resolve, reject) => { + let unsubscribeStateSync = () => {}; + let unsubscribeNoIota = () => {}; + const cleanup = () => { + clearTimeout(timeout); + unsubscribeStateSync(); + unsubscribeNoIota(); + signal.removeEventListener("abort", onAbort); + }; + const onAbort = () => { + cleanup(); + reject(abortError(signal)); + }; + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("Initial state synchronization timed out")); + }, syncTimeoutMs); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return; + } + unsubscribeStateSync = client.subscribe("ClientStateSync", (message) => { + cleanup(); + try { + resolve( + validateResponse("ClientStateSync", normalizeMtpMessage(message)), + ); + } catch (error) { + reject(error); + } + }); + unsubscribeNoIota = client.subscribe("ErrorNoIota", () => { + cleanup(); + reject(new Error("No Iota is currently connected")); + }); + }, + ); + const [, state] = await Promise.all([ + withDeadline( + client.auth(), + syncTimeoutMs, + "MTP authentication timed out", + signal, + ), + stateSync, + ]); + if (state.type.startsWith("Error")) { + throw new Error(`State synchronization failed: ${state.type}`); + } + const acknowledgement = normalizeMtpMessage( + await withDeadline( + requestWithId(client, ids, "ClientStateAck", { + SessionId: state.data.SessionId, + VersionNumber: state.data.VersionNumber, + }), + ackTimeoutMs, + "State acknowledgement timed out", + signal, + ), + ); + if (acknowledgement.type.startsWith("Error")) { + throw new Error(`State acknowledgement failed: ${acknowledgement.type}`); + } + if (signal.aborted) throw abortError(signal); + return state; +} + function useMessageHandlers() { const interceptorsRef = useRef(new Set()); const pushHandlersRef = useRef(new Set()); @@ -153,7 +351,10 @@ function BrowserProvider(props: { const [freshContacts, setFreshContacts] = useState([]); const [freshCalls, setFreshCalls] = useState([]); - const clientRef = useRef | null>(null); + const clientRef = useRef> | null>( + null, + ); + const requestIdsRef = useRef(null); const { addInterceptor, interceptorsRef, @@ -170,16 +371,38 @@ function BrowserProvider(props: { load("omega_url").then(setMtpUrl); }, [load]); + // Validation override functions const send: BoundSendFn = useMemo( - () => async (type, data, options) => { + () => async (type, data) => { const client = clientRef.current; + const ids = requestIdsRef.current; if (!client) { throw new Error("mtp is not connected"); } + if (!ids) { + throw new Error("MTP request allocator is unavailable"); + } - const response = await client.request(type, data, options); + const rawMessage = await requestWithId( + client, + ids, + type, + (data ?? {}) as Record, + ); + const response = validateResponse(type, normalizeMtpMessage(rawMessage)); setFreshContacts((contacts) => removeMissingContacts(contacts, response)); + if (response.type.startsWith("Error")) { + const errorData = response.data as Record; + throw new ProtocolError({ + type: response.type, + requestId: response.id, + errorType: + typeof errorData.ErrorType === "string" + ? errorData.ErrorType + : undefined, + }); + } return response; }, [], @@ -191,7 +414,9 @@ function BrowserProvider(props: { return () => {}; } - return client.subscribe(type, handler); + return client.subscribe(type, (message) => { + handler(validateResponse(type, normalizeMtpMessage(message))); + }); }, []); // Reconnect stuff @@ -221,45 +446,57 @@ function BrowserProvider(props: { const scheduleReconnect = (error: unknown) => { if (disposed || reconnectScheduled) return; - if (attempts >= RECONNECT_TRIES) { + attempts += 1; + const shortRetry = attempts <= RECONNECT_TRIES; + if (!shortRetry) { log(0, "mtp", "red", "Reconnection attempts exhausted", error); sonnerToast.error("Connection failed", { id: "mtp-connection-toast", description: error instanceof Error - ? error.message.split(":")[0] - : "Connection lost", + ? `${error.message.split(":")[0]}. Retrying in the background.` + : "Connection lost. Retrying in the background.", icon: null, duration: Infinity, closeButton: true, promise: null, } as unknown as Parameters[1]); - return; + } else { + sonnerToast.loading( + `Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`, + { id: "mtp-connection-toast" }, + ); } - attempts += 1; - sonnerToast.loading( - `Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`, - { id: "mtp-connection-toast" }, - ); + const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL; + const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER; reconnectScheduled = true; - reconnectTimer = setTimeout(() => { - reconnectScheduled = false; - reconnectTimer = null; - void connect(); - }, RETRY_INTERVAL); + reconnectTimer = setTimeout( + () => { + reconnectScheduled = false; + reconnectTimer = null; + void connect(); + }, + Math.round(baseDelay * jitter), + ); }; async function connect() { if (disposed || props.blockConnection) return; const generation = ++connectionGeneration; - let client: MTPClient | null = null; + let client: Awaited> | null = null; let failed = false; + let connectionReady = false; + const attemptAbort = new AbortController(); const cleanup = () => { + attemptAbort.abort( + new Error("Initial state synchronization was cancelled"), + ); client?.disconnect(); if (clientRef.current === client) { clientRef.current = null; + requestIdsRef.current = null; } clearReconnectResetTimer(); if (generation === connectionGeneration) { @@ -289,19 +526,18 @@ function BrowserProvider(props: { omikronPublicKey = forcedOmikronPublicKey; } else { log(2, "mtp", "purple", "Fetching Omikron data."); - const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`); + const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, { + signal: AbortSignal.any([ + attemptAbort.signal, + AbortSignal.timeout(DISCOVERY_TIMEOUT), + ]), + }); if (data.status === 404) { - sonnerToast.error("We couldn't reach your Iota", { - description: - "Check your network connection and try restarting your Iota", - icon: null, - duration: Infinity, - closeButton: true, - }); - resolveConnectionRef.current?.(); - cleanup(); - return; + throw new Error("No Omikron assignment is currently available"); + } + if (!data.ok) { + throw new Error(`Omikron discovery failed: HTTP ${data.status}`); } const omikronData = (await data.json()) as { id: number; @@ -328,20 +564,18 @@ function BrowserProvider(props: { log(2, "mtp", "green", "Connecting to: " + url); - client = await MTPClient.create({ + client = await MTPClient.create({ url, credentials: { clientId: userId, - keyring: base64ToBytes(keyring), + keyring: base64ToUint8Array(keyring), + }, + hostPublicKey: { + value: omikronPublicKey, + encoding: "base64", }, - hostPublicKey: encodedPublicKey(omikronPublicKey), descriptor: "client", pings: true, - schemas, - throwProtocolErrors: true, - onValidationError: (error) => { - log(1, "mtp", "red", "Failed to validate MTP message", error); - }, logger: (event) => { if (event.type === "state") { if (generation !== connectionGeneration) return; @@ -353,10 +587,12 @@ function BrowserProvider(props: { !failed ) { failed = true; - clientRef.current = null; - setIdentified(false); - setIdentifying(false); - scheduleReconnect(new Error("MTP connection lost")); + const error = new Error("MTP connection lost"); + attemptAbort.abort(error); + if (connectionReady) { + cleanup(); + scheduleReconnect(error); + } } } @@ -391,9 +627,19 @@ function BrowserProvider(props: { const activeClient = client; clientRef.current = activeClient; + requestIdsRef.current = new RequestIdAllocator(); for (const type of PUSH_TYPES) { activeClient.subscribe(type, (message) => { - const validated = message as ProtocolMessage; + let validated: ProtocolMessage; + try { + validated = validateResponse(type, normalizeMtpMessage(message)); + } catch (error) { + log(1, "mtp", "red", "Failed to validate push message", error, { + type, + data: message.data, + }); + return; + } setFreshContacts((contacts) => removeMissingContacts(contacts, validated), @@ -408,71 +654,45 @@ function BrowserProvider(props: { if (validated.type === "GetStates") { lastInitialStateRef.current = validated; } + if ( + validated.type === "ErrorNoIota" && + clientRef.current === activeClient && + !failed + ) { + failed = true; + const error = new Error("No Iota is currently connected"); + attemptAbort.abort(error); + cleanup(); + scheduleReconnect(error); + } }); } - setReadyState(activeClient.state); - - clearReconnectTimer(); - - // Schedule reconnect reset - clearReconnectResetTimer(); - reconnectResetTimer = setTimeout(() => { - attempts = 0; - reconnectResetTimer = null; - }, RECONNECT_RESET * 1_000); - setReadyState(activeClient.state); setIdentifying(true); - const stateSync = new Promise>( - (resolve, reject) => { - let unsubscribeStateSync = () => {}; - let unsubscribeNoIota = () => {}; - const cleanupStateSync = () => { - clearTimeout(timeout); - unsubscribeStateSync(); - unsubscribeNoIota(); - }; - const timeout = setTimeout(() => { - cleanupStateSync(); - reject(new Error("Initial state synchronization timed out")); - }, 120_000); - unsubscribeStateSync = activeClient.subscribe( - "ClientStateSync", - (message) => { - cleanupStateSync(); - resolve(message); - }, - ); - unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => { - cleanupStateSync(); - reject(new Error("No Iota is currently connected")); - }); - }, + const ids = requestIdsRef.current; + if (!ids) throw new Error("MTP request allocator is unavailable"); + const finalResponse = await completeInitialSynchronization( + activeClient, + ids, + attemptAbort.signal, ); - const [, finalResponse] = await Promise.all([ - activeClient.auth(), - stateSync, - ]); - - if (finalResponse.type.startsWith("Error")) { - throw new Error( - `State synchronization failed: ${finalResponse.type}`, - ); - } - - await activeClient.request("ClientStateAck", { - SessionId: finalResponse.data.SessionId, - VersionNumber: finalResponse.data.VersionNumber, - }); if (disposed || clientRef.current !== activeClient) return; setFreshContacts(finalResponse.data.Contacts); setFreshCommunities(finalResponse.data.Communities); setFreshCalls(finalResponse.data.Calls); + connectionReady = true; setIdentifying(false); setIdentified(true); + + clearReconnectTimer(); + clearReconnectResetTimer(); + reconnectResetTimer = setTimeout(() => { + attempts = 0; + reconnectResetTimer = null; + }, RECONNECT_RESET * 1_000); resolveConnectionRef.current?.(); } catch (connectError) { if (disposed || generation !== connectionGeneration) { @@ -490,7 +710,7 @@ function BrowserProvider(props: { "mtp", "red", `Connection/authentication attempt failed: ${connectErrorMessage}`, - connectError, + getProtocolErrorDetails(connectError) ?? connectError, ); scheduleReconnect(connectError); @@ -506,6 +726,7 @@ function BrowserProvider(props: { clientRef.current?.disconnect(); clientRef.current = null; + requestIdsRef.current = null; setReadyState(ConnectionState.Disconnected); setIdentified(false); setIdentifying(false); @@ -567,9 +788,9 @@ function BrowserProvider(props: { }, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]); const sendQueued: BoundSendFn = useMemo( - () => async (type, data, options) => { + () => async (type, data) => { const mtp = await mtpRef.get(); - const response = await mtp.send(type, data, options); + const response = await mtp.send(type, data); for (const interceptor of interceptorsRef.current) { void Promise.resolve( interceptor({ type, data, response: response as ProtocolMessage }), @@ -611,39 +832,6 @@ type NativeSnapshot = { error?: string; }; -class TauriMTPAdapter implements MTPProxyAdapter { - readonly #subscriptions = new Map void>>(); - - async request( - type: string, - data: Record, - options?: MTPRequestOptions, - ) { - return await invoke("mtp_request", { - typeName: type, - data, - id: options?.id, - }); - } - - subscribe(type: string, handler: (message: MTPFrame) => void) { - const handlers = - this.#subscriptions.get(type) ?? new Set<(message: MTPFrame) => void>(); - handlers.add(handler); - this.#subscriptions.set(type, handlers); - return () => { - handlers.delete(handler); - if (handlers.size === 0) this.#subscriptions.delete(type); - }; - } - - dispatch(message: MTPFrame) { - for (const handler of this.#subscriptions.get(message.type) ?? []) { - handler(message); - } - } -} - function TauriProvider(props: { children: ReactNode; blockConnection?: boolean; @@ -664,16 +852,8 @@ function TauriProvider(props: { pushHandlersRef, subscribePush, } = useMessageHandlers(); - const [adapter] = useState(() => new TauriMTPAdapter()); - const [connection] = useState( - () => - new MTPProxyConnection(adapter, { - schemas, - throwProtocolErrors: true, - onValidationError: (error) => { - log(1, "mtp", "red", "Failed to validate native MTP message", error); - }, - }), + const subscriptionsRef = useRef( + new Map void>>(), ); const applySnapshot = useCallback((next: NativeSnapshot) => { @@ -682,49 +862,69 @@ function TauriProvider(props: { if (next.error) { log(0, "android", "orange", "MTP connection failed", next.error); } - setSnapshot(next); - if (!next.identified || next.state === undefined) return; + if (!next.identified) { + setSnapshot(next); + return; + } + if (next.state === undefined) { + setSnapshot({ + ...next, + identified: false, + error: "Native MTP connection omitted initial state", + }); + return; + } const parsed = schemas.ClientStateSync.response.safeParse(next.state); if (!parsed.success) { log(0, "mtp", "red", "Invalid native MTP state", parsed.error); + setSnapshot({ + ...next, + identified: false, + error: "Invalid ClientStateSync payload", + }); return; } setFreshContacts(parsed.data.Contacts); setFreshCommunities(parsed.data.Communities); setFreshCalls(parsed.data.Calls); + setSnapshot(next); }, []); const dispatchMessage = useCallback( (raw: unknown) => { if (!raw || typeof raw !== "object" || !("type" in raw)) return; - adapter.dispatch(raw as MTPFrame); - }, - [adapter], - ); - - useEffect(() => { - const unsubscribers = PUSH_TYPES.map((type) => - connection.subscribe(type, (message) => { - const validated = message as ProtocolMessage; - setFreshContacts((contacts) => - removeMissingContacts(contacts, validated), + const message = raw as { id?: number; type: string; data: unknown }; + let validated: ProtocolMessage; + try { + validated = validateResponse( + message.type as keyof Schemas & string, + normalizeMtpMessage(message), ); - for (const handler of [...pushHandlersRef.current]) { - void Promise.resolve(handler(validated)).catch((error) => { - log(1, "mtp", "red", "Native MTP push handler failed", error, { - type, - }); + } catch (error) { + log(1, "mtp", "red", "Failed to validate native MTP message", error); + return; + } + for (const handler of subscriptionsRef.current.get(validated.type) ?? + []) { + handler(validated); + } + if (!isPushType(validated.type)) return; + setFreshContacts((contacts) => + removeMissingContacts(contacts, validated), + ); + for (const handler of [...pushHandlersRef.current]) { + void Promise.resolve(handler(validated)).catch((error) => { + log(1, "mtp", "red", "Native MTP push handler failed", error, { + type: validated.type, }); - } - if (validated.type === "GetStates") { - lastInitialStateRef.current = validated; - } - }), - ); - return () => { - for (const unsubscribe of unsubscribers) unsubscribe(); - }; - }, [connection, lastInitialStateRef, pushHandlersRef]); + }); + } + if (validated.type === "GetStates") { + lastInitialStateRef.current = validated; + } + }, + [lastInitialStateRef, pushHandlersRef], + ); useEffect(() => { if (props.blockConnection) return; @@ -800,11 +1000,26 @@ function TauriProvider(props: { }, [props.blockConnection]); const send = useCallback( - async (type, data, options) => { - const validated = await connection.request(type, data, options); + async (type, data) => { + const response = await invoke("mtp_request", { + typeName: type, + data: data ?? {}, + }); + const validated = validateResponse(type, normalizeMtpMessage(response)); setFreshContacts((contacts) => removeMissingContacts(contacts, validated), ); + if (validated.type.startsWith("Error")) { + const errorData = validated.data as Record; + throw new ProtocolError({ + type: validated.type, + requestId: validated.id, + errorType: + typeof errorData.ErrorType === "string" + ? errorData.ErrorType + : undefined, + }); + } for (const interceptor of interceptorsRef.current) { void Promise.resolve( interceptor({ type, data, response: validated as ProtocolMessage }), @@ -814,13 +1029,20 @@ function TauriProvider(props: { } return validated; }, - [connection, interceptorsRef], + [interceptorsRef], ); - const subscribe = useCallback( - (type, handler) => connection.subscribe(type, handler), - [connection], - ); + const subscribe = useCallback((type, handler) => { + const handlers = + subscriptionsRef.current.get(type) ?? + new Set<(message: ProtocolMessage) => void>(); + handlers.add(handler as (message: ProtocolMessage) => void); + subscriptionsRef.current.set(type, handlers); + return () => { + handlers.delete(handler as (message: ProtocolMessage) => void); + if (handlers.size === 0) subscriptionsRef.current.delete(type); + }; + }, []); const connected = snapshot.readyState === ConnectionState.Connected; const contextReady = connected && snapshot.identified; @@ -847,7 +1069,7 @@ function TauriProvider(props: { ); } -function BrowserProviderLoader(props: { +export function Provider(props: { children: ReactNode; blockConnection?: boolean; }) { @@ -872,17 +1094,10 @@ function BrowserProviderLoader(props: { if (wasmError) throw wasmError; if (!wasmReady) return null; - return ; -} - -export function Provider(props: { - children: ReactNode; - blockConnection?: boolean; -}) { return isTauri() ? ( ) : ( - + ); } diff --git a/packages/mtp/src/index.ts b/packages/mtp/src/index.ts index 39bce46..19f5acd 100644 --- a/packages/mtp/src/index.ts +++ b/packages/mtp/src/index.ts @@ -1,4 +1,5 @@ export { Provider, useMTP } from "./context"; +export { RequestIdAllocator } from "./requestIds"; export type { BoundSendFn, MTPExchange, diff --git a/packages/mtp/src/protocolFields.test.ts b/packages/mtp/src/protocolFields.test.ts new file mode 100644 index 0000000..ef0c18f --- /dev/null +++ b/packages/mtp/src/protocolFields.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { fromWireData, toWireData } from "./protocolFields"; + +describe("MTP protocol field translation", () => { + it("maps application message fields to MTP wire fields", () => { + expect( + toWireData({ + Messages: [{ Content: "abc", MessageId: 4 }], + }), + ).toEqual({ + Messages: [{ AppContent: "abc", AppMessageId: 4 }], + }); + }); + + it("maps MTP wire fields back to application fields", () => { + expect( + fromWireData({ + AppCreatedAt: 123, + Message: { AppContent: "abc", AppMessageId: 4 }, + }), + ).toEqual({ + CreatedAt: 123, + Message: { Content: "abc", MessageId: 4 }, + }); + }); + + it("preserves byte arrays and unrelated fields", () => { + const bytes = new Uint8Array([1, 2, 3]); + const translated = toWireData({ Payload: bytes, Other: "value" }) as { + Payload: Uint8Array; + Other: string; + }; + + expect(translated.Payload).toBe(bytes); + expect(translated.Other).toBe("value"); + }); + + it("rejects field mapping collisions", () => { + expect(() => toWireData({ Content: "a", AppContent: "b" })).toThrow( + "MTP field translation collision", + ); + }); +}); diff --git a/packages/mtp/src/protocolFields.ts b/packages/mtp/src/protocolFields.ts new file mode 100644 index 0000000..3ec3d5d --- /dev/null +++ b/packages/mtp/src/protocolFields.ts @@ -0,0 +1,58 @@ +const APPLICATION_TO_WIRE_FIELDS = { + Content: "AppContent", + CreatedAt: "AppCreatedAt", + MessageId: "AppMessageId", +} as const; + +const WIRE_TO_APPLICATION_FIELDS = { + AppContent: "Content", + AppCreatedAt: "CreatedAt", + AppMessageId: "MessageId", +} as const; + +function mapProtocolFields( + value: unknown, + fieldMap: Readonly>, +): unknown { + if ( + value === null || + typeof value !== "object" || + value instanceof Uint8Array + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => mapProtocolFields(item, fieldMap)); + } + + const source = value as Record; + const target: Record = {}; + + for (const [key, child] of Object.entries(source)) { + const mappedKey = fieldMap[key] ?? key; + + if (mappedKey in target) { + throw new Error(`MTP field translation collision for ${mappedKey}`); + } + + target[mappedKey] = mapProtocolFields(child, fieldMap); + } + + return target; +} + +export function toWireData(value: unknown): unknown { + return mapProtocolFields(value, APPLICATION_TO_WIRE_FIELDS); +} + +export function fromWireData(value: unknown): unknown { + return mapProtocolFields(value, WIRE_TO_APPLICATION_FIELDS); +} + +export function fromWireMessage(message: T): T { + return { + ...message, + data: fromWireData(message.data), + }; +} diff --git a/packages/mtp/src/requestIds.test.ts b/packages/mtp/src/requestIds.test.ts new file mode 100644 index 0000000..82fd508 --- /dev/null +++ b/packages/mtp/src/requestIds.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { RequestIdAllocator } from "./requestIds"; + +describe("MTP request ID allocation", () => { + it("allocates nonzero request IDs monotonically", () => { + const ids = new RequestIdAllocator(); + + expect(ids.allocate()).toBe(1); + expect(ids.allocate()).toBe(2); + expect(new RequestIdAllocator().allocate()).toBe(1); + }); + + it("does not wrap exhausted request IDs", () => { + const ids = new RequestIdAllocator(0x1_0000_0000); + + expect(() => ids.allocate()).toThrow("MTP request ID space exhausted"); + }); + + it("rejects invalid allocator states", () => { + expect(() => new RequestIdAllocator(0)).toThrow( + "invalid MTP request ID allocator state", + ); + }); +}); diff --git a/packages/mtp/src/requestIds.ts b/packages/mtp/src/requestIds.ts new file mode 100644 index 0000000..5581e8a --- /dev/null +++ b/packages/mtp/src/requestIds.ts @@ -0,0 +1,24 @@ +const MAX_MTP_REQUEST_ID = 0xffff_ffff; + +export class RequestIdAllocator { + #next: number; + + constructor(next = 1) { + if ( + !Number.isSafeInteger(next) || + next <= 0 || + next > MAX_MTP_REQUEST_ID + 1 + ) { + throw new RangeError("invalid MTP request ID allocator state"); + } + this.#next = next; + } + + allocate(): number { + if (this.#next > MAX_MTP_REQUEST_ID) { + throw new Error("MTP request ID space exhausted for this connection"); + } + + return this.#next++; + } +} diff --git a/packages/mtp/src/values.ts b/packages/mtp/src/values.ts index d9f6cd4..c5ba321 100644 --- a/packages/mtp/src/values.ts +++ b/packages/mtp/src/values.ts @@ -1,3 +1,8 @@ export const RETRY_INTERVAL = 3_000; export const RECONNECT_TRIES = 3; export const RECONNECT_RESET = 6; +export const RECONNECT_LONG_INTERVAL = 60_000; +export const RECONNECT_JITTER = 0.2; +export const DISCOVERY_TIMEOUT = 20_000; +export const INITIAL_SYNC_TIMEOUT = 120_000; +export const STATE_ACK_TIMEOUT = 30_000; diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 1835dc1..7a8b32a 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -87,15 +87,9 @@ export default function Provider(props: { children: React.ReactNode }) { moveUserIdToTop(data.SenderId); if (await load("settings.receive_confirmations")) { - void send( - "MessageState", - { - MessageState: "received", - }, - { - id: data.Message.SendTime, - }, - ); + void send("MessageState", { + MessageState: "received", + }); } } diff --git a/packages/settings/src/layout.tsx b/packages/settings/src/layout.tsx index 53759cd..5d49f0b 100644 --- a/packages/settings/src/layout.tsx +++ b/packages/settings/src/layout.tsx @@ -1,8 +1,8 @@ -import { Outlet, useLocation, useNavigate } from "@tanstack/react-router"; -import { Button, ClearStorageButton, cn, useIsMobile } from "@methanium/ui"; +import { Outlet, useLocation } from "@tanstack/react-router"; +import { Button, useIsMobile } from "@methanium/ui"; import { ArrowLeft } from "lucide-react"; -import { settingsNavigation } from "./manifest"; +import { SettingsSidebar } from "./sidebar"; export default function SettingsLayout() { const isMobile = useIsMobile(); @@ -36,47 +36,3 @@ export default function SettingsLayout() {
    ); } - -export function SettingsSidebar({ - mobile = false, - className, -}: { - mobile?: boolean; - className?: string; -}) { - const navigate = useNavigate(); - const categories = [ - ...new Set(settingsNavigation.map((page) => page.category)), - ]; - - return ( -
    - {categories.map((category) => ( -
    -

    {category}

    - {settingsNavigation - .filter((page) => page.category === category) - .map((page) => ( - - ))} -
    - ))} -
    - -
    -
    - ); -} diff --git a/packages/settings/src/manifest.ts b/packages/settings/src/manifest.ts index 41fe14a..1afdd5a 100644 --- a/packages/settings/src/manifest.ts +++ b/packages/settings/src/manifest.ts @@ -8,46 +8,24 @@ import Profile from "./pages/profile"; import Security from "./pages/security"; import Theme from "./pages/theme"; import Hotkeys from "./pages/hotkeys"; +import { settingsNavigation } from "./navigation"; + +const pageComponents = { + profile: Profile, + security: Security, + chat: Chat, + call: Call, + cache: Cache, + theme: Theme, + accessibility: Accessibility, + hotkeys: Hotkeys, + licenses: Licenses, +} as const; export const settingsPages = [ { path: "/", component: Index }, - { - category: "account", - path: "profile", - label: "Profile", - component: Profile, - }, - { - category: "account", - path: "security", - label: "Security", - component: Security, - }, - { category: "general", path: "chat", label: "Chat", component: Chat }, - { category: "general", path: "call", label: "Call", component: Call }, - { category: "application", path: "cache", label: "Cache", component: Cache }, - { category: "application", path: "theme", label: "Theme", component: Theme }, - { - category: "application", - path: "accessibility", - label: "Accessibility", - component: Accessibility, - }, - { - category: "application", - path: "hotkeys", - label: "Hotkeys", - component: Hotkeys, - }, - { - category: "application", - path: "licenses", - label: "Licenses", - component: Licenses, - }, + ...settingsNavigation.map((page) => ({ + ...page, + component: pageComponents[page.path], + })), ] as const; - -export const settingsNavigation = settingsPages.filter( - (page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> => - page.path !== "/", -); diff --git a/packages/settings/src/navigation.ts b/packages/settings/src/navigation.ts new file mode 100644 index 0000000..b47fae9 --- /dev/null +++ b/packages/settings/src/navigation.ts @@ -0,0 +1,31 @@ +export const settingsNavigation = [ + { + category: "account", + path: "profile", + label: "Profile", + }, + { + category: "account", + path: "security", + label: "Security", + }, + { category: "general", path: "chat", label: "Chat" }, + { category: "general", path: "call", label: "Call" }, + { category: "application", path: "cache", label: "Cache" }, + { category: "application", path: "theme", label: "Theme" }, + { + category: "application", + path: "accessibility", + label: "Accessibility", + }, + { + category: "application", + path: "hotkeys", + label: "Hotkeys", + }, + { + category: "application", + path: "licenses", + label: "Licenses", + }, +] as const; diff --git a/packages/settings/src/pages/index.tsx b/packages/settings/src/pages/index.tsx index 803a0c1..b5d04e3 100644 --- a/packages/settings/src/pages/index.tsx +++ b/packages/settings/src/pages/index.tsx @@ -1,4 +1,4 @@ -import { SettingsSidebar } from "../layout"; +import { SettingsSidebar } from "../sidebar"; export default function Page() { return ( diff --git a/packages/settings/src/sidebar.tsx b/packages/settings/src/sidebar.tsx new file mode 100644 index 0000000..3a2155e --- /dev/null +++ b/packages/settings/src/sidebar.tsx @@ -0,0 +1,48 @@ +import { Button, ClearStorageButton, cn } from "@methanium/ui"; +import { useNavigate } from "@tanstack/react-router"; + +import { settingsNavigation } from "./navigation"; + +export function SettingsSidebar({ + mobile = false, + className, +}: { + mobile?: boolean; + className?: string; +}) { + const navigate = useNavigate(); + const categories = [ + ...new Set(settingsNavigation.map((page) => page.category)), + ]; + + return ( +
    + {categories.map((category) => ( +
    +

    {category}

    + {settingsNavigation + .filter((page) => page.category === category) + .map((page) => ( + + ))} +
    + ))} +
    + +
    +
    + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721102f..107149b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: '@methanium/ui': https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz - mtp: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz + mtp: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz importers: @@ -16,17 +16,17 @@ importers: specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz version: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(emojibase@17.0.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@7.2.0)(typescript@6.0.3) mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz sonner: - specifier: ^2.0.7 - version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + specifier: ^2.0.8 + version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)) '@types/node': - specifier: ^26.1.2 + specifier: ^26.2.0 version: 26.2.0 '@types/react': specifier: ^19.2.18 @@ -35,20 +35,20 @@ importers: specifier: ^19.2.4 version: 19.2.4(@types/react@19.2.18) '@typescript-eslint/parser': - specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) eslint: - specifier: ^10.8.0 + specifier: ^10.8.1 version: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) fallow: - specifier: ^3.14.0 - version: 3.14.0 + specifier: ^3.17.0 + version: 3.17.0 globals: - specifier: ^17.9.0 - version: 17.9.0 + specifier: ^17.11.0 + version: 17.11.0 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -56,11 +56,11 @@ importers: specifier: ^6.0.3 version: 6.0.3 typescript-eslint: - specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + specifier: ^8.67.0 + version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) apps/electron: devDependencies: @@ -98,8 +98,8 @@ importers: specifier: workspace:* version: link:../../packages/storage mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz react: specifier: ^19.2.8 version: 19.2.8 @@ -289,6 +289,9 @@ importers: globals: specifier: ^17.9.0 version: 17.9.0 + mtp: + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz typescript: specifier: ~6.0.3 version: 6.0.3 @@ -352,6 +355,9 @@ importers: lucide-react: specifier: ^1.29.0 version: 1.30.0(react@19.2.8) + mtp: + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz react: specifier: ^19.2.8 version: 19.2.8 @@ -424,6 +430,9 @@ importers: packages/crypto: dependencies: + mtp: + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz react: specifier: ^19.2.8 version: 19.2.8 @@ -450,6 +459,45 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + packages/markdown: + dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.3 + version: 6.20.3 + '@codemirror/commands': + specifier: ^6.10.4 + version: 6.11.0 + '@codemirror/lang-markdown': + specifier: ^6.5.2 + version: 6.5.2 + '@codemirror/language': + specifier: ^6.12.4 + version: 6.12.4 + '@codemirror/state': + specifier: ^6.7.1 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.43.8 + version: 6.43.9 + '@methanium/ui': + specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz + version: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(emojibase@17.0.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@7.2.0)(typescript@6.0.3) + '@twemoji/api': + specifier: ^17.0.3 + version: 17.0.3 + emojibase-data: + specifier: ^17.0.0 + version: 17.0.0(emojibase@17.0.0) + lucide-react: + specifier: ^1.30.0 + version: 1.32.0(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + packages/mtp: dependencies: '@methanium/ui': @@ -458,6 +506,9 @@ importers: '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 + '@tensamin/crypto': + specifier: workspace:* + version: link:../crypto '@tensamin/shared': specifier: workspace:* version: link:../shared @@ -465,11 +516,17 @@ importers: specifier: workspace:* version: link:../storage mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz + specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz + version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz react: specifier: ^19.2.8 version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: eslint: specifier: ^10.8.0 @@ -1255,8 +1312,8 @@ packages: '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} - '@codemirror/commands@6.10.4': - resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + '@codemirror/commands@6.11.0': + resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==} '@codemirror/lang-css@6.3.1': resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} @@ -1279,8 +1336,8 @@ packages: '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} - '@codemirror/view@6.43.8': - resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + '@codemirror/view@6.43.9': + resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==} '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} @@ -1528,43 +1585,43 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@fallow-cli/darwin-arm64@3.14.0': - resolution: {integrity: sha512-seaix3OqADcq90PkPOOPrN8Jl7QZDGjFziHA85Ikp+lGSHVjJ2IMyGh/QKLhvi9Q6Ha3Y//F0+PFFGNd0Gi4mw==} + '@fallow-cli/darwin-arm64@3.17.0': + resolution: {integrity: sha512-u9O4aQaTSdaZaa4MAVEZEiwOx6QQzChymyQPrxV6KvDcVbcl0oYSekJKdvN4gEqUY8ClMze9gWS1tbn/W3N4dQ==} cpu: [arm64] os: [darwin] - '@fallow-cli/darwin-x64@3.14.0': - resolution: {integrity: sha512-qgYZHHTFobuJCtON7aVe/tXCUjiF/9nKT4tNLVfUPH4Qe9KwieO4rAuknDOnxkjzsWPO4MfHyCbcVdslf4JMnw==} + '@fallow-cli/darwin-x64@3.17.0': + resolution: {integrity: sha512-l3tZJovnCa8j+im4qTl4GVSfrx4gisJYeHDwb5TbcdTGaWYz42gHriAaz5MhOR8S24q3xRegX8QrI8PA4XXU4g==} cpu: [x64] os: [darwin] - '@fallow-cli/linux-arm64-gnu@3.14.0': - resolution: {integrity: sha512-FwmIG391lMViBXh1cAvOcwQxbI2sLjEYkugFauVbmntjuJt6xVxQXAxjybdFXSL/jjfFnbAzPKy9oHlhq3sA6A==} + '@fallow-cli/linux-arm64-gnu@3.17.0': + resolution: {integrity: sha512-IAjS6qR4tHcRo1y5lzYT6mktN/ATPyPNrJ6yjwTORwwQV1YfMO58A5zGBfJlPBY+lUmaMZ9w37ftvH9a2AS4dQ==} cpu: [arm64] os: [linux] - '@fallow-cli/linux-arm64-musl@3.14.0': - resolution: {integrity: sha512-x4R9aNT2kIoSHRIZ5s2MZ+swyb3uutV6SSYCwWhPSE5UI/oCEYWNWiYS7uKMLM0cTuMPVcsm2qMTd4iYA3ACLw==} + '@fallow-cli/linux-arm64-musl@3.17.0': + resolution: {integrity: sha512-Oe1dvk5Wt5Bmnxfc6HufJTaFY9SEwDVrZpnSdVQlv+nvz7DzrTK1r91cR3Ip477ANSLa9XD/MHPMpfqqo0dlZg==} cpu: [arm64] os: [linux] - '@fallow-cli/linux-x64-gnu@3.14.0': - resolution: {integrity: sha512-8kBxy9FkwKjiH1C19T0nSkmbtldwVyWLQWajIqvyHE0x4rurHhve7RQrwEDPzNMFu7OgYCMU1nXr/AMIJ6Kg9w==} + '@fallow-cli/linux-x64-gnu@3.17.0': + resolution: {integrity: sha512-XgLU2a51zuky7gifjVDHuiJGToAReDsVCDcyDF84TQz/h3bqO0iRkpDL8dh7+HODku84+EffpK5ZHY19n6Ij0w==} cpu: [x64] os: [linux] - '@fallow-cli/linux-x64-musl@3.14.0': - resolution: {integrity: sha512-9TAdXSpYvk9WkKy2GR8T/PHbq7QCTO/SYUmiZ+EAJUMzXBvSfnpj8cCGQpeAD000Nb+o7rL94lIAPJMAqMKO3Q==} + '@fallow-cli/linux-x64-musl@3.17.0': + resolution: {integrity: sha512-GnDZx2kbLKKdYAp05NSKv6CZHgzEKvhRd0btCKFMMpUEhdzmr4uh2Qb4UzKDBg/q2r1EhkzN4b/qC+tbFCmtwA==} cpu: [x64] os: [linux] - '@fallow-cli/win32-arm64-msvc@3.14.0': - resolution: {integrity: sha512-xc495cracyJL+0UKwcqNhofIz2K5XiJbb+YFhxrbSrrbm+EVDnQKpCOzzOJYbm7RPxF6XOxJpATn7ciBy7Rqrw==} + '@fallow-cli/win32-arm64-msvc@3.17.0': + resolution: {integrity: sha512-u9n5WzPuKuC1PKegKQknWtngAUJ2szQaRXsbY54s7UMqeFNyC+W04LV5D+sEZywU7L0kzL+Kck9LxFLXBQcgvA==} cpu: [arm64] os: [win32] - '@fallow-cli/win32-x64-msvc@3.14.0': - resolution: {integrity: sha512-ihyCZ5GMoYGnzlPP6b5pZBBcGZhDHDW2u1YEGkZEuf3pbW6E45U7Hhf213cE2CCheScXOirK6AKWnLM/Qj4c2A==} + '@fallow-cli/win32-x64-msvc@3.17.0': + resolution: {integrity: sha512-/OcyxHuCKabzQ9hKIjM/CFJ9fr30n0Mw5MK6nzi5rn26mbYUgh073UZq4FONOpPJ/FqHtC726rxpgBtR4qS+gg==} cpu: [x64] os: [win32] @@ -1589,8 +1646,8 @@ packages: '@fontsource-variable/public-sans@5.3.0': resolution: {integrity: sha512-AVfkmAt50BMXWpOO21FAntiJFKGX6xTc2dSL8dxtDteONe9IuRXJWGbs0EbG955vAMCq23ENeuopuW87cGWDSQ==} - '@hono/node-server@2.1.0': - resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -2217,32 +2274,32 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@shikijs/core@4.4.2': - resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==} + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.4.2': - resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==} + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.4.2': - resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==} + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} - '@shikijs/langs@4.4.2': - resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==} + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} - '@shikijs/primitive@4.4.2': - resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==} + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} engines: {node: '>=20'} - '@shikijs/themes@4.4.2': - resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==} + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} - '@shikijs/types@4.4.2': - resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==} + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': @@ -2522,6 +2579,12 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@twemoji/api@17.0.3': + resolution: {integrity: sha512-iwERjxY0QgPGVwT6b1OKG0Oa9nIfHhJw+Ij1TapTBMKTvVCU6qdXPXX/XKwxKx5QZIJW5GwELUCtw8wlaIQ2ug==} + + '@twemoji/parser@17.0.2': + resolution: {integrity: sha512-X/P7pHsGOxnrupQYUVetIeuxBGgffFu8CLwoPMMjH9CWmQvlXiCpbTW/BXxMOCWXQojgHdmgdvm6IsCqAQ5nxA==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -2643,6 +2706,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.67.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.66.0': resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2650,22 +2721,45 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.66.0': resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.66.0': resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.66.0': resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.66.0': resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2673,16 +2767,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.66.0': resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.66.0': resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.66.0': resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2690,10 +2801,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.66.0': resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -2830,11 +2952,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2844,20 +2966,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} @@ -2916,8 +3038,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -3015,8 +3137,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.12: - resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} + baseline-browser-mapping@2.11.15: + resolution: {integrity: sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==} engines: {node: '>=6.0.0'} hasBin: true @@ -3045,8 +3167,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.7: - resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3233,8 +3355,8 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} convert-source-map@2.0.0: @@ -3397,8 +3519,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} defer-to-connect@2.0.1: @@ -3498,8 +3620,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.402: - resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} + electron-to-chromium@1.5.411: + resolution: {integrity: sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==} electron-winstaller@5.4.0: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} @@ -3587,8 +3709,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -3705,8 +3827,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -3741,13 +3863,13 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fallow-type-aware@3.14.0: - resolution: {integrity: sha512-D8+bNELjmoVV2dKpmHplQ1YzuheHltmZKaOVJ//hx7h8vFNyp5H3hcJV8rR26ofBU51z9K5eyA7FpgP3hebjwg==} + fallow-type-aware@3.17.0: + resolution: {integrity: sha512-DFtZUK5oqP56tSsrBWPzjrTIwPPxwlBV8QoSaGghMru347Nqj8ZBY6u9HFa/TIPcxvFU/zIZMRF8xS/XAWQTzg==} engines: {node: '>=20'} hasBin: true - fallow@3.14.0: - resolution: {integrity: sha512-hse+sChmWkrffLiZDPGdNSyiLybVEhgoiYCIYgqLwdYhfTCO+f2PS1g+gMhGAJdLlYpZ75tFwRoMBFmZjN3Q6g==} + fallow@3.17.0: + resolution: {integrity: sha512-tbLuvsPq3I2CzpoF/9MgSQ7hWzOqlj3avFUAmCr1J9OERE6z714SlDmA38NVvNy/zuK/70utXB3/b33xC9z29g==} engines: {node: '>=22'} hasBin: true @@ -3965,6 +4087,10 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} + engines: {node: '>=18'} + globals@17.9.0: resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} engines: {node: '>=18'} @@ -4056,8 +4182,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.13.1: - resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} + hono@4.13.3: + resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -4133,8 +4259,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - input-otp@1.4.2: - resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + input-otp@1.5.0: + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc @@ -4147,8 +4273,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.4.0: - resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -4400,6 +4526,9 @@ packages: jose@6.2.8: resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4444,6 +4573,9 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@5.0.0: + resolution: {integrity: sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -4686,8 +4818,8 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.31.0: - resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==} + lucide-react@1.32.0: + resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4953,9 +5085,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mtp@https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz: - resolution: {integrity: sha512-WcXHd53aDM0zQfO8eaUnK5tpnRP73GlWjwAMMcstcVAIWrvyFGuINkCRNEkb2RsNk9h57UMhxElCZeBkFrVmCg==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz} - version: 0.2.0 + mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz: + resolution: {integrity: sha512-rI+xskgAp93o9EdBO/b7HnA0PZTQQPTYuF4Snon/hTvayN+x9O40oad4n/ZUF0ahS6grw00MPKPe89Mfe3BHnw==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz} + version: 0.3.0 nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} @@ -5056,8 +5188,8 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - open@11.0.0: - resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + open@11.0.1: + resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} engines: {node: '>=20'} open@8.4.2: @@ -5213,6 +5345,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + powershell-utils@0.2.0: + resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5342,8 +5478,8 @@ packages: '@types/react': optional: true - react-resizable-panels@4.12.2: - resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==} + react-resizable-panels@4.12.3: + resolution: {integrity: sha512-GHMJWnDXui/3RX4bT+cgBP+N3N2nkwcoZATr/2xLFpqQQe7TlBrE0cGM0dUa9ceT7A90tUrSRsiqgRG+g0/2AA==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -5369,8 +5505,8 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - recast@0.23.19: - resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} recharts@3.10.1: @@ -5623,8 +5759,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.16.2: - resolution: {integrity: sha512-M1AvZKFWcCzWRDoyApIqJMSLIpY8Ev4uBGuiPLSFmiTbixXhPmzotSTvLzFmBrfoIxG9aIg2dZOETblEaXGUnQ==} + shadcn@4.18.0: + resolution: {integrity: sha512-tUFZgkYmfVNQVm3xX7lhSzOvDsp+O14ac5dwgXIr5mIsr79ISueb/Mu+ZtWMz0DH6v77u4eYyvbQ9TTMpSn3aw==} engines: {node: '>=20.18.1'} hasBin: true @@ -5636,8 +5772,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.4.2: - resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==} + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -5673,10 +5809,18 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smob@1.6.2: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -5970,6 +6114,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -6065,8 +6216,8 @@ packages: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} - update-browserslist-db@1.3.0: - resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -6192,20 +6343,20 @@ packages: yaml: optional: true - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6347,8 +6498,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} xmlbuilder@15.1.1: @@ -6484,7 +6635,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.7 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -7164,14 +7315,14 @@ snapshots: dependencies: '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 '@lezer/common': 1.5.2 - '@codemirror/commands@6.10.4': + '@codemirror/commands@6.11.0': dependencies: '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 '@lezer/common': 1.5.2 '@codemirror/lang-css@6.3.1': @@ -7189,7 +7340,7 @@ snapshots: '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 '@lezer/common': 1.5.2 '@lezer/css': 1.3.6 '@lezer/html': 1.3.13 @@ -7200,7 +7351,7 @@ snapshots: '@codemirror/language': 6.12.4 '@codemirror/lint': 6.9.7 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 '@lezer/common': 1.5.2 '@lezer/javascript': 1.5.4 @@ -7210,14 +7361,14 @@ snapshots: '@codemirror/lang-html': 6.4.12 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 '@lezer/common': 1.5.2 '@lezer/markdown': 1.7.2 '@codemirror/language@6.12.4': dependencies: '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -7226,14 +7377,14 @@ snapshots: '@codemirror/lint@6.9.7': dependencies: '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 + '@codemirror/view': 6.43.9 crelt: 1.0.7 '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.3 - '@codemirror/view@6.43.8': + '@codemirror/view@6.43.9': dependencies: '@codemirror/state': 6.7.1 crelt: 1.0.7 @@ -7467,28 +7618,28 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@fallow-cli/darwin-arm64@3.14.0': + '@fallow-cli/darwin-arm64@3.17.0': optional: true - '@fallow-cli/darwin-x64@3.14.0': + '@fallow-cli/darwin-x64@3.17.0': optional: true - '@fallow-cli/linux-arm64-gnu@3.14.0': + '@fallow-cli/linux-arm64-gnu@3.17.0': optional: true - '@fallow-cli/linux-arm64-musl@3.14.0': + '@fallow-cli/linux-arm64-musl@3.17.0': optional: true - '@fallow-cli/linux-x64-gnu@3.14.0': + '@fallow-cli/linux-x64-gnu@3.17.0': optional: true - '@fallow-cli/linux-x64-musl@3.14.0': + '@fallow-cli/linux-x64-musl@3.17.0': optional: true - '@fallow-cli/win32-arm64-msvc@3.14.0': + '@fallow-cli/win32-arm64-msvc@3.17.0': optional: true - '@fallow-cli/win32-x64-msvc@3.14.0': + '@fallow-cli/win32-x64-msvc@3.17.0': optional: true '@floating-ui/core@1.8.0': @@ -7515,9 +7666,9 @@ snapshots: '@fontsource-variable/public-sans@5.3.0': {} - '@hono/node-server@2.1.0(hono@4.13.1)': + '@hono/node-server@2.1.1(hono@4.13.3)': dependencies: - hono: 4.13.1 + hono: 4.13.3 '@humanfs/core@0.19.2': dependencies: @@ -7643,12 +7794,12 @@ snapshots: dependencies: '@base-ui/react': 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@codemirror/autocomplete': 6.20.3 - '@codemirror/commands': 6.10.4 + '@codemirror/commands': 6.11.0 '@codemirror/lang-markdown': 6.5.2 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.8 - '@shikijs/langs': 4.4.2 + '@codemirror/view': 6.43.9 + '@shikijs/langs': 4.4.3 '@tauri-apps/api': 2.11.1 class-variance-authority: 0.7.1 clsx: 2.1.1 @@ -7656,14 +7807,14 @@ snapshots: embla-carousel-react: 8.6.0(react@19.2.8) emojibase-data: 17.0.0(emojibase@17.0.0) hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) - input-otp: 1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + input-otp: 1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) katex: 0.18.4 - lucide-react: 1.31.0(react@19.2.8) + lucide-react: 1.32.0(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-markdown: 10.1.0(@types/react@19.2.18)(react@19.2.8)(supports-color@7.2.0) - react-resizable-panels: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-resizable-panels: 4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) recharts: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1) rehype-autolink-headings: 7.1.0 rehype-katex: 7.0.1 @@ -7671,8 +7822,8 @@ snapshots: remark-breaks: 4.0.0 remark-gfm: 4.0.1(supports-color@7.2.0) remark-math: 6.0.0(supports-color@7.2.0) - shadcn: 4.16.2(supports-color@7.2.0)(typescript@6.0.3) - shiki: 4.4.2 + shadcn: 4.18.0(supports-color@7.2.0)(typescript@6.0.3) + shiki: 4.4.3 sonner: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tailwind-merge: 3.6.0 tw-animate-css: 1.4.0 @@ -7693,18 +7844,18 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76)': dependencies: - '@hono/node-server': 2.1.0(hono@4.13.1) + '@hono/node-server': 2.1.1(hono@4.13.3) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 express: 5.2.1(supports-color@7.2.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) - hono: 4.13.1 - jose: 6.2.8 + hono: 4.13.3 + jose: 6.2.9 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -8069,40 +8220,40 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@shikijs/core@4.4.2': + '@shikijs/core@4.4.3': dependencies: - '@shikijs/primitive': 4.4.2 - '@shikijs/types': 4.4.2 + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.4.2': + '@shikijs/engine-javascript@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.4.2': + '@shikijs/engine-oniguruma@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.4.2': + '@shikijs/langs@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 - '@shikijs/primitive@4.4.2': + '@shikijs/primitive@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/themes@4.4.2': + '@shikijs/themes@4.4.3': dependencies: - '@shikijs/types': 4.4.2 + '@shikijs/types': 4.4.3 - '@shikijs/types@4.4.2': + '@shikijs/types@4.4.3': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -8328,6 +8479,15 @@ snapshots: minimatch: 10.2.6 path-browserify: 1.0.1 + '@twemoji/api@17.0.3': + dependencies: + '@twemoji/parser': 17.0.2 + fs-extra: 8.1.0 + jsonfile: 5.0.0 + universalify: 0.1.2 + + '@twemoji/parser@17.0.2': {} + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -8456,6 +8616,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.66.0 @@ -8468,6 +8644,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.66.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) @@ -8477,15 +8665,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.67.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + debug: 4.4.3(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.66.0': dependencies: '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.66.0 @@ -8498,8 +8704,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.66.0(supports-color@7.2.0)(typescript@6.0.3) @@ -8515,6 +8735,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.67.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)) @@ -8526,11 +8761,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.66.0': dependencies: '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -8598,44 +8849,44 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -8682,7 +8933,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@4.3.0: dependencies: @@ -8819,14 +9070,14 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.12: {} + baseline-browser-mapping@2.11.15: {} bluebird@3.7.2: {} body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 - content-type: 2.0.0 + content-type: 2.1.0 debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.3 @@ -8857,13 +9108,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.7: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.11.12 + baseline-browser-mapping: 2.11.15 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.402 + electron-to-chromium: 1.5.411 node-releases: 2.0.53 - update-browserslist-db: 1.3.0(browserslist@4.28.7) + update-browserslist-db: 1.3.1(browserslist@4.28.8) buffer-from@1.1.2: {} @@ -9045,7 +9296,7 @@ snapshots: content-type@1.0.5: {} - content-type@2.0.0: {} + content-type@2.1.0: {} convert-source-map@2.0.0: {} @@ -9057,7 +9308,7 @@ snapshots: core-js-compat@3.50.0: dependencies: - browserslist: 4.28.7 + browserslist: 4.28.8 core-util-is@1.0.3: {} @@ -9180,7 +9431,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.0: + default-browser@5.5.1: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -9304,7 +9555,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.402: {} + electron-to-chromium@1.5.411: {} electron-winstaller@5.4.0(supports-color@7.2.0): dependencies: @@ -9444,7 +9695,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: dependencies: @@ -9603,11 +9854,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 execa@5.1.1: dependencies: @@ -9644,7 +9895,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@7.2.0) express: 5.2.1(supports-color@7.2.0) - ip-address: 10.4.0 + ip-address: 10.5.0 transitivePeerDependencies: - supports-color @@ -9683,24 +9934,24 @@ snapshots: extend@3.0.2: {} - fallow-type-aware@3.14.0: + fallow-type-aware@3.17.0: dependencies: typescript: 7.0.2 optional: true - fallow@3.14.0: + fallow@3.17.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - '@fallow-cli/darwin-arm64': 3.14.0 - '@fallow-cli/darwin-x64': 3.14.0 - '@fallow-cli/linux-arm64-gnu': 3.14.0 - '@fallow-cli/linux-arm64-musl': 3.14.0 - '@fallow-cli/linux-x64-gnu': 3.14.0 - '@fallow-cli/linux-x64-musl': 3.14.0 - '@fallow-cli/win32-arm64-msvc': 3.14.0 - '@fallow-cli/win32-x64-msvc': 3.14.0 - fallow-type-aware: 3.14.0 + '@fallow-cli/darwin-arm64': 3.17.0 + '@fallow-cli/darwin-x64': 3.17.0 + '@fallow-cli/linux-arm64-gnu': 3.17.0 + '@fallow-cli/linux-arm64-musl': 3.17.0 + '@fallow-cli/linux-x64-gnu': 3.17.0 + '@fallow-cli/linux-x64-musl': 3.17.0 + '@fallow-cli/win32-arm64-msvc': 3.17.0 + '@fallow-cli/win32-x64-msvc': 3.17.0 + fallow-type-aware: 3.17.0 fast-deep-equal@3.1.3: {} @@ -9946,6 +10197,8 @@ snapshots: serialize-error: 7.0.1 optional: true + globals@17.11.0: {} + globals@17.9.0: {} globalthis@1.0.4: @@ -10101,7 +10354,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.13.1: {} + hono@4.13.3: {} hosted-git-info@4.1.0: dependencies: @@ -10172,7 +10425,7 @@ snapshots: inline-style-parser@0.2.7: {} - input-otp@1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + input-otp@1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -10185,7 +10438,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.4.0: {} + ip-address@10.5.0: {} ipaddr.js@1.9.1: {} @@ -10390,6 +10643,8 @@ snapshots: jose@6.2.8: {} + jose@6.2.9: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -10421,6 +10676,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@5.0.0: + dependencies: + universalify: 0.1.2 + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -10607,7 +10868,7 @@ snapshots: dependencies: react: 19.2.8 - lucide-react@1.31.0(react@19.2.8): + lucide-react@1.32.0(react@19.2.8): dependencies: react: 19.2.8 @@ -11076,7 +11337,7 @@ snapshots: ms@2.1.3: {} - mtp@https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz: + mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz: dependencies: yaml: 2.9.0 @@ -11174,14 +11435,14 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - open@11.0.0: + open@11.0.1: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.1.0 - wsl-utils: 0.3.1 + powershell-utils: 0.2.0 + wsl-utils: 1.0.0 open@8.4.2: dependencies: @@ -11340,6 +11601,8 @@ snapshots: powershell-utils@0.1.0: {} + powershell-utils@0.2.0: {} + prelude-ls@1.2.1: {} prettier@3.9.6: {} @@ -11465,7 +11728,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-resizable-panels@4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-resizable-panels@4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -11496,7 +11759,7 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - recast@0.23.19: + recast@0.23.21: dependencies: ast-types: 0.16.1 esprima: 4.0.1 @@ -11885,7 +12148,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.16.2(supports-color@7.2.0)(typescript@6.0.3): + shadcn@4.18.0(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.8 @@ -11894,7 +12157,7 @@ snapshots: '@dotenvx/dotenvx': 1.75.1 '@modelcontextprotocol/sdk': 1.30.0(supports-color@7.2.0)(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.7 + browserslist: 4.28.8 commander: 14.0.3 cosmiconfig: 9.0.2(typescript@6.0.3) dedent: 1.7.2 @@ -11905,12 +12168,13 @@ snapshots: fs-extra: 11.4.0 fuzzysort: 3.1.0 kleur: 4.1.5 - open: 11.0.0 + open: 11.0.1 ora: 8.2.0 postcss: 8.5.26 postcss-selector-parser: 7.1.5 prompts: 2.4.2 - recast: 0.23.19 + recast: 0.23.21 + socks: 2.8.9 stringify-object: 5.0.0 tailwind-merge: 3.6.0 ts-morph: 26.0.0 @@ -11931,14 +12195,14 @@ snapshots: shebang-regex@3.0.0: {} - shiki@4.4.2: + shiki@4.4.3: dependencies: - '@shikijs/core': 4.4.2 - '@shikijs/engine-javascript': 4.4.2 - '@shikijs/engine-oniguruma': 4.4.2 - '@shikijs/langs': 4.4.2 - '@shikijs/themes': 4.4.2 - '@shikijs/types': 4.4.2 + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -11982,8 +12246,15 @@ snapshots: sisteransi@1.0.5: {} + smart-buffer@4.2.0: {} + smob@1.6.2: {} + socks@2.8.9: + dependencies: + ip-address: 10.5.0 + smart-buffer: 4.2.0 + sonner@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -12106,7 +12377,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-bom@3.0.0: {} @@ -12251,7 +12522,7 @@ snapshots: type-is@2.1.0: dependencies: - content-type: 2.0.0 + content-type: 2.1.0 media-typer: 1.1.1 mime-types: 3.0.2 @@ -12303,6 +12574,17 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-eslint@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + typescript@6.0.3: {} typescript@7.0.2: @@ -12420,9 +12702,9 @@ snapshots: upath@1.2.0: {} - update-browserslist-db@1.3.0(browserslist@4.28.7): + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: - browserslist: 4.28.7 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -12529,16 +12811,16 @@ snapshots: terser: 5.50.0 yaml: 2.9.0 - vitest@4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 @@ -12757,7 +13039,7 @@ snapshots: wrappy@1.0.2: {} - wsl-utils@0.3.1: + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ee3c57a..f713a2d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,4 +7,4 @@ allowBuilds: esbuild: true overrides: "@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz" - mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz" + mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz" diff --git a/utils/scripts/build-packages.ts b/utils/scripts/build-packages.ts index 9518c49..1f1f4fd 100644 --- a/utils/scripts/build-packages.ts +++ b/utils/scripts/build-packages.ts @@ -15,7 +15,14 @@ function getPackageDirs(dir: string): string[] { for (const entry of entries) { const fullPath = join(dir, entry); - if (!statSync(fullPath).isDirectory()) continue; + if (entry === "node_modules" || entry.startsWith(".")) continue; + let stats; + try { + stats = statSync(fullPath); + } catch { + continue; + } + if (!stats.isDirectory()) continue; if (existsSync(join(fullPath, "package.json"))) { dirs.push(fullPath); diff --git a/utils/scripts/lint-packages.ts b/utils/scripts/lint-packages.ts index 0aea8c3..c84e0e0 100644 --- a/utils/scripts/lint-packages.ts +++ b/utils/scripts/lint-packages.ts @@ -16,7 +16,14 @@ function getPackageDirs(dir: string): string[] { for (const entry of entries) { const fullPath = join(dir, entry); - if (!statSync(fullPath).isDirectory()) continue; + if (entry === "node_modules" || entry.startsWith(".")) continue; + let stats; + try { + stats = statSync(fullPath); + } catch { + continue; + } + if (!stats.isDirectory()) continue; if (existsSync(join(fullPath, "package.json"))) { dirs.push(fullPath); diff --git a/utils/scripts/update-packages.ts b/utils/scripts/update-packages.ts index 22b258f..90b3b0c 100644 --- a/utils/scripts/update-packages.ts +++ b/utils/scripts/update-packages.ts @@ -15,7 +15,14 @@ function getPackageDirs(dir: string): string[] { for (const entry of entries) { const fullPath = join(dir, entry); - if (!statSync(fullPath).isDirectory()) continue; + if (entry === "node_modules" || entry.startsWith(".")) continue; + let stats; + try { + stats = statSync(fullPath); + } catch { + continue; + } + if (!stats.isDirectory()) continue; if (existsSync(join(fullPath, "package.json"))) { dirs.push(fullPath);