diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 46adaad..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[env] -MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true } diff --git a/.fallowrc.json b/.fallowrc.json index ba50fb5..022e0cc 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -3,7 +3,6 @@ "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 2ae48fe..709c75b 100644 --- a/.forgejo/workflows/dependency-builds.yml +++ b/.forgejo/workflows/dependency-builds.yml @@ -4,6 +4,7 @@ on: pull_request: env: + NIX_CONFIG: experimental-features = nix-command flakes FORGEJO_TOKEN: "" GITHUB_TOKEN: "" @@ -13,6 +14,7 @@ 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 @@ -26,6 +28,7 @@ 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 @@ -34,23 +37,12 @@ jobs: - run: nix develop .#electron --command pnpm run build:packages - run: nix develop .#electron --command pnpm run build:desktop - native-mtp: + mobile: if: ${{ github.actor == 'rasensprenger' }} - name: Test native MTP + name: Build mobile 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 7f59ec1..0d5be50 100644 --- a/.forgejo/workflows/deploy-dev.yml +++ b/.forgejo/workflows/deploy-dev.yml @@ -1,15 +1,20 @@ on: - workflow_dispatch: push: branches: - dev paths-ignore: - flake.nix +env: + NIX_CONFIG: experimental-features = nix-command flakes + 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 @@ -34,6 +39,9 @@ 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 @@ -54,6 +62,8 @@ 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 @@ -118,6 +128,9 @@ 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 @@ -168,6 +181,9 @@ 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 621a0cd..d85ce27 100644 --- a/.forgejo/workflows/deploy-prod.yml +++ b/.forgejo/workflows/deploy-prod.yml @@ -6,10 +6,16 @@ on: paths-ignore: - flake.nix +env: + NIX_CONFIG: experimental-features = nix-command flakes + 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 @@ -34,6 +40,9 @@ 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 @@ -54,6 +63,8 @@ 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 @@ -118,6 +129,9 @@ 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 @@ -166,6 +180,9 @@ 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 @@ -311,3 +328,58 @@ jobs: "$API/repos/$REPO/releases/$release_id" done < "$DELETE_RELEASES" EOF + + - name: Update root flake release hash + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + nix develop .#electron --command bash <<'EOF' + set -eu + + DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)" + test -n "$DEB" + + HASH="$(node -e 'const fs = require("fs"); const crypto = require("crypto"); const file = process.argv[1]; console.log("sha256-" + crypto.createHash("sha256").update(fs.readFileSync(file)).digest("base64"));' "$DEB")" + export HASH + + node -e ' + const fs = require("fs"); + const version = process.env.TAG; + const hash = process.env.HASH; + let content = fs.readFileSync("flake.nix", "utf8"); + content = content.replace(/version = "[^"]+";/, `version = "${version}";`); + content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`); + fs.writeFileSync("flake.nix", content); + ' + + if git diff --quiet -- flake.nix; then + echo "flake.nix already has the current release hash on main." + else + git add flake.nix + git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash" + git push + fi + + git fetch origin dev + git worktree add ../dev-flake-update origin/dev + cd ../dev-flake-update + + node -e ' + const fs = require("fs"); + const version = process.env.TAG; + const hash = process.env.HASH; + let content = fs.readFileSync("flake.nix", "utf8"); + content = content.replace(/version = "[^"]+";/, `version = "${version}";`); + content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`); + fs.writeFileSync("flake.nix", content); + ' + + if git diff --quiet -- flake.nix; then + echo "flake.nix already has the current release hash on dev." + exit 0 + fi + + git add flake.nix + git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash" + git push origin HEAD:dev + EOF diff --git a/apps/electron/build/icons/128x128.png b/apps/electron/build/icons/128x128.png new file mode 100644 index 0000000..357e1c2 Binary files /dev/null and b/apps/electron/build/icons/128x128.png differ diff --git a/apps/electron/build/icons/128x128@2x.png b/apps/electron/build/icons/128x128@2x.png new file mode 100644 index 0000000..be55e24 Binary files /dev/null and b/apps/electron/build/icons/128x128@2x.png differ diff --git a/apps/electron/build/icons/32x32.png b/apps/electron/build/icons/32x32.png new file mode 100644 index 0000000..bc4fba4 Binary files /dev/null and b/apps/electron/build/icons/32x32.png differ diff --git a/apps/electron/build/icons/64x64.png b/apps/electron/build/icons/64x64.png new file mode 100644 index 0000000..567f4ad Binary files /dev/null and b/apps/electron/build/icons/64x64.png differ diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns new file mode 100644 index 0000000..a7f0a1e Binary files /dev/null and b/apps/electron/build/icons/icon.icns differ diff --git a/apps/electron/build/icons/icon.ico b/apps/electron/build/icons/icon.ico new file mode 100644 index 0000000..c5c68f8 Binary files /dev/null and b/apps/electron/build/icons/icon.ico differ diff --git a/apps/electron/build/icons/icon.png b/apps/electron/build/icons/icon.png new file mode 100644 index 0000000..dad3edb Binary files /dev/null and b/apps/electron/build/icons/icon.png differ diff --git a/apps/pwa/src/runtime.tsx b/apps/pwa/src/runtime.tsx index a1947eb..d7917cc 100644 --- a/apps/pwa/src/runtime.tsx +++ b/apps/pwa/src/runtime.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { setDatabaseEntry } from "@tensamin/shared/indexedDb"; @@ -6,6 +6,11 @@ import { isTauri } from "@tauri-apps/api/core"; import "./style.css"; +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +}; + const launchedFiles: File[] = []; const fileListeners = new Set<(file: File) => void>(); @@ -22,10 +27,9 @@ export function subscribeTuFileLaunch(listener: (file: File) => void) { }; } -function isInstalledPwa() { +function isStandalone() { return ( window.matchMedia("(display-mode: standalone)").matches || - window.matchMedia("(display-mode: window-controls-overlay)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true ); } @@ -61,9 +65,13 @@ async function enablePush() { await setDatabaseEntry("keys", "push-subscription", subscription.toJSON()); } -function InstalledPwaRuntime() { +export default function PwaRuntime() { + const [installPrompt, setInstallPrompt] = + useState(null); + useEffect(() => { if ( + isTauri() || !("serviceWorker" in navigator) || !["http:", "https:"].includes(window.location.protocol) ) { @@ -121,6 +129,16 @@ function InstalledPwaRuntime() { }; }, []); + useEffect(() => { + const handleInstallPrompt = (event: Event) => { + event.preventDefault(); + setInstallPrompt(event as BeforeInstallPromptEvent); + }; + window.addEventListener("beforeinstallprompt", handleInstallPrompt); + return () => + window.removeEventListener("beforeinstallprompt", handleInstallPrompt); + }, []); + useEffect(() => { const launchQueue = ( window as Window & { @@ -141,7 +159,41 @@ function InstalledPwaRuntime() { }, []); useEffect(() => { + if (!installPrompt) return; + toast("Install Tensamin for a native app experience", { + duration: Infinity, + action: { + label: "Install", + onClick: () => { + void installPrompt.prompt().then(() => installPrompt.userChoice); + setInstallPrompt(null); + }, + }, + }); + }, [installPrompt]); + + useEffect(() => { + const isAppleMobile = /iPad|iPhone|iPod/.test(navigator.userAgent); if ( + isTauri() || + !isAppleMobile || + isStandalone() || + localStorage.getItem("pwa-ios-install-hint") + ) { + return; + } + localStorage.setItem("pwa-ios-install-hint", "shown"); + toast( + "Install Tensamin from Safari's Share menu to enable background notifications.", + { + duration: 12_000, + }, + ); + }, []); + + useEffect(() => { + if ( + !isStandalone() || !("Notification" in window) || Notification.permission !== "default" || localStorage.getItem("pwa-push-hint") @@ -170,6 +222,7 @@ function InstalledPwaRuntime() { useEffect(() => { if ( + isStandalone() && "Notification" in window && Notification.permission === "granted" && import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY @@ -182,8 +235,3 @@ function InstalledPwaRuntime() { return null; } - -export default function PwaRuntime() { - if (isTauri() || !isInstalledPwa()) return null; - return ; -} diff --git a/apps/pwa/src/serviceWorker.ts b/apps/pwa/src/serviceWorker.ts index 554ceeb..de341bb 100644 --- a/apps/pwa/src/serviceWorker.ts +++ b/apps/pwa/src/serviceWorker.ts @@ -34,22 +34,19 @@ 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 secret?.version === "number" && - stringValues.every((item) => typeof item === "string") + 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" ); } diff --git a/apps/pwa/src/vite.ts b/apps/pwa/src/vite.ts index 962dce1..908a3d2 100644 --- a/apps/pwa/src/vite.ts +++ b/apps/pwa/src/vite.ts @@ -128,7 +128,6 @@ export function tensaminPwa(): Plugin[] { filename: "serviceWorker.ts", injectRegister: null, registerType: "prompt", - buildBase: "/", manifestFilename: "manifest.json", includeAssets: ["favicon.ico", "icons/*.png"], manifest: { diff --git a/apps/tauri/.cargo/config.toml b/apps/tauri/.cargo/config.toml new file mode 100644 index 0000000..161678f --- /dev/null +++ b/apps/tauri/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true } diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index d404d61..b1b1c0b 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -674,6 +674,12 @@ 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" @@ -883,6 +889,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -967,14 +974,25 @@ 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", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1039,7 +1057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -1186,14 +1204,38 @@ 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", - "signature", + "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", ] [[package]] @@ -1203,10 +1245,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", - "ed25519", + "ed25519 3.0.0", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2695,14 +2737,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "hybrid-array", "module-lattice", - "pkcs8", + "pkcs8 0.11.0", "shake", - "signature", + "signature 3.0.0", ] [[package]] @@ -2744,50 +2786,72 @@ dependencies = [ [[package]] name = "mtp" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" dependencies = [ "mtp-client", - "mtp-codec", - "mtp-common", - "mtp-crypto", + "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-host", - "mtp-transport", - "mtp-type-map", + "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-webserver", ] [[package]] name = "mtp-client" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" dependencies = [ - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-transport", + "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", ] [[package]] name = "mtp-codec" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" dependencies = [ "base64 0.23.1", "byteorder", - "mtp-common", - "mtp-crypto", - "mtp-type-map", + "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)", + "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", - "thiserror 2.0.19", ] [[package]] name = "mtp-common" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +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" dependencies = [ "quinn", "rustls", @@ -2797,18 +2861,18 @@ dependencies = [ [[package]] name = "mtp-crypto" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "chacha20poly1305", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.4.3", "hkdf", "ml-dsa", "mlkem-tls", "rand 0.10.2", - "rand_core 0.6.4", + "rand_core 0.10.1", "rustls", "serde", "sha2 0.11.0", @@ -2818,31 +2882,67 @@ dependencies = [ ] [[package]] -name = "mtp-host" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +name = "mtp-crypto" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" dependencies = [ - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-transport", + "base64 0.23.1", + "chacha20poly1305", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", + "hkdf", + "ml-dsa", "rand 0.10.2", + "rand_core 0.10.1", + "rustls", + "sha2 0.11.0", "thiserror 2.0.19", "tokio", + "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.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" dependencies = [ "async-trait", - "mtp-codec", - "mtp-common", - "mtp-crypto", - "rand 0.10.2", + "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", @@ -2850,13 +2950,21 @@ dependencies = [ "tokio", "tracing", "wtransport", - "zeroize", ] [[package]] name = "mtp-type-map" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +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" dependencies = [ "serde", "serde_yaml", @@ -2864,8 +2972,8 @@ dependencies = [ [[package]] name = "mtp-webserver" -version = "0.3.0" -source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a" +version = "0.2.0" +source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" dependencies = [ "async-trait", "bytes", @@ -2876,12 +2984,13 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "mtp-codec", - "mtp-common", - "mtp-crypto", + "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-host", - "mtp-transport", + "mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "quinn", + "rand 0.10.2", "rustls", "thiserror 2.0.19", "tokio", @@ -3383,6 +3492,15 @@ 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" @@ -3468,14 +3586,24 @@ 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", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -4464,6 +4592,15 @@ 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" @@ -4572,6 +4709,16 @@ 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" @@ -4579,7 +4726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] @@ -5142,6 +5289,7 @@ 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 2a68e95..08a66d5 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -24,7 +24,8 @@ 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 = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] } +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" } webpki-root-certs = "1" tauri-plugin-deep-link = "2" tauri-plugin-notification = "2" @@ -35,11 +36,6 @@ 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 272583a..e78a26a 100644 --- a/apps/tauri/src-tauri/src/main.rs +++ b/apps/tauri/src-tauri/src/main.rs @@ -1,6 +1,9 @@ // 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 fa28597..b343337 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, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use base64::{ engine::general_purpose::{STANDARD, STANDARD_NO_PAD}, @@ -16,7 +16,6 @@ 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; @@ -25,9 +24,6 @@ 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\ @@ -99,7 +95,7 @@ enum MtpEvent { pub struct MtpManager { runtime: tokio::runtime::Runtime, config: RwLock>, - connection: RwLock>>, + connection: RwLock>>, snapshot: RwLock, generation: AtomicU64, enabled: AtomicBool, @@ -108,46 +104,6 @@ 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 { @@ -193,7 +149,7 @@ impl MtpManager { .take() { self.runtime - .spawn(async move { connection.mtp.sender.close().await }); + .spawn(async move { connection.sender.close().await }); } self.set_snapshot(MtpSnapshot { generation, @@ -215,7 +171,7 @@ impl MtpManager { .take() { self.runtime - .spawn(async move { connection.mtp.sender.close().await }); + .spawn(async move { connection.sender.close().await }); } self.set_snapshot(MtpSnapshot { generation: self.generation.load(Ordering::SeqCst), @@ -275,16 +231,20 @@ impl MtpManager { self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation } - async fn request(&self, type_name: &str, data: Value) -> Result { + async fn request( + &self, + type_name: &str, + data: Value, + id: Option, + ) -> 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, connection.next_request_id().await?)?; + let request = json_to_frame(type_name, data, id)?; let response = connection - .mtp .request(&request, None) .await .map_err(|error| error.to_string())?; @@ -301,12 +261,9 @@ async fn supervise(generation: u64) { manager.log(2, "Starting native MTP connection", None); android_status("Connecting"); match connect(&config).await { - Ok(prepared) => { + Ok((connection, state)) => { delay = Duration::from_secs(1); - let connection = Arc::new(ManagedConnection { - mtp: Arc::new(prepared.connection), - request_ids: prepared.request_ids, - }); + let connection = Arc::new(connection); let stale = { let _guard = manager.start_lock.lock().expect("start lock poisoned"); let mut current = manager @@ -322,47 +279,22 @@ async fn supervise(generation: u64) { generation, ready_state: CONNECTED, identified: true, - state: Some(prepared.initial_state), + state: Some(state), error: None, }); false } }; if stale { - connection.mtp.sender.close().await; + connection.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.mtp.receive().await { - Ok(frame) => handle_push(generation, ¬ification_tx, frame).await, + match connection.receive().await { + Ok(frame) => handle_push(generation, connection.clone(), frame).await, Err(error) => { let _guard = manager.start_lock.lock().expect("start lock poisoned"); if manager.is_current(generation) { @@ -378,13 +310,6 @@ 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() @@ -414,21 +339,12 @@ async fn supervise(generation: u64) { break; } android_status("Reconnecting"); - tokio::time::sleep(jittered_retry_delay(delay)).await; + tokio::time::sleep(delay).await; delay = (delay * 2).min(Duration::from_secs(60)); } } -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 { +async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> { let (url, public_key) = resolve_endpoint(config) .await .map_err(|error| format!("endpoint discovery failed: {error}"))?; @@ -465,10 +381,39 @@ async fn connect(config: &MtpConfig) -> Result { .map_err(|error| format!("transport authentication failed: {error}"))?; manager().log(2, "Native MTP authentication completed", 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)) + 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)) .await .map_err(|_| "state acknowledgement timed out".to_string())? .map_err(|error| format!("state acknowledgement failed: {error}"))?; @@ -478,182 +423,7 @@ async fn connect(config: &MtpConfig) -> Result { { return Err(format!("ClientStateAck failed: {response}")); } - 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(communication_type("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.get_type_name() == Some("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); - } + Ok((connection, frame_data_to_json(&state)?)) } async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> { @@ -695,11 +465,7 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String )) } -async fn handle_push( - generation: u64, - notification_tx: &mpsc::Sender, - frame: CommunicationValue, -) { +async fn handle_push(generation: u64, connection: Arc, frame: CommunicationValue) { let manager = manager(); if let Ok(message) = frame_to_json(&frame) { manager.emit(MtpEvent::Message { @@ -712,7 +478,7 @@ async fn handle_push( { if let Some(partner_id) = frame .get_data(DataType::ChatPartnerId) - .and_then(DataValue::as_number) + .as_number() .and_then(|value| u64::try_from(value).ok()) { if let Err(error) = android_cancel_notification(partner_id) { @@ -721,39 +487,40 @@ async fn handle_push( } } if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) { - if notification_tx.try_send(frame).is_err() { - eprintln!("background message notification queue is full"); + if let Err(error) = notify_message(connection, &frame).await { + eprintln!("failed to create background message notification: {error}"); } } } async fn notify_message( - config: &MtpConfig, - connection: Arc, + connection: Arc, frame: &CommunicationValue, ) -> Result<(), String> { let sender_id = frame .get_data(DataType::SenderId) - .and_then(DataValue::as_number) + .as_number() .and_then(|value| u64::try_from(value).ok()) .ok_or("MessageLive omitted SenderId")?; - let message = frame - .get_data(DataType::Message) - .ok_or("MessageLive omitted Message")?; - let content = container_value_by_name(message, "Content") + let message = frame.get_data(DataType::Message); + let content = container_value(message, DataType::Content) .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())?; @@ -765,7 +532,7 @@ async fn notify_message( } let version = secret .get_data(DataType::VersionNumber) - .and_then(DataValue::as_number) + .as_number() .ok_or("missing secret version")?; let encrypted_secret = secret .get_bytes(DataType::EncryptedSecret) @@ -793,14 +560,11 @@ async fn notify_message( .decrypt(&ciphertext, b"") .map_err(|error| error.to_string())?; - 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_request = CommunicationValue::new(CommunicationType::GetUserData).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())?; @@ -888,17 +652,17 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> { value.get_field(id) } -fn container_value_by_name<'a>(value: &'a DataValue, field: &str) -> Option<&'a DataValue> { - container_value(value, DataType::from_name(field)?) -} - -fn communication_type(name: &str) -> Result { - CommunicationType::from_name(name).ok_or_else(|| format!("unknown communication type: {name}")) -} - -fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result { - let comm_type = communication_type(type_name)?; - let mut frame = CommunicationValue::new(comm_type).with_id(id); +fn json_to_frame( + type_name: &str, + data: Value, + id: Option, +) -> 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 Value::Object(fields) = data else { return Err("MTP request data must be an object".into()); }; @@ -973,8 +737,8 @@ fn is_bytes_field(field: &str) -> bool { fn frame_to_json(frame: &CommunicationValue) -> Result { let mut result = Map::new(); - if let Some(id) = frame.id() { - result.insert("id".into(), Value::from(id)); + if frame.get_id() != 0 { + result.insert("id".into(), Value::from(frame.get_id())); } result.insert( "type".into(), @@ -987,10 +751,7 @@ 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(); - let entries = frame - .data() - .ok_or("MTP frame payload is not a data container")?; - for (id, value) in entries { + for (id, value) in frame.data() { let name = map .data_type_name(id.0) .ok_or_else(|| format!("unknown data type id: {}", id.0))?; @@ -1040,15 +801,16 @@ 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 mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; - use serde_json::json; - - use super::{ - container_value_by_name, decode_browser_base64, decode_sdk_bytes, frame_to_json, - jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator, - }; + use super::{decode_browser_base64, decode_sdk_bytes}; #[test] fn browser_base64_accepts_file_whitespace_and_missing_padding() { @@ -1061,137 +823,11 @@ 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_content_wire_type() { - let frame = json_to_frame( - "MessageEdit", - json!({ - "Content": "ciphertext", - "ChatPartnerId": 42, - "SendTime": 10, - }), - 1, - ) - .unwrap(); - - assert_eq!( - frame - .get_data(DataType::Content) - .and_then(DataValue::as_str), - Some("ciphertext") - ); - } - - #[test] - fn nested_json_content_uses_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_by_name(message, "Content").and_then(DataValue::as_str), - Some("ciphertext") - ); - } - - #[test] - fn content_is_exposed_to_frontend() { - let frame = CommunicationValue::new(CommunicationType::MessageEditLive) - .with_id(1) - .add_typed_default(DataType::Content, DataValue::Str("ciphertext".into())); - - let json = frame_to_json(&frame).unwrap(); - - assert_eq!(json["data"]["Content"], "ciphertext"); - } - - fn valid_initial_state() -> CommunicationValue { - CommunicationValue::new(communication_type("ClientStateSync").unwrap()) - .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_eq!(ack.get_type_name(), Some("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(communication_type("ClientStateSync").unwrap()) - .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) -> Result { - manager().request(&type_name, data).await +pub async fn mtp_request(type_name: String, data: Value, id: Option) -> Result { + manager().request(&type_name, data, id).await } #[tauri::command] diff --git a/apps/web/package.json b/apps/web/package.json index a6b3951..f608fcb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,7 +55,6 @@ "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/flake.nix b/flake.nix index 0b0d63c..2b8781f 100644 --- a/flake.nix +++ b/flake.nix @@ -17,6 +17,8 @@ systems = [ "x86_64-linux" ]; forAllSystems = nixpkgs.lib.genAttrs systems; version = "0.0.11"; + x86_64DebHash = "sha256-E4FGMpCt2ByaN6d+YzglqeA6LbhMOQmPf3UzAj7uZXE="; + forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}"; in { packages = forAllSystems ( @@ -30,6 +32,8 @@ android_sdk.accept_license = true; }; }; + debArtifact = "Tensamin-${version}-linux-amd64.deb"; + electronRuntimeLibs = with pkgs; [ alsa-lib at-spi2-atk @@ -71,130 +75,71 @@ libXtst libxcb ]; - electron = pkgs.electron; - pnpm = pkgs.pnpm; - mtpTypeMaps = pkgs.fetchgit { - url = "https://git.methanium.net/tensamin/mtp-type-maps"; - rev = "6e5122fe44f793c0e0d3229b3d34145ce17c2d31"; - hash = "sha256-/4n8F0YLJaLncefKL907P5l+en1CTjpgdFmLBF3gbiQ="; - }; - mtpSource = pkgs.fetchzip { - url = "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz"; - hash = "sha256-XLTa8DxP93Q4hBHRCLUzCPOqkbdb4V3aZxE5Iuq+kW0="; - }; - mtpCargoDeps = pkgs.rustPlatform.fetchCargoVendor { - src = mtpSource; - hash = "sha256-8MZ65N/EtWPAggal0JkGDx3WSn+LxWQORinkqVbsrys="; - }; - wasmBindgenCliSource = pkgs.fetchCrate { - pname = "wasm-bindgen-cli"; - version = "0.2.127"; - hash = "sha256-di+qBAdd7pENLiIB9CoZoab+W5xeDoByMREcCGTSzWo="; - }; - wasmBindgenCli = pkgs.buildWasmBindgenCli { - src = wasmBindgenCliSource; - cargoDeps = pkgs.rustPlatform.fetchCargoVendor { - src = wasmBindgenCliSource; - hash = "sha256-FTv2GZIAQs0ePdIZXIXil7JbZ6kIT05VG6vqC1qNFxQ="; + packageDeb = + src: + pkgs.stdenv.mkDerivation { + pname = "tensamin"; + inherit version src; + + nativeBuildInputs = with pkgs; [ + autoPatchelfHook + dpkg + makeWrapper + ]; + buildInputs = electronRuntimeLibs; + + dontConfigure = true; + dontBuild = true; + + unpackPhase = '' + runHook preUnpack + dpkg-deb -x "$src" . + runHook postUnpack + ''; + + installPhase = '' + runHook preInstall + + mkdir -p "$out" + cp -r opt "$out/" + cp -r usr/* "$out/" + + mkdir -p "$out/bin" + makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" \ + --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}" + + substituteInPlace "$out/share/applications/Tensamin.desktop" \ + --replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin" + + runHook postInstall + ''; }; - }; - desktopItem = pkgs.makeDesktopItem { - name = "tensamin"; - desktopName = "Tensamin"; - exec = "tensamin"; - icon = "tensamin"; - startupWMClass = "Tensamin"; - categories = [ "Network" ]; - }; - defaultPackage = pkgs.stdenv.mkDerivation (finalAttrs: { - pname = "tensamin"; - inherit version; - src = self; - - pnpmDeps = pkgs.fetchPnpmDeps { - inherit (finalAttrs) pname version src; - inherit pnpm; - fetcherVersion = 4; - hash = "sha256-imP3MTr1YLc28Z9n617m0Wt/6vPirFznzoriKzojlEg="; - }; - - nativeBuildInputs = with pkgs; [ - copyDesktopItems - makeWrapper - nodejs_22 - pnpm - pnpmConfigHook - cargo - lld - rustc - wasm-pack - wasmBindgenCli - binaryen - ]; - - env.ELECTRON_SKIP_BINARY_DOWNLOAD = 1; - - postPatch = '' - rm -rf mtp-type-maps - ln -s ${mtpTypeMaps} mtp-type-maps - - node -e ' - const fs = require("fs"); - const path = "apps/electron/package.json"; - const pkg = JSON.parse(fs.readFileSync(path, "utf8")); - pkg.version = "${version}"; - fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n"); - ' - ''; - - buildPhase = '' - runHook preBuild - - mkdir -p "$HOME/.cargo" - substitute ${mtpCargoDeps}/.cargo/config.toml "$HOME/.cargo/config.toml" \ - --replace-fail @vendor@ ${mtpCargoDeps} - - pnpm run copy-licenses - pnpm run build:packages - pnpm run build:web - pnpm --dir apps/tauri run gen-icons - pnpm --dir apps/electron run build - pnpm --dir apps/electron exec electron-builder --dir --linux --publish never \ - --config.electronDist=${electron.dist} \ - --config.electronVersion=${electron.version} - - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - - mkdir -p "$out/lib/tensamin" "$out/bin" - cp -r apps/electron/release/linux-unpacked/. "$out/lib/tensamin/" - - makeWrapper "$out/lib/tensamin/tensamin" "$out/bin/tensamin" \ - --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}" - - install -Dm644 apps/electron/build/icons/icon.png \ - "$out/share/icons/hicolor/512x512/apps/tensamin.png" - - runHook postInstall - ''; - - desktopItems = [ desktopItem ]; - - meta = { - description = "Tensamin desktop client"; - homepage = "https://git.methanium.net/tensamin/client"; - mainProgram = "tensamin"; - platforms = pkgs.lib.platforms.linux; - }; - }); + defaultPackage = packageDeb ( + pkgs.fetchurl { + url = "${forgejoBaseUrl}/${debArtifact}"; + hash = x86_64DebHash; + } + ); + localDebPath = builtins.getEnv "TENSAMIN_DEB"; + localPathPackage = + if localDebPath == "" then + pkgs.writeShellScriptBin "tensamin" '' + echo "Set TENSAMIN_DEB to a local .deb path and run with --impure." >&2 + exit 1 + '' + else + packageDeb ( + builtins.path { + path = localDebPath; + name = debArtifact; + } + ); in { default = defaultPackage; tensamin = defaultPackage; electron = defaultPackage; + localPathForDev = localPathPackage; } ); diff --git a/mtp-type-maps b/mtp-type-maps index f4e45aa..a297dcc 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c +Subproject commit a297dcce60bc6e84696c6a16f5fd510beb2ca643 diff --git a/package.json b/package.json index 1975028..05856f5 100644 --- a/package.json +++ b/package.json @@ -25,27 +25,26 @@ "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", - "update-submodules": "git submodule update --remote --force --recursive" + "delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^26.2.0", + "@types/node": "^26.1.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@typescript-eslint/parser": "^8.67.0", - "eslint": "^10.8.1", + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", - "fallow": "^3.17.0", - "globals": "^17.11.0", + "fallow": "^3.14.0", + "globals": "^17.9.0", "prettier": "^3.9.6", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0", - "vitest": "^4.1.11" + "typescript-eslint": "^8.66.0", + "vitest": "^4.1.10" }, "dependencies": { - "@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" + "@methanium/ui": "*", + "mtp": "*", + "sonner": "^2.0.7" } } diff --git a/packages/cache/src/sync.tsx b/packages/cache/src/sync.tsx index 8928e23..37076c1 100644 --- a/packages/cache/src/sync.tsx +++ b/packages/cache/src/sync.tsx @@ -21,7 +21,8 @@ export function removeMissingContactSnapshots( } export default function CacheSync() { - const { addInterceptor, contextReady, freshContacts, subscribe } = useMTP(); + const { addInterceptor, contextReady, freshContacts, subscribePush } = + useMTP(); const { load } = useStorage(); const [accountId, setAccountId] = useState(0); const queueRef = useRef(Promise.resolve()); @@ -320,19 +321,10 @@ export default function CacheSync() { useEffect(() => { if (!accountId || !contextReady) return; - const handleMessage = (message: ProtocolMessage) => { + return subscribePush((message) => { void enqueue(() => synchronizePush(message)); - }; - const unsubscribers = [ - subscribe("GetStates", handleMessage), - subscribe("MessageLive", handleMessage), - subscribe("MessageEditLive", handleMessage), - subscribe("MessageDeleteLive", handleMessage), - subscribe("MessageState", handleMessage), - subscribe("MessageReactionLive", handleMessage), - ]; - return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); - }, [accountId, contextReady, enqueue, subscribe, synchronizePush]); + }); + }, [accountId, contextReady, enqueue, subscribePush, synchronizePush]); return null; } diff --git a/packages/call/package.json b/packages/call/package.json index 7171f50..d4f7c10 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -28,7 +28,6 @@ "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 9706d4d..6d2c47f 100644 --- a/packages/call/src/mediaShare/index.ts +++ b/packages/call/src/mediaShare/index.ts @@ -19,6 +19,7 @@ export function getMediaShareAdapter(): MediaShareAdapter { export type { MediaShareAdapter, + MediaShareCapabilities, MediaShareKind, MediaShareRequest, MediaShareSession, diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index bbf3f4e..6f7f524 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -1223,7 +1223,7 @@ export const useCall = create<{ export function useInitializeCall() { const navigate = useNavigate(); const location = useLocation(); - const { send, subscribe } = useMTP(); + const { send, subscribePush } = useMTP(); const { load } = useStorage(); const { insertCall } = useSession(); const { get } = useUser(); @@ -1313,9 +1313,14 @@ export function useInitializeCall() { // listen to call invites useEffect(() => { - return subscribe("CallInvite", async ({ data }) => { - const { CallId, CallSecret, SenderId } = data; - if (!CallId || !CallSecret || !SenderId) return; + return subscribePush(async (message) => { + if (message.type !== "CallInvite") return; + + const { CallId, CallSecret, SenderId } = message.data as { + CallId: string; + CallSecret: ProtocolCallSecret; + SenderId: number; + }; if (SenderId === Number(await load("user_id"))) { return; @@ -1332,7 +1337,7 @@ export function useInitializeCall() { SenderId, ); }); - }, [load, subscribe, showCallingScreen]); + }, [load, subscribePush, showCallingScreen]); // get callId from url useEffect(() => { diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 7c88df1..b4f1f5b 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -226,7 +226,7 @@ export async function fetchReplyMessage({ export default function Provider({ children }: { children: ReactNode }) { const { load } = useStorage(); - const { send, subscribe } = useMTP(); + const { send, subscribePush } = useMTP(); const { get: getUser } = useUser(); const { moveUserIdToTop } = useSession(); @@ -912,47 +912,124 @@ export default function Provider({ children }: { children: ReactNode }) { // Get live updates for message states useEffect(() => { - const unsubscribeEdit = subscribe("MessageEditLive", ({ data }) => { - if (!currentChatSecret) return; - if (data.ChatPartnerId !== userIdValue) { - log( - 3, - "chat", - "yellow", - "Cancel message edit update due to user ID mismatch", - { - expected: userIdValue, - received: data.ChatPartnerId, - }, + return subscribePush((message) => { + if (message.type === "MessageEditLive") { + if (!currentChatSecret) return; + + const rawData = message.data as { + ChatPartnerId: unknown; + SendTime: unknown; + Content: string; + }; + + const chatPartnerId = Number(rawData.ChatPartnerId); + const sendTime = Number(rawData.SendTime); + + if (!Number.isFinite(chatPartnerId) || !Number.isFinite(sendTime)) { + log( + 3, + "chat", + "yellow", + "Cancel message edit update due to invalid data", + ); + return; + } + + if (chatPartnerId !== userIdValue) { + log( + 3, + "chat", + "yellow", + "Cancel message edit update due to user ID mismatch", + { + expected: userIdValue, + received: chatPartnerId, + }, + ); + return; + } + + void decryptChatText(currentChatSecret, rawData.Content) + .then((content) => { + editMessage(sendTime, { Content: content, Edited: true }); + }) + .catch((err) => { + log(1, "chat", "red", "Failed to decrypt message edit", err, { + SendTime: sendTime, + }); + }); + return; + } + + if (message.type === "MessageReactionLive") { + const rawData = message.data as { + ChatPartnerId: unknown; + SendTime: unknown; + Reaction: string; + SenderId: unknown; + Accepted: boolean; + }; + const chatPartnerId = Number(rawData.ChatPartnerId); + const sendTime = Number(rawData.SendTime); + const senderId = Number(rawData.SenderId); + + if ( + chatPartnerId !== userIdValue || + !Number.isFinite(sendTime) || + !Number.isFinite(senderId) + ) { + return; + } + + applyLiveReaction( + sendTime, + rawData.Reaction, + senderId, + rawData.Accepted, ); return; } - void decryptChatText(currentChatSecret, data.Content) - .then((content) => { - editMessage(data.SendTime, { Content: content, Edited: true }); - }) - .catch((err) => { - log(1, "chat", "red", "Failed to decrypt message edit", err, { - SendTime: data.SendTime, - }); - }); - }); - const unsubscribeReaction = subscribe("MessageReactionLive", ({ data }) => { - if (data.ChatPartnerId !== userIdValue) return; - applyLiveReaction( - data.SendTime, - data.Reaction, - data.SenderId, - data.Accepted, - ); - }); - const unsubscribeDelete = subscribe("MessageDeleteLive", ({ data }) => { - if (data.ChatPartnerId !== userIdValue) return; - removeMessage(data.SendTime); - }); - const unsubscribeState = subscribe("MessageState", ({ data }) => { - if (data.ChatPartnerId !== userIdValue) { + if (message.type === "MessageDeleteLive") { + const data = message.data as { + ChatPartnerId: number; + SendTime: number; + }; + + if (data.ChatPartnerId !== userIdValue) return; + + removeMessage(data.SendTime); + return; + } + + if (message.type !== "MessageState") return; + + const rawData = message.data as { + ChatPartnerId: unknown; + SendTime: unknown; + MessageState: RawMessage["MessageState"]; + }; + + const nextState = { + ChatPartnerId: Number(rawData.ChatPartnerId), + SendTime: Number(rawData.SendTime), + MessageState: rawData.MessageState, + }; + + if ( + !Number.isFinite(nextState.ChatPartnerId) || + !Number.isFinite(nextState.SendTime) + ) { + log( + 3, + "chat", + "yellow", + "Cancel message state update due to invalid data", + ); + return; + } + + if (nextState.ChatPartnerId !== userIdValue) { log( 3, "chat", @@ -960,27 +1037,22 @@ export default function Provider({ children }: { children: ReactNode }) { "Cancel message state update due to user ID mismatch", { expected: userIdValue, - received: data.ChatPartnerId, + received: nextState.ChatPartnerId, }, ); return; } - editMessage(data.SendTime, { - MessageState: data.MessageState, + + editMessage(nextState.SendTime, { + MessageState: nextState.MessageState, }); }); - return () => { - unsubscribeEdit(); - unsubscribeReaction(); - unsubscribeDelete(); - unsubscribeState(); - }; }, [ currentChatSecret, applyLiveReaction, editMessage, removeMessage, - subscribe, + subscribePush, userIdValue, ]); diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 2b40fdf..62580df 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -15,7 +15,6 @@ "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 556d131..97ef318 100644 --- a/packages/crypto/src/callSecret.ts +++ b/packages/crypto/src/callSecret.ts @@ -13,13 +13,11 @@ export function deriveCallSecretId(callId: string): string { } export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { - return crypto.keyringToKeys({ value: keyring, encoding: "base64" }) - .kemPublicKey; + return crypto.keyringToKeys(keyring).kemPublicKey; } export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { - return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" }) - .kemPublicKey; + return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; } export async function wrapCallSecret(args: { @@ -74,10 +72,7 @@ export async function unwrapCallSecret(args: { ); } - const ownKeys = crypto.keyringToKeys({ - value: args.keyring, - encoding: "base64", - }); + const ownKeys = crypto.keyringToKeys(args.keyring); const sharedSecret = crypto.decapsulate( ownKeys.kemSecretKey, args.kemCiphertext, diff --git a/packages/crypto/src/chatSecret.ts b/packages/crypto/src/chatSecret.ts index cbac59a..e6e836c 100644 --- a/packages/crypto/src/chatSecret.ts +++ b/packages/crypto/src/chatSecret.ts @@ -23,13 +23,11 @@ export function randomChatSecret(): Uint8Array { } export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { - return crypto.keyringToKeys({ value: keyring, encoding: "base64" }) - .kemPublicKey; + return crypto.keyringToKeys(keyring).kemPublicKey; } export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { - return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" }) - .kemPublicKey; + return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; } export async function wrapChatSecret(args: { @@ -81,10 +79,7 @@ export async function unwrapChatSecret(args: { ); } - const ownKeys = crypto.keyringToKeys({ - value: args.keyring, - encoding: "base64", - }); + const ownKeys = crypto.keyringToKeys(args.keyring); const sharedSecret = crypto.decapsulate( ownKeys.kemSecretKey, args.kemCiphertext, diff --git a/packages/markdown/package.json b/packages/markdown/package.json deleted file mode 100644 index 247d715..0000000 --- a/packages/markdown/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index c0eb397..0000000 --- a/packages/markdown/src/emoji.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 158b5b0..0000000 --- a/packages/markdown/src/emojiData.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index e16c84d..0000000 --- a/packages/markdown/src/input.tsx +++ /dev/null @@ -1,850 +0,0 @@ -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 deleted file mode 100644 index f8684c4..0000000 --- a/packages/markdown/src/markdown.tsx +++ /dev/null @@ -1,815 +0,0 @@ -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[]. - */ -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; -} - -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 deleted file mode 100644 index ca65fe0..0000000 --- a/packages/markdown/src/text.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index b59da98..0000000 --- a/packages/markdown/todo.md +++ /dev/null @@ -1 +0,0 @@ -- Improve the Input box diff --git a/packages/markdown/tsconfig.json b/packages/markdown/tsconfig.json deleted file mode 100644 index 9714625..0000000 --- a/packages/markdown/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 44f5750..982593a 100644 --- a/packages/mtp/package.json +++ b/packages/mtp/package.json @@ -9,16 +9,18 @@ "scripts": { "format": "pnpm exec prettier --write .", "lint": "eslint src --ext .ts,.tsx", - "test": "vitest run --passWithNoTests", - "build": "pnpm run test && tsc -p tsconfig.json --noEmit" + "build": "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/browser.tsx b/packages/mtp/src/browser.tsx deleted file mode 100644 index 755a021..0000000 --- a/packages/mtp/src/browser.tsx +++ /dev/null @@ -1,529 +0,0 @@ -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { toast as sonnerToast } from "@methanium/ui"; -import { base64ToBytes, ConnectionState, MTPClient } from "mtp"; -import createAsyncQueue from "@tensamin/shared/asyncQueue"; -import { - mtp as mtpSchemas, - type Calls, - type Communities, - type Contacts, -} from "@tensamin/shared/data"; -import { log } from "@tensamin/shared/log"; -import { useStorage } from "@tensamin/storage/context"; - -import { - type BoundSendFn, - MTPContext, - type MTPContextType, - type ProtocolMessage, - removeMissingContacts, - useMessageHandlers, -} from "./mtpContext"; -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 createBrowserClient( - options: Omit[0], "schemas">, -) { - return MTPClient.create({ - ...options, - schemas: mtpSchemas, - throwProtocolErrors: true, - onValidationError: (error) => { - log(1, "mtp", "red", "Failed to validate push message", error); - }, - }); -} - -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)), - ); - }); -} - -async function completeInitialSynchronization( - client: BrowserMtpClient, - subscribe: MTPContextType["subscribe"], - 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 = subscribe("ClientStateSync", (message) => { - cleanup(); - resolve(message); - }); - unsubscribeNoIota = 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, - ]); - await withDeadline( - client.request("ClientStateAck", { - SessionId: state.data.SessionId, - VersionNumber: state.data.VersionNumber, - }), - ackTimeoutMs, - "State acknowledgement timed out", - signal, - ); - if (signal.aborted) throw abortError(signal); - return state; -} - -function protocolErrorDetails(error: unknown) { - if (typeof error !== "object" || error === null || !("type" in error)) { - return null; - } - const protocolError = error as { - id?: unknown; - type?: unknown; - frame?: unknown; - }; - return { - id: protocolError.id, - type: protocolError.type, - frame: protocolError.frame, - }; -} - -export function BrowserProvider(props: { - children: ReactNode; - blockConnection?: boolean; -}) { - const { load } = useStorage(); - const [readyState, setReadyState] = useState( - ConnectionState.Disconnected, - ); - const [identified, setIdentified] = useState(false); - const [identifying, setIdentifying] = useState(false); - const [freshCommunities, setFreshCommunities] = useState([]); - const [freshContacts, setFreshContacts] = useState([]); - const [freshCalls, setFreshCalls] = useState([]); - const clientRef = useRef(null); - const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } = - useMessageHandlers(); - const connected = readyState === ConnectionState.Connected; - - const [mtpUrl, setMtpUrl] = useState(null); - useEffect(() => { - load("omega_url").then(setMtpUrl); - }, [load]); - - const send: BoundSendFn = useMemo( - () => async (type, data, options) => { - const client = clientRef.current; - if (!client) throw new Error("mtp is not connected"); - const response = await client.request(type, data, options); - if (response.type === "GetStates") { - setFreshContacts((contacts) => - removeMissingContacts( - contacts, - response as ProtocolMessage<"GetStates">, - ), - ); - } - return response; - }, - [], - ); - - const resolveConnectionRef = useRef(() => {}); - useEffect(() => { - if (!mtpUrl) return; - let attempts = 0; - let reconnectTimer: ReturnType | null = null; - let reconnectResetTimer: ReturnType | null = null; - let reconnectScheduled = false; - let disposed = false; - let connectionGeneration = 0; - let cleanupConnection = () => {}; - - const clearReconnectTimer = () => { - if (!reconnectTimer) return; - clearTimeout(reconnectTimer); - reconnectTimer = null; - reconnectScheduled = false; - }; - const clearReconnectResetTimer = () => { - if (!reconnectResetTimer) return; - clearTimeout(reconnectResetTimer); - reconnectResetTimer = null; - }; - const scheduleReconnect = (error: unknown) => { - if (disposed || reconnectScheduled) return; - 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]}. Retrying in the background.` - : "Connection lost. Retrying in the background.", - icon: null, - duration: Infinity, - closeButton: true, - promise: null, - } as unknown as Parameters[1]); - } else { - 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(); - }, - Math.round(baseDelay * jitter), - ); - }; - - async function connect() { - if (disposed || props.blockConnection) return; - const generation = ++connectionGeneration; - let client: BrowserMtpClient | null = null; - let failed = false; - let connectionReady = false; - let detachSubscriptions = () => {}; - let unsubscribeNoIota = () => {}; - const attemptAbort = new AbortController(); - const cleanup = () => { - attemptAbort.abort( - new Error("Initial state synchronization was cancelled"), - ); - unsubscribeNoIota(); - detachSubscriptions(); - client?.disconnect(); - if (clientRef.current === client) clientRef.current = null; - clearReconnectResetTimer(); - if (generation === connectionGeneration) { - setReadyState(ConnectionState.Disconnected); - setIdentified(false); - setIdentifying(false); - } - }; - cleanupConnection = cleanup; - try { - setIdentified(false); - setIdentifying(false); - const [userId, keyring] = await Promise.all([ - load("user_id"), - load("mtp_keyring"), - ]); - if (!userId || !keyring) throw new Error("Missing login credentials"); - const forcedOmikronUrl = await load("forced_omikron_url"); - const forcedOmikronPublicKey = await load("forced_omikron_public_key"); - let url = null; - let omikronPublicKey = null; - if (forcedOmikronUrl && forcedOmikronPublicKey) { - url = forcedOmikronUrl; - omikronPublicKey = forcedOmikronPublicKey; - } else { - log(2, "mtp", "purple", "Fetching Omikron data."); - const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, { - signal: AbortSignal.any([ - attemptAbort.signal, - AbortSignal.timeout(DISCOVERY_TIMEOUT), - ]), - }); - if (data.status === 404) { - 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 { - ip_address: string; - port: number; - public_key: string; - }; - if ( - !omikronData.ip_address || - !omikronData.port || - !omikronData.public_key - ) { - throw new Error("Invalid Omikron data"); - } - url = `https://${omikronData.ip_address}:${omikronData.port}`; - omikronPublicKey = omikronData.public_key; - } - if (!url || !omikronPublicKey) { - throw new Error("Missing Omikron URL or Public Key"); - } - log(2, "mtp", "green", "Connecting to: " + url); - client = await createBrowserClient({ - url, - credentials: { clientId: userId, keyring: base64ToBytes(keyring) }, - hostPublicKey: { value: omikronPublicKey, encoding: "base64" }, - descriptor: "client", - pings: true, - logger: (event) => { - if (event.type === "state") { - if (generation !== connectionGeneration) return; - const state = client?.state ?? ConnectionState.Disconnected; - setReadyState(state); - if ( - state === ConnectionState.Disconnected && - clientRef.current === client && - !failed - ) { - failed = true; - const error = new Error("MTP connection lost"); - attemptAbort.abort(error); - if (connectionReady) { - cleanup(); - scheduleReconnect(error); - } - } - } - if (event.type !== "Pong" && event.type !== "Ping") { - log( - 2, - "mtp", - event.type === "state" - ? "purple" - : event.direction === "recv" - ? "cyan" - : event.direction === "send" - ? "gray" - : "blue", - event.type === "state" - ? event.data - : event.direction === "recv" - ? "< " + event.type - : event.direction === "send" - ? "> " + event.type - : event.type, - event, - ); - } - }, - }); - if (disposed || generation !== connectionGeneration) { - client.disconnect(); - return; - } - const activeClient = client; - clientRef.current = activeClient; - detachSubscriptions = attachSubscriptions(activeClient); - unsubscribeNoIota = subscribe("ErrorNoIota", () => { - if (clientRef.current !== activeClient || failed) return; - failed = true; - const error = new Error("No Iota is currently connected"); - attemptAbort.abort(error); - cleanup(); - scheduleReconnect(error); - }); - setReadyState(activeClient.state); - setIdentifying(true); - const finalResponse = await completeInitialSynchronization( - activeClient, - subscribe, - attemptAbort.signal, - ); - 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) { - client?.disconnect(); - return; - } - failed = true; - cleanup(); - const message = - connectError instanceof Error - ? connectError.message - : String(connectError ?? "Unknown error"); - log( - 0, - "mtp", - "red", - `Connection/authentication attempt failed: ${message}`, - protocolErrorDetails(connectError) ?? connectError, - ); - scheduleReconnect(connectError); - } - } - - void connect(); - return () => { - disposed = true; - clearReconnectTimer(); - clearReconnectResetTimer(); - cleanupConnection(); - setReadyState(ConnectionState.Disconnected); - setIdentified(false); - setIdentifying(false); - sonnerToast.dismiss("mtp-connection-toast"); - }; - }, [attachSubscriptions, load, mtpUrl, props.blockConnection, subscribe]); - - useEffect(() => { - return subscribe("ErrorNoIota", () => { - setIdentified(false); - setIdentifying(false); - 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?.(); - }); - }, [subscribe]); - - useEffect( - () => - subscribe("GetStates", (message) => { - setFreshContacts((contacts) => - removeMissingContacts(contacts, message), - ); - }), - [subscribe], - ); - - const loadingDescription = useMemo(() => { - if (!mtpUrl) return "Loading connection details"; - if (readyState === ConnectionState.Connecting || !connected) { - return "Establishing transport channel"; - } - if (identifying || !identified) return "Waiting for authenticated session"; - return "Loading..."; - }, [connected, identified, identifying, readyState, mtpUrl]); - const contextReady = connected && identified && mtpUrl !== null; - const mtpRef = useMemo(() => createAsyncQueue<{ send: typeof send }>(), []); - useEffect(() => { - if (connected && identified && mtpUrl) { - mtpRef.set({ send }); - } - }, [connected, identified, mtpUrl, send, mtpRef]); - - const sendQueued: BoundSendFn = useMemo( - () => async (type, data, options) => { - const mtp = await mtpRef.get(); - const response = await mtp.send(type, data, options); - for (const interceptor of interceptorsRef.current) { - void Promise.resolve(interceptor({ type, data, response })).catch( - (error) => { - log(1, "mtp", "yellow", "MTP interceptor failed", error, { type }); - }, - ); - } - return response; - }, - [interceptorsRef, mtpRef], - ); - - return ( - - {props.children} - - ); -} diff --git a/packages/mtp/src/context.test.tsx b/packages/mtp/src/context.test.tsx new file mode 100644 index 0000000..7f50aba --- /dev/null +++ b/packages/mtp/src/context.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { isPushType, validateResponse } from "./context"; + +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); + }); +}); diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index c07579f..af06ee0 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -1,41 +1,957 @@ -import { type ReactNode, useContext, useEffect, useState } from "react"; -import { isTauri } from "@tauri-apps/api/core"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; 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"; -import { BrowserProvider } from "./browser"; -import { MTPContext, type MTPContextType } from "./mtpContext"; -import { TauriProvider } from "./tauri"; +import { + type Calls, + type Communities, + type Contacts, + mtp as schemas, + 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"; + +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, +> = { + id?: number; + type: T | string; + data: z.infer; +}; + +export type BoundSendFn = ( + type: T, + data?: z.infer, + options?: { id?: number }, +) => Promise>; + +export type PushHandler = (message: ProtocolMessage) => void | Promise; + +const PUSH_TYPES = [ + "MessageLive", + "MessageEditLive", + "MessageReactionLive", + "MessageDeleteLive", + "MessageState", + "CallInvite", + "GetStates", + "ClientChanged", + "ErrorNoIota", +] as const; + +export function isPushType(type: string): boolean { + return (PUSH_TYPES as readonly string[]).includes(type); +} + +function removeMissingContacts( + contacts: Contacts, + message: ProtocolMessage, +): Contacts { + if (message.type !== "GetStates") return contacts; + const data = message.data as { MissingUserIds?: unknown }; + if (!Array.isArray(data.MissingUserIds)) return contacts; + const missing = new Set( + data.MissingUserIds.filter( + (userId): userId is number => typeof userId === "number", + ), + ); + return contacts.filter((contact) => !missing.has(contact.UserId)); +} + +export type MTPExchange = { + type: keyof Schemas & string; + data: unknown; + response: ProtocolMessage; +}; + +export type MTPInterceptor = (exchange: MTPExchange) => void | Promise; + +type ContextType = { + send: BoundSendFn; + subscribe: ( + type: T, + handler: (message: ProtocolMessage) => void, + ) => () => void; + subscribePush: (handler: PushHandler) => () => void; + addInterceptor: (interceptor: MTPInterceptor) => () => void; + readyState: number; + identified: boolean; + freshContacts: Contacts; + freshCommunities: Communities; + freshCalls: Calls; + contextReady: boolean; + loadingDescription: string; +}; + +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 useMessageHandlers() { + const interceptorsRef = useRef(new Set()); + const pushHandlersRef = useRef(new Set()); + const lastInitialStateRef = useRef(null); + const subscribePush = useCallback((handler: PushHandler) => { + pushHandlersRef.current.add(handler); + const initialState = lastInitialStateRef.current; + if (initialState?.type === "GetStates") { + void Promise.resolve(handler(initialState)).catch(() => undefined); + } + return () => pushHandlersRef.current.delete(handler); + }, []); + const addInterceptor = useCallback((interceptor: MTPInterceptor) => { + interceptorsRef.current.add(interceptor); + return () => interceptorsRef.current.delete(interceptor); + }, []); + return { + addInterceptor, + interceptorsRef, + lastInitialStateRef, + pushHandlersRef, + subscribePush, + }; +} + +function BrowserProvider(props: { + children: ReactNode; + blockConnection?: boolean; +}) { + const { load } = useStorage(); + + const [readyState, setReadyState] = useState( + ConnectionState.Disconnected, + ); + const [identified, setIdentified] = useState(false); + const [identifying, setIdentifying] = useState(false); + + const [freshCommunities, setFreshCommunities] = useState([]); + const [freshContacts, setFreshContacts] = useState([]); + const [freshCalls, setFreshCalls] = useState([]); + + const clientRef = useRef> | null>( + null, + ); + const { + addInterceptor, + interceptorsRef, + lastInitialStateRef, + pushHandlersRef, + subscribePush, + } = useMessageHandlers(); + + const connected = readyState === ConnectionState.Connected; + + // MTP url + const [mtpUrl, setMtpUrl] = useState(null); + useEffect(() => { + load("omega_url").then(setMtpUrl); + }, [load]); + + // Validation override functions + const send: BoundSendFn = useMemo( + () => async (type, data, options) => { + const client = clientRef.current; + + if (!client) { + throw new Error("mtp is not connected"); + } + + const message = await client.request( + type, + (data ?? {}) as Record, + options, + ); + const response = validateResponse(type, message); + 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; + }, + [], + ); + + const subscribe = useCallback((type, handler) => { + const client = clientRef.current; + if (!client) { + return () => {}; + } + + return client.subscribe(type, (message) => { + handler(validateResponse(type, message)); + }); + }, []); + + // Reconnect stuff + const resolveConnectionRef = useRef(() => {}); + useEffect(() => { + if (!mtpUrl) return; + + let attempts = 0; + let reconnectTimer: ReturnType | null = null; + let reconnectResetTimer: ReturnType | null = null; + let reconnectScheduled = false; + let disposed = false; + let connectionGeneration = 0; + + const clearReconnectTimer = () => { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + reconnectScheduled = false; + }; + + const clearReconnectResetTimer = () => { + if (!reconnectResetTimer) return; + clearTimeout(reconnectResetTimer); + reconnectResetTimer = null; + }; + + const scheduleReconnect = (error: unknown) => { + if (disposed || reconnectScheduled) return; + if (attempts >= RECONNECT_TRIES) { + 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", + icon: null, + duration: Infinity, + closeButton: true, + promise: null, + } as unknown as Parameters[1]); + return; + } + + attempts += 1; + sonnerToast.loading( + `Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`, + { id: "mtp-connection-toast" }, + ); + reconnectScheduled = true; + reconnectTimer = setTimeout(() => { + reconnectScheduled = false; + reconnectTimer = null; + void connect(); + }, RETRY_INTERVAL); + }; + + async function connect() { + if (disposed || props.blockConnection) return; + + const generation = ++connectionGeneration; + let client: Awaited> | null = null; + let failed = false; + const cleanup = () => { + client?.disconnect(); + if (clientRef.current === client) { + clientRef.current = null; + } + clearReconnectResetTimer(); + if (generation === connectionGeneration) { + setReadyState(ConnectionState.Disconnected); + setIdentified(false); + setIdentifying(false); + } + }; + try { + setIdentified(false); + setIdentifying(false); + + const [userId, keyring] = await Promise.all([ + load("user_id"), + load("mtp_keyring"), + ]); + if (!userId || !keyring) { + throw new Error("Missing login credentials"); + } + const forcedOmikronUrl = await load("forced_omikron_url"); + const forcedOmikronPublicKey = await load("forced_omikron_public_key"); + + let url = null; + let omikronPublicKey = null; + if (forcedOmikronUrl && forcedOmikronPublicKey) { + url = forcedOmikronUrl; + omikronPublicKey = forcedOmikronPublicKey; + } else { + log(2, "mtp", "purple", "Fetching Omikron data."); + const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`); + + 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; + } + const omikronData = (await data.json()) as { + id: number; + ip_address: string; + port: number; + public_key: string; + status: string; + }; + + if ( + !omikronData.ip_address || + !omikronData.port || + !omikronData.public_key + ) + throw new Error("Invalid Omikron data"); + + url = `https://${omikronData.ip_address}:${omikronData.port}`; + omikronPublicKey = omikronData.public_key; + } + //codec.decode(new Uint8Array(await res.arrayBuffer())), + + if (!url || !omikronPublicKey) + throw new Error("Missing Omikron URL or Public Key"); + + log(2, "mtp", "green", "Connecting to: " + url); + + client = await MTPClient.create({ + url, + credentials: { + clientId: userId, + keyring: base64ToUint8Array(keyring), + }, + hostPublicKey: omikronPublicKey, + descriptor: "client", + pings: true, + logger: (event) => { + if (event.type === "state") { + if (generation !== connectionGeneration) return; + const state = client?.state ?? ConnectionState.Disconnected; + setReadyState(state); + if ( + state === ConnectionState.Disconnected && + clientRef.current === client && + !failed + ) { + failed = true; + clientRef.current = null; + setIdentified(false); + setIdentifying(false); + scheduleReconnect(new Error("MTP connection lost")); + } + } + + if (event.type !== "Pong" && event.type !== "Ping") { + log( + 2, + "mtp", + event.type === "state" + ? "purple" + : event.direction === "recv" + ? "cyan" + : event.direction === "send" + ? "gray" + : "blue", + event.type === "state" + ? event.data + : event.direction === "recv" + ? "< " + event.type + : event.direction === "send" + ? "> " + event.type + : event.type, + event, + ); + } + }, + }); + + if (disposed || generation !== connectionGeneration) { + client.disconnect(); + return; + } + const activeClient = client; + + clientRef.current = activeClient; + for (const type of PUSH_TYPES) { + activeClient.subscribe(type, (message) => { + let validated: ProtocolMessage; + try { + validated = validateResponse(type, message); + } catch (error) { + log(1, "mtp", "red", "Failed to validate push message", error, { + type, + data: message.data, + }); + return; + } + + setFreshContacts((contacts) => + removeMissingContacts(contacts, validated), + ); + for (const handler of [...pushHandlersRef.current]) { + void Promise.resolve() + .then(() => handler(validated)) + .catch((error) => { + log(1, "mtp", "red", "Push handler failed", error, { type }); + }); + } + if (validated.type === "GetStates") { + lastInitialStateRef.current = validated; + } + }); + } + 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(); + try { + resolve(validateResponse("ClientStateSync", message)); + } catch (error) { + reject(error); + } + }, + ); + unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => { + cleanupStateSync(); + reject(new Error("No Iota is currently connected")); + }); + }, + ); + const [, finalResponse] = await Promise.all([ + activeClient.auth(), + stateSync, + ]); + + if (finalResponse.type.startsWith("Error")) { + throw new Error( + `State synchronization failed: ${finalResponse.type}`, + ); + } + + const acknowledgement = await activeClient.request("ClientStateAck", { + SessionId: finalResponse.data.SessionId, + VersionNumber: finalResponse.data.VersionNumber, + }); + if (acknowledgement.type.startsWith("Error")) { + throw new Error( + `State acknowledgement failed: ${acknowledgement.type}`, + ); + } + + if (disposed || clientRef.current !== activeClient) return; + + setFreshContacts(finalResponse.data.Contacts); + setFreshCommunities(finalResponse.data.Communities); + setFreshCalls(finalResponse.data.Calls); + setIdentifying(false); + setIdentified(true); + resolveConnectionRef.current?.(); + } catch (connectError) { + if (disposed || generation !== connectionGeneration) { + client?.disconnect(); + return; + } + failed = true; + cleanup(); + const connectErrorMessage = + connectError instanceof Error + ? connectError.message + : String(connectError ?? "Unknown error"); + log( + 0, + "mtp", + "red", + `Connection/authentication attempt failed: ${connectErrorMessage}`, + getProtocolErrorDetails(connectError) ?? connectError, + ); + + scheduleReconnect(connectError); + } + } + + void connect(); + + return () => { + disposed = true; + clearReconnectTimer(); + clearReconnectResetTimer(); + + clientRef.current?.disconnect(); + clientRef.current = null; + setReadyState(ConnectionState.Disconnected); + setIdentified(false); + setIdentifying(false); + sonnerToast.dismiss("mtp-connection-toast"); + }; + }, [ + lastInitialStateRef, + mtpUrl, + props.blockConnection, + load, + pushHandlersRef, + ]); + + // No Iota check + useEffect(() => { + if (!connected) return; + + return subscribe("ErrorNoIota", () => { + setIdentified(false); + setIdentifying(false); + 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?.(); + }); + }, [connected, subscribe]); + + // Async queue + const loadingDescription = useMemo(() => { + if (!mtpUrl) return "Loading connection details"; + if (readyState === ConnectionState.Connecting || !connected) { + return "Establishing transport channel"; + } + if (identifying || !identified) return "Waiting for authenticated session"; + return "Loading..."; + }, [connected, identified, identifying, readyState, mtpUrl]); + const contextReady = connected && identified && mtpUrl !== null; + const mtpRef = useMemo( + () => + createAsyncQueue<{ + send: typeof send; + subscribe: typeof subscribe; + subscribePush: typeof subscribePush; + }>(), + [], + ); + useEffect(() => { + if (connected && identified && mtpUrl) { + mtpRef.set({ + send, + subscribe, + subscribePush, + }); + } + }, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]); + + const sendQueued: BoundSendFn = useMemo( + () => async (type, data, options) => { + const mtp = await mtpRef.get(); + const response = await mtp.send(type, data, options); + for (const interceptor of interceptorsRef.current) { + void Promise.resolve( + interceptor({ type, data, response: response as ProtocolMessage }), + ).catch((error) => { + log(1, "mtp", "yellow", "MTP interceptor failed", error, { type }); + }); + } + return response; + }, + [interceptorsRef, mtpRef], + ); + + return ( + + {props.children} + + ); +} + +type NativeSnapshot = { + generation: number; + readyState: number; + identified: boolean; + state?: unknown; + error?: string; +}; + +function TauriProvider(props: { + children: ReactNode; + blockConnection?: boolean; +}) { + const [snapshot, setSnapshot] = useState({ + generation: 0, + readyState: ConnectionState.Disconnected, + identified: false, + }); + const [freshContacts, setFreshContacts] = useState([]); + const [freshCommunities, setFreshCommunities] = useState([]); + const [freshCalls, setFreshCalls] = useState([]); + const generationRef = useRef(0); + const { + addInterceptor, + interceptorsRef, + lastInitialStateRef, + pushHandlersRef, + subscribePush, + } = useMessageHandlers(); + const subscriptionsRef = useRef( + new Map void>>(), + ); + + const applySnapshot = useCallback((next: NativeSnapshot) => { + if (next.generation < generationRef.current) return; + generationRef.current = next.generation; + if (next.error) { + log(0, "android", "orange", "MTP connection failed", next.error); + } + setSnapshot(next); + if (!next.identified || next.state === undefined) return; + const parsed = schemas.ClientStateSync.response.safeParse(next.state); + if (!parsed.success) { + log(0, "mtp", "red", "Invalid native MTP state", parsed.error); + return; + } + setFreshContacts(parsed.data.Contacts); + setFreshCommunities(parsed.data.Communities); + setFreshCalls(parsed.data.Calls); + }, []); + + const dispatchMessage = useCallback( + (raw: unknown) => { + if (!raw || typeof raw !== "object" || !("type" in raw)) return; + const message = raw as { id?: number; type: string; data: unknown }; + let validated: ProtocolMessage; + try { + validated = validateResponse( + message.type as keyof Schemas & string, + message, + ); + } 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; + } + }, + [lastInitialStateRef, pushHandlersRef], + ); + + useEffect(() => { + if (props.blockConnection) return; + let disposed = false; + let unlisten: UnlistenFn | undefined; + void (async () => { + try { + const nextUnlisten = await listen< + | { kind: "state"; snapshot: NativeSnapshot } + | { kind: "message"; generation: number; message: unknown } + | { + kind: "log"; + level: number; + message: string; + details?: unknown; + } + >("mtp://event", ({ payload }) => { + if (disposed) return; + if (payload.kind === "state") { + applySnapshot(payload.snapshot); + return; + } + if (payload.kind === "message") { + if (payload.generation === generationRef.current) { + dispatchMessage(payload.message); + } + return; + } + log( + payload.level, + "android", + "orange", + payload.message, + payload.details, + ); + }); + if (disposed) nextUnlisten(); + else unlisten = nextUnlisten; + } catch (error) { + log(0, "mtp", "red", "Failed to subscribe to native MTP events", error); + } + + try { + const current = await invoke("mtp_status"); + if (!disposed) applySnapshot(current); + } catch (error) { + log(0, "mtp", "red", "Failed to load native MTP status", error); + } + })(); + return () => { + disposed = true; + unlisten?.(); + }; + }, [applySnapshot, dispatchMessage, props.blockConnection]); + + useEffect(() => { + if (props.blockConnection) return; + const updateVisibility = () => { + void invoke("mtp_set_ui_visible", { + visible: document.visibilityState === "visible" && document.hasFocus(), + }); + }; + updateVisibility(); + document.addEventListener("visibilitychange", updateVisibility); + window.addEventListener("focus", updateVisibility); + window.addEventListener("blur", updateVisibility); + return () => { + document.removeEventListener("visibilitychange", updateVisibility); + window.removeEventListener("focus", updateVisibility); + window.removeEventListener("blur", updateVisibility); + void invoke("mtp_set_ui_visible", { visible: false }); + }; + }, [props.blockConnection]); + + const send = useCallback( + async (type, data, options) => { + const response = await invoke("mtp_request", { + typeName: type, + data: data ?? {}, + id: options?.id, + }); + const validated = validateResponse(type, 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 }), + ).catch((error) => { + log(1, "mtp", "yellow", "MTP interceptor failed", error, { type }); + }); + } + return validated; + }, + [interceptorsRef], + ); + + 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; + + return ( + + {props.children} + + ); +} export function Provider(props: { children: ReactNode; blockConnection?: boolean; -}) { - if (isTauri()) return ; - return ; -} - -function BrowserWasmProvider(props: { - children: ReactNode; - blockConnection?: boolean; }) { const [wasmReady, setWasmReady] = useState(false); const [wasmError, setWasmError] = useState(); + useEffect(() => { let active = true; void MTPClient.init().then( - () => active && setWasmReady(true), - (error: unknown) => active && setWasmError(() => error), + () => { + if (active) setWasmReady(true); + }, + (error: unknown) => { + if (active) setWasmError(() => error); + }, ); return () => { active = false; }; }, []); + if (wasmError) throw wasmError; - return wasmReady ? : null; + if (!wasmReady) return null; + + return isTauri() ? ( + + ) : ( + + ); } -export function useMTP(): MTPContextType { +export function useMTP(): ContextType { const context = useContext(MTPContext); - if (!context) throw new Error("useMTP must be used within an MTPProvider"); + if (!context) { + throw new Error("useMTP must be used within an MTPProvider"); + } return context; } diff --git a/packages/mtp/src/index.ts b/packages/mtp/src/index.ts index a4f05c8..39bce46 100644 --- a/packages/mtp/src/index.ts +++ b/packages/mtp/src/index.ts @@ -3,5 +3,6 @@ export type { BoundSendFn, MTPExchange, MTPInterceptor, + PushHandler, ProtocolMessage, -} from "./mtpContext"; +} from "./context"; diff --git a/packages/mtp/src/mtpContext.tsx b/packages/mtp/src/mtpContext.tsx deleted file mode 100644 index 1f1654d..0000000 --- a/packages/mtp/src/mtpContext.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { createContext, useCallback, useRef } from "react"; -import type { - MTPRequestFunction, - MTPResponseFrame, - MTPSubscriptionFunction, -} from "mtp"; -import { - mtp as mtpSchemas, - type Calls, - type Communities, - type Contacts, -} from "@tensamin/shared/data"; -import { log } from "@tensamin/shared/log"; - -export type ProtocolMessage< - Type extends keyof typeof mtpSchemas & string = keyof typeof mtpSchemas & - string, -> = MTPResponseFrame; - -export type BoundSendFn = MTPRequestFunction; - -export type MTPExchange = { - type: keyof typeof mtpSchemas & string; - data: unknown; - response: ProtocolMessage; -}; - -export type MTPInterceptor = (exchange: MTPExchange) => void | Promise; - -export type MTPContextType = { - send: BoundSendFn; - subscribe: MTPSubscriptionFunction; - addInterceptor: (interceptor: MTPInterceptor) => () => void; - readyState: number; - identified: boolean; - freshContacts: Contacts; - freshCommunities: Communities; - freshCalls: Calls; - contextReady: boolean; - loadingDescription: string; -}; - -export const MTPContext = createContext(undefined); - -export function removeMissingContacts( - contacts: Contacts, - message: ProtocolMessage<"GetStates">, -): Contacts { - const missing = new Set(message.data.MissingUserIds ?? []); - return contacts.filter((contact) => !missing.has(contact.UserId)); -} - -export function useMessageHandlers() { - const interceptorsRef = useRef(new Set()); - const subscriptionHandlersRef = useRef( - new Map void | Promise>>(), - ); - const transportRef = useRef<{ - subscribe: MTPSubscriptionFunction; - } | null>(null); - const transportGenerationRef = useRef(0); - const transportUnsubscribersRef = useRef(new Map void>()); - const lastInitialStateRef = useRef | null>(null); - - const attachType = useCallback( - (type: Type) => { - const transport = transportRef.current; - if (!transport || transportUnsubscribersRef.current.has(type)) return; - const generation = transportGenerationRef.current; - const unsubscribe = transport.subscribe(type, (message) => { - if ( - transportRef.current !== transport || - transportGenerationRef.current !== generation - ) - return; - if (type === "GetStates") { - lastInitialStateRef.current = message as ProtocolMessage<"GetStates">; - } - for (const handler of [ - ...(subscriptionHandlersRef.current.get(type) ?? []), - ]) { - void Promise.resolve(handler(message as ProtocolMessage)).catch( - (error) => { - log(1, "mtp", "red", "Subscription handler failed", error, { - type, - }); - }, - ); - } - }); - transportUnsubscribersRef.current.set(type, unsubscribe); - }, - [], - ); - - const attachSubscriptions = useCallback( - (transport: { subscribe: MTPSubscriptionFunction }) => { - for (const unsubscribe of transportUnsubscribersRef.current.values()) { - unsubscribe(); - } - transportUnsubscribersRef.current.clear(); - transportRef.current = transport; - const generation = ++transportGenerationRef.current; - for (const type of subscriptionHandlersRef.current.keys()) { - attachType(type as keyof typeof mtpSchemas & string); - } - return () => { - if ( - transportRef.current !== transport || - transportGenerationRef.current !== generation - ) - return; - transportRef.current = null; - transportGenerationRef.current += 1; - for (const unsubscribe of transportUnsubscribersRef.current.values()) { - unsubscribe(); - } - transportUnsubscribersRef.current.clear(); - }; - }, - [attachType], - ); - - const subscribe = useCallback>( - (type, handler) => { - const handlers = subscriptionHandlersRef.current.get(type) ?? new Set(); - const untypedHandler = handler as ( - message: ProtocolMessage, - ) => void | Promise; - handlers.add(untypedHandler); - subscriptionHandlersRef.current.set(type, handlers); - attachType(type); - const initialState = lastInitialStateRef.current; - if (type === "GetStates" && initialState) { - void Promise.resolve(untypedHandler(initialState)).catch( - () => undefined, - ); - } - return () => { - handlers.delete(untypedHandler); - if (handlers.size !== 0) return; - subscriptionHandlersRef.current.delete(type); - transportUnsubscribersRef.current.get(type)?.(); - transportUnsubscribersRef.current.delete(type); - }; - }, - [attachType], - ); - const addInterceptor = useCallback((interceptor: MTPInterceptor) => { - interceptorsRef.current.add(interceptor); - return () => interceptorsRef.current.delete(interceptor); - }, []); - return { - addInterceptor, - attachSubscriptions, - interceptorsRef, - subscribe, - }; -} diff --git a/packages/mtp/src/tauri.tsx b/packages/mtp/src/tauri.tsx deleted file mode 100644 index 92ac209..0000000 --- a/packages/mtp/src/tauri.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { - ConnectionState, - MTPProxyConnection, - type MTPFrame, - type MTPProxyAdapter, -} from "mtp"; -import { - mtp as mtpSchemas, - type Calls, - type Communities, - type Contacts, -} from "@tensamin/shared/data"; -import { log } from "@tensamin/shared/log"; - -import { - type BoundSendFn, - MTPContext, - type ProtocolMessage, - removeMissingContacts, - useMessageHandlers, -} from "./mtpContext"; - -type NativeSnapshot = { - generation: number; - readyState: number; - identified: boolean; - state?: unknown; - error?: string; -}; - -function createTauriAdapter() { - const subscriptions = new Map void>>(); - const adapter: MTPProxyAdapter = { - request: (type, data) => - invoke("mtp_request", { typeName: type, data }), - subscribe(type, handler) { - const handlers = subscriptions.get(type) ?? new Set(); - handlers.add(handler); - subscriptions.set(type, handlers); - return () => { - handlers.delete(handler); - if (handlers.size === 0) subscriptions.delete(type); - }; - }, - }; - return { - adapter, - dispatch(message: MTPFrame) { - for (const handler of subscriptions.get(message.type) ?? []) - handler(message); - }, - }; -} - -export function TauriProvider(props: { - children: ReactNode; - blockConnection?: boolean; -}) { - const [snapshot, setSnapshot] = useState({ - generation: 0, - readyState: ConnectionState.Disconnected, - identified: false, - }); - const [freshContacts, setFreshContacts] = useState([]); - const [freshCommunities, setFreshCommunities] = useState([]); - const [freshCalls, setFreshCalls] = useState([]); - const generationRef = useRef(0); - const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } = - useMessageHandlers(); - const [{ bridge, connection }] = useState(() => { - const bridge = createTauriAdapter(); - return { - bridge, - connection: new MTPProxyConnection(bridge.adapter, { - schemas: mtpSchemas, - throwProtocolErrors: true, - onValidationError: (error) => { - log(1, "mtp", "red", "Failed to validate native MTP message", error); - }, - }), - }; - }); - - const applySnapshot = useCallback(async (next: NativeSnapshot) => { - if (next.generation < generationRef.current) return; - generationRef.current = next.generation; - if (next.error) - log(0, "android", "orange", "MTP connection failed", next.error); - if (!next.identified) { - setSnapshot(next); - return; - } - if (next.state === undefined) { - setSnapshot({ - ...next, - identified: false, - error: "Native MTP connection omitted initial state", - }); - return; - } - try { - const state = await mtpSchemas.ClientStateSync.response.parseAsync( - next.state, - ); - setFreshContacts(state.Contacts); - setFreshCommunities(state.Communities); - setFreshCalls(state.Calls); - setSnapshot(next); - } catch (error) { - log(0, "mtp", "red", "Invalid native MTP state", error); - setSnapshot({ - ...next, - identified: false, - error: "Invalid ClientStateSync payload", - }); - } - }, []); - - const dispatchMessage = useCallback( - (message: MTPFrame) => { - bridge.dispatch(message); - }, - [bridge], - ); - - useEffect(() => { - return attachSubscriptions(connection); - }, [attachSubscriptions, connection]); - - useEffect( - () => - subscribe("GetStates", (message) => { - setFreshContacts((contacts) => - removeMissingContacts(contacts, message), - ); - }), - [subscribe], - ); - - useEffect(() => { - if (props.blockConnection) return; - let disposed = false; - let unlisten: UnlistenFn | undefined; - void (async () => { - try { - const nextUnlisten = await listen< - | { kind: "state"; snapshot: NativeSnapshot } - | { kind: "message"; generation: number; message: MTPFrame } - | { kind: "log"; level: number; message: string; details?: unknown } - >("mtp://event", ({ payload }) => { - if (disposed) return; - if (payload.kind === "state") { - void applySnapshot(payload.snapshot); - } else if (payload.kind === "message") { - if (payload.generation === generationRef.current) { - dispatchMessage(payload.message); - } - } else { - log( - payload.level, - "android", - "orange", - payload.message, - payload.details, - ); - } - }); - if (disposed) nextUnlisten(); - else unlisten = nextUnlisten; - } catch (error) { - log(0, "mtp", "red", "Failed to subscribe to native MTP events", error); - } - try { - const current = await invoke("mtp_status"); - if (!disposed) await applySnapshot(current); - } catch (error) { - log(0, "mtp", "red", "Failed to load native MTP status", error); - } - })(); - return () => { - disposed = true; - unlisten?.(); - }; - }, [applySnapshot, dispatchMessage, props.blockConnection]); - - useEffect(() => { - if (props.blockConnection) return; - const updateVisibility = () => { - void invoke("mtp_set_ui_visible", { - visible: document.visibilityState === "visible" && document.hasFocus(), - }); - }; - updateVisibility(); - document.addEventListener("visibilitychange", updateVisibility); - window.addEventListener("focus", updateVisibility); - window.addEventListener("blur", updateVisibility); - return () => { - document.removeEventListener("visibilitychange", updateVisibility); - window.removeEventListener("focus", updateVisibility); - window.removeEventListener("blur", updateVisibility); - void invoke("mtp_set_ui_visible", { visible: false }); - }; - }, [props.blockConnection]); - - const send = useCallback( - async (type, data, options) => { - const response = await connection.request(type, data, options); - if (response.type === "GetStates") { - setFreshContacts((contacts) => - removeMissingContacts( - contacts, - response as ProtocolMessage<"GetStates">, - ), - ); - } - for (const interceptor of interceptorsRef.current) { - void Promise.resolve(interceptor({ type, data, response })).catch( - (error) => { - log(1, "mtp", "yellow", "MTP interceptor failed", error, { type }); - }, - ); - } - return response; - }, - [connection, interceptorsRef], - ); - const connected = snapshot.readyState === ConnectionState.Connected; - - return ( - - {props.children} - - ); -} diff --git a/packages/mtp/src/values.ts b/packages/mtp/src/values.ts index c5ba321..d9f6cd4 100644 --- a/packages/mtp/src/values.ts +++ b/packages/mtp/src/values.ts @@ -1,8 +1,3 @@ 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 e0da277..1835dc1 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -16,6 +16,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { decryptChatText } from "@tensamin/crypto/chatSecret"; import { log } from "@tensamin/shared/log"; import { playSound } from "@tensamin/shared/sounds"; +import { type RawMessage } from "@tensamin/chat/values"; export const context = createContext(undefined); @@ -29,7 +30,7 @@ async function requestNotificationPermission() { } export default function Provider(props: { children: React.ReactNode }) { - const { subscribe, send } = useMTP(); + const { subscribePush, send } = useMTP(); const { load } = useStorage(); const { get } = useUser(); const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat(); @@ -38,140 +39,154 @@ export default function Provider(props: { children: React.ReactNode }) { const location = useLocation(); useEffect(() => { - return subscribe("MessageLive", async ({ data }) => { - if (!data.SenderId) return; + return subscribePush(async (message) => { + if (message.type === "MessageLive") { + const data = message.data as { + Message?: RawMessage; + SenderId?: number; + }; - const isCurrentChat = - location.pathname === "/chat" && userId === data.SenderId; - const appFocused = - document.hasFocus() && document.visibilityState === "visible"; - const shouldAlert = !isCurrentChat || !appFocused; + if (!data.SenderId) return; - const messageSecret = - isCurrentChat && chatSecret - ? chatSecret - : await getChatSecret(data.SenderId); + const isCurrentChat = + location.pathname === "/chat" && userId === data.SenderId; + const appFocused = + document.hasFocus() && document.visibilityState === "visible"; + const shouldAlert = !isCurrentChat || !appFocused; - if (!data.Message || !messageSecret) return; - if (shouldAlert) playSound("message"); + const messageSecret = + isCurrentChat && chatSecret + ? chatSecret + : await getChatSecret(data.SenderId); - void decryptChatText(messageSecret, data.Message.Content) - .catch((err) => { - log(1, "chat", "red", "Failed to decrypt live message", err, { - SendTime: data.Message?.SendTime, - }); - return null; - }) - .then(async (content) => { - if (!data.Message || !content || !data.SenderId) return; + if (!data.Message || !messageSecret) return; + if (shouldAlert) playSound("message"); - if (isCurrentChat) { - addLiveMessage({ - ...data.Message, - Content: content ?? "Failed to decrypt message", - decryptionFailed: content === null, + void decryptChatText(messageSecret, data.Message.Content) + .catch((err) => { + log(1, "chat", "red", "Failed to decrypt live message", err, { + SendTime: data.Message?.SendTime, }); - } + return null; + }) + .then(async (content) => { + if (!data.Message || !content || !data.SenderId) return; - if (!shouldAlert) return; - - if (!isCurrentChat) { - // todo: add notification symbol to conversation cards (incl. message start) - moveUserIdToTop(data.SenderId); - - if (await load("settings.receive_confirmations")) { - void send("MessageState", { - MessageState: "received", + if (isCurrentChat) { + addLiveMessage({ + ...data.Message, + Content: content ?? "Failed to decrypt message", + decryptionFailed: content === null, }); } - } - const user = await get(data.SenderId, [ - "UserId", - "Display", - "Avatar", - ]); + if (!shouldAlert) return; - if (isTauri()) { - if (!appFocused) return; - const permissionGranted = - (await isTauriNotificationPermissionGranted()) || - (await requestTauriNotificationPermission()) === "granted"; + if (!isCurrentChat) { + // todo: add notification symbol to conversation cards (incl. message start) + moveUserIdToTop(data.SenderId); - if (permissionGranted) { - let handledNatively = false; - try { - handledNatively = await invoke( - "mtp_post_message_notification", + if (await load("settings.receive_confirmations")) { + void send( + "MessageState", { - senderId: user.UserId, - sender: user.Display, - body: content, - avatar: user.Avatar, + MessageState: "received", + }, + { + id: data.Message.SendTime, }, ); - } catch (error) { - log( - 1, - "notifications", - "red", - "Failed to create native message notification", - error, - ); - } - - if (!handledNatively) { - sendTauriNotification({ title: user.Display, body: content }); } } - } else { - const hasPermissions = await requestNotificationPermission(); - if (hasPermissions) { - const options: NotificationOptions = { - body: content, - icon: user.Avatar || "/icons/icon-192.png", - badge: "/icons/notification-badge.png", - tag: `message-${user.UserId}`, - silent: true, - }; - if ("serviceWorker" in navigator) { - const registration = - await navigator.serviceWorker.getRegistration(); - if (registration) { - await registration.showNotification(user.Display, { - ...options, - data: { url: `/chat?id=${user.UserId}` }, - }); - return; + const user = await get(data.SenderId, [ + "UserId", + "Display", + "Avatar", + ]); + + if (isTauri()) { + if (!appFocused) return; + const permissionGranted = + (await isTauriNotificationPermissionGranted()) || + (await requestTauriNotificationPermission()) === "granted"; + + if (permissionGranted) { + let handledNatively = false; + try { + handledNatively = await invoke( + "mtp_post_message_notification", + { + senderId: user.UserId, + sender: user.Display, + body: content, + avatar: user.Avatar, + }, + ); + } catch (error) { + log( + 1, + "notifications", + "red", + "Failed to create native message notification", + error, + ); + } + + if (!handledNatively) { + sendTauriNotification({ title: user.Display, body: content }); } } - const notification = new Notification(user.Display, options); - notification.onclick = () => { - window.focus(); - navigate({ - to: `/chat?id=${user.UserId}`, - }); - notification.close(); - }; } else { - sonnerToast(user.Display, { - classNames: { - content: "pl-4", - }, - description: content, - icon: ( - - - - {user.Display.slice(0, 2).toUpperCase()} - - - ), - }); + const hasPermissions = await requestNotificationPermission(); + + if (hasPermissions) { + const options: NotificationOptions = { + body: content, + icon: user.Avatar || "/icons/icon-192.png", + badge: "/icons/notification-badge.png", + tag: `message-${user.UserId}`, + silent: true, + }; + if ("serviceWorker" in navigator) { + const registration = + await navigator.serviceWorker.getRegistration(); + if (registration) { + await registration.showNotification(user.Display, { + ...options, + data: { url: `/chat?id=${user.UserId}` }, + }); + return; + } + } + const notification = new Notification(user.Display, options); + notification.onclick = () => { + window.focus(); + navigate({ + to: `/chat?id=${user.UserId}`, + }); + notification.close(); + }; + } else { + sonnerToast(user.Display, { + classNames: { + content: "pl-4", + }, + description: content, + icon: ( + + + + {user.Display.slice(0, 2).toUpperCase()} + + + ), + }); + } } - } - }); + }); + return; + } }); }, [ addLiveMessage, @@ -182,7 +197,7 @@ export default function Provider(props: { children: React.ReactNode }) { navigate, send, moveUserIdToTop, - subscribe, + subscribePush, getChatSecret, userId, ]); diff --git a/packages/settings/src/layout.tsx b/packages/settings/src/layout.tsx index 5d49f0b..53759cd 100644 --- a/packages/settings/src/layout.tsx +++ b/packages/settings/src/layout.tsx @@ -1,8 +1,8 @@ -import { Outlet, useLocation } from "@tanstack/react-router"; -import { Button, useIsMobile } from "@methanium/ui"; +import { Outlet, useLocation, useNavigate } from "@tanstack/react-router"; +import { Button, ClearStorageButton, cn, useIsMobile } from "@methanium/ui"; import { ArrowLeft } from "lucide-react"; -import { SettingsSidebar } from "./sidebar"; +import { settingsNavigation } from "./manifest"; export default function SettingsLayout() { const isMobile = useIsMobile(); @@ -36,3 +36,47 @@ 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 1afdd5a..41fe14a 100644 --- a/packages/settings/src/manifest.ts +++ b/packages/settings/src/manifest.ts @@ -8,24 +8,46 @@ 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 }, - ...settingsNavigation.map((page) => ({ - ...page, - component: pageComponents[page.path], - })), + { + 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, + }, ] 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 deleted file mode 100644 index b47fae9..0000000 --- a/packages/settings/src/navigation.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 b5d04e3..803a0c1 100644 --- a/packages/settings/src/pages/index.tsx +++ b/packages/settings/src/pages/index.tsx @@ -1,4 +1,4 @@ -import { SettingsSidebar } from "../sidebar"; +import { SettingsSidebar } from "../layout"; export default function Page() { return ( diff --git a/packages/settings/src/sidebar.tsx b/packages/settings/src/sidebar.tsx deleted file mode 100644 index 3a2155e..0000000 --- a/packages/settings/src/sidebar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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/packages/user/src/context.tsx b/packages/user/src/context.tsx index 8c41aa4..423661c 100644 --- a/packages/user/src/context.tsx +++ b/packages/user/src/context.tsx @@ -9,12 +9,13 @@ import { useState, useSyncExternalStore, } from "react"; -import { useMTP } from "@tensamin/mtp"; +import { useMTP, type ProtocolMessage } from "@tensamin/mtp"; import { clientUserStateSchema, mtp as schemas, publicUserStateSchema, + userStateEntrySchema, } from "@tensamin/shared/data"; import type z from "zod"; import { createCache } from "@tensamin/cache"; @@ -88,7 +89,7 @@ export default function UserProvider(props: { children: ReactNode }) { ); const revisionsRef = useRef(new Map>()); - const { send, subscribe: subscribeMTP } = useMTP(); + const { send, subscribePush } = useMTP(); const { load } = useStorage(); const { contacts } = useSession(); const [accountId, setAccountId] = useState(null); @@ -173,6 +174,39 @@ export default function UserProvider(props: { children: ReactNode }) { [publishUser], ); + const handleStatePush = useCallback( + async (message: ProtocolMessage) => { + if (!accountId) return; + const data = message.data as Record; + if (message.type === "GetStates") { + if (Array.isArray(data.MissingUserIds)) { + for (const userId of data.MissingUserIds) { + if (typeof userId === "number") removePresence(userId); + } + } + if (!Array.isArray(data.UserStates)) return; + for (const entry of data.UserStates) { + const parsed = userStateEntrySchema.safeParse(entry); + if (!parsed.success) continue; + if (parsed.data.UserId === accountId) continue; + initialStatesRef.current.set( + parsed.data.UserId, + parsed.data.UserState, + ); + if (applyUserState(parsed.data.UserId, parsed.data.UserState)) { + initialStatesRef.current.delete(parsed.data.UserId); + } + } + return; + } + if (message.type !== "ClientChanged") return; + const parsed = schemas.ClientChanged.response.safeParse(data); + if (!parsed.success) return; + applyUserState(parsed.data.UserId, parsed.data.UserState, true); + }, + [accountId, applyUserState, removePresence], + ); + useEffect(() => { void load("user_id").then((accountId) => { accountIdRef.current = accountId; @@ -189,24 +223,8 @@ export default function UserProvider(props: { children: ReactNode }) { useEffect(() => { if (!accountId) return; - const unsubscribeStates = subscribeMTP("GetStates", ({ data }) => { - for (const userId of data.MissingUserIds ?? []) removePresence(userId); - for (const entry of data.UserStates) { - if (!entry || entry.UserId === accountId) continue; - initialStatesRef.current.set(entry.UserId, entry.UserState); - if (applyUserState(entry.UserId, entry.UserState)) { - initialStatesRef.current.delete(entry.UserId); - } - } - }); - const unsubscribeChanged = subscribeMTP("ClientChanged", ({ data }) => { - applyUserState(data.UserId, data.UserState, true); - }); - return () => { - unsubscribeStates(); - unsubscribeChanged(); - }; - }, [accountId, applyUserState, removePresence, subscribeMTP]); + return subscribePush(handleStatePush); + }, [accountId, handleStatePush, subscribePush]); const loadUser = useCallback( async (userId: number): Promise => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83d88ff..faff301 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.3.0-dev-c7c7afe/mtp-0.3.0.tgz + mtp: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.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.3.0-dev-c7c7afe/mtp-0.3.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz + 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 sonner: - 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) + specifier: ^2.0.7 + version: 2.0.7(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.2.0 + specifier: ^26.1.2 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.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) + 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) eslint: - specifier: ^10.8.1 + specifier: ^10.8.0 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.17.0 - version: 3.17.0 + specifier: ^3.14.0 + version: 3.14.0 globals: - specifier: ^17.11.0 - version: 17.11.0 + specifier: ^17.9.0 + version: 17.9.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.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) + 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) vitest: - 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)) + 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)) 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.3.0-dev-c7c7afe/mtp-0.3.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz + 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 react: specifier: ^19.2.8 version: 19.2.8 @@ -289,9 +289,6 @@ importers: globals: specifier: ^17.9.0 version: 17.9.0 - mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz typescript: specifier: ~6.0.3 version: 6.0.3 @@ -355,9 +352,6 @@ 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-dev-c7c7afe/mtp-0.3.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz react: specifier: ^19.2.8 version: 19.2.8 @@ -430,9 +424,6 @@ importers: packages/crypto: dependencies: - mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz react: specifier: ^19.2.8 version: 19.2.8 @@ -459,45 +450,6 @@ 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': @@ -506,6 +458,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 @@ -513,11 +468,17 @@ importers: specifier: workspace:* version: link:../storage mtp: - specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz - version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz + 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 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 @@ -1303,8 +1264,8 @@ packages: '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} - '@codemirror/commands@6.11.0': - resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==} + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} '@codemirror/lang-css@6.3.1': resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} @@ -1327,8 +1288,8 @@ packages: '@codemirror/state@6.7.1': resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} - '@codemirror/view@6.43.9': - resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==} + '@codemirror/view@6.43.8': + resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} @@ -1576,43 +1537,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.17.0': - resolution: {integrity: sha512-u9O4aQaTSdaZaa4MAVEZEiwOx6QQzChymyQPrxV6KvDcVbcl0oYSekJKdvN4gEqUY8ClMze9gWS1tbn/W3N4dQ==} + '@fallow-cli/darwin-arm64@3.14.0': + resolution: {integrity: sha512-seaix3OqADcq90PkPOOPrN8Jl7QZDGjFziHA85Ikp+lGSHVjJ2IMyGh/QKLhvi9Q6Ha3Y//F0+PFFGNd0Gi4mw==} cpu: [arm64] os: [darwin] - '@fallow-cli/darwin-x64@3.17.0': - resolution: {integrity: sha512-l3tZJovnCa8j+im4qTl4GVSfrx4gisJYeHDwb5TbcdTGaWYz42gHriAaz5MhOR8S24q3xRegX8QrI8PA4XXU4g==} + '@fallow-cli/darwin-x64@3.14.0': + resolution: {integrity: sha512-qgYZHHTFobuJCtON7aVe/tXCUjiF/9nKT4tNLVfUPH4Qe9KwieO4rAuknDOnxkjzsWPO4MfHyCbcVdslf4JMnw==} cpu: [x64] os: [darwin] - '@fallow-cli/linux-arm64-gnu@3.17.0': - resolution: {integrity: sha512-IAjS6qR4tHcRo1y5lzYT6mktN/ATPyPNrJ6yjwTORwwQV1YfMO58A5zGBfJlPBY+lUmaMZ9w37ftvH9a2AS4dQ==} + '@fallow-cli/linux-arm64-gnu@3.14.0': + resolution: {integrity: sha512-FwmIG391lMViBXh1cAvOcwQxbI2sLjEYkugFauVbmntjuJt6xVxQXAxjybdFXSL/jjfFnbAzPKy9oHlhq3sA6A==} cpu: [arm64] os: [linux] - '@fallow-cli/linux-arm64-musl@3.17.0': - resolution: {integrity: sha512-Oe1dvk5Wt5Bmnxfc6HufJTaFY9SEwDVrZpnSdVQlv+nvz7DzrTK1r91cR3Ip477ANSLa9XD/MHPMpfqqo0dlZg==} + '@fallow-cli/linux-arm64-musl@3.14.0': + resolution: {integrity: sha512-x4R9aNT2kIoSHRIZ5s2MZ+swyb3uutV6SSYCwWhPSE5UI/oCEYWNWiYS7uKMLM0cTuMPVcsm2qMTd4iYA3ACLw==} cpu: [arm64] os: [linux] - '@fallow-cli/linux-x64-gnu@3.17.0': - resolution: {integrity: sha512-XgLU2a51zuky7gifjVDHuiJGToAReDsVCDcyDF84TQz/h3bqO0iRkpDL8dh7+HODku84+EffpK5ZHY19n6Ij0w==} + '@fallow-cli/linux-x64-gnu@3.14.0': + resolution: {integrity: sha512-8kBxy9FkwKjiH1C19T0nSkmbtldwVyWLQWajIqvyHE0x4rurHhve7RQrwEDPzNMFu7OgYCMU1nXr/AMIJ6Kg9w==} cpu: [x64] os: [linux] - '@fallow-cli/linux-x64-musl@3.17.0': - resolution: {integrity: sha512-GnDZx2kbLKKdYAp05NSKv6CZHgzEKvhRd0btCKFMMpUEhdzmr4uh2Qb4UzKDBg/q2r1EhkzN4b/qC+tbFCmtwA==} + '@fallow-cli/linux-x64-musl@3.14.0': + resolution: {integrity: sha512-9TAdXSpYvk9WkKy2GR8T/PHbq7QCTO/SYUmiZ+EAJUMzXBvSfnpj8cCGQpeAD000Nb+o7rL94lIAPJMAqMKO3Q==} cpu: [x64] os: [linux] - '@fallow-cli/win32-arm64-msvc@3.17.0': - resolution: {integrity: sha512-u9n5WzPuKuC1PKegKQknWtngAUJ2szQaRXsbY54s7UMqeFNyC+W04LV5D+sEZywU7L0kzL+Kck9LxFLXBQcgvA==} + '@fallow-cli/win32-arm64-msvc@3.14.0': + resolution: {integrity: sha512-xc495cracyJL+0UKwcqNhofIz2K5XiJbb+YFhxrbSrrbm+EVDnQKpCOzzOJYbm7RPxF6XOxJpATn7ciBy7Rqrw==} cpu: [arm64] os: [win32] - '@fallow-cli/win32-x64-msvc@3.17.0': - resolution: {integrity: sha512-/OcyxHuCKabzQ9hKIjM/CFJ9fr30n0Mw5MK6nzi5rn26mbYUgh073UZq4FONOpPJ/FqHtC726rxpgBtR4qS+gg==} + '@fallow-cli/win32-x64-msvc@3.14.0': + resolution: {integrity: sha512-ihyCZ5GMoYGnzlPP6b5pZBBcGZhDHDW2u1YEGkZEuf3pbW6E45U7Hhf213cE2CCheScXOirK6AKWnLM/Qj4c2A==} cpu: [x64] os: [win32] @@ -1637,8 +1598,8 @@ packages: '@fontsource-variable/public-sans@5.3.0': resolution: {integrity: sha512-AVfkmAt50BMXWpOO21FAntiJFKGX6xTc2dSL8dxtDteONe9IuRXJWGbs0EbG955vAMCq23ENeuopuW87cGWDSQ==} - '@hono/node-server@2.1.1': - resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + '@hono/node-server@2.1.0': + resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -2265,32 +2226,32 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@shikijs/core@4.4.3': - resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + '@shikijs/core@4.4.2': + resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.4.3': - resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + '@shikijs/engine-javascript@4.4.2': + resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.4.3': - resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + '@shikijs/engine-oniguruma@4.4.2': + resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==} engines: {node: '>=20'} - '@shikijs/langs@4.4.3': - resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + '@shikijs/langs@4.4.2': + resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==} engines: {node: '>=20'} - '@shikijs/primitive@4.4.3': - resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + '@shikijs/primitive@4.4.2': + resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==} engines: {node: '>=20'} - '@shikijs/themes@4.4.3': - resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + '@shikijs/themes@4.4.2': + resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==} engines: {node: '>=20'} - '@shikijs/types@4.4.3': - resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + '@shikijs/types@4.4.2': + resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': @@ -2570,12 +2531,6 @@ 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==} @@ -2697,14 +2652,6 @@ 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} @@ -2712,45 +2659,22 @@ 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} @@ -2758,33 +2682,16 @@ 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} @@ -2792,21 +2699,10 @@ 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'} @@ -2943,11 +2839,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.11': - resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.1.11': - resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2957,20 +2853,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.11': - resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.11': - resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.11': - resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.11': - resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.11': - resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} @@ -3029,8 +2925,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.3.0: - resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -3128,8 +3024,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.11.15: - resolution: {integrity: sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==} + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} engines: {node: '>=6.0.0'} hasBin: true @@ -3158,8 +3054,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.8: - resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3346,8 +3242,8 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.1.0: - resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} convert-source-map@2.0.0: @@ -3510,8 +3406,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.1: - resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} defer-to-connect@2.0.1: @@ -3611,8 +3507,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.411: - resolution: {integrity: sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==} + electron-to-chromium@1.5.402: + resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} electron-winstaller@5.4.0: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} @@ -3700,8 +3596,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.2: - resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -3818,8 +3714,8 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.1.1: - resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -3854,13 +3750,13 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fallow-type-aware@3.17.0: - resolution: {integrity: sha512-DFtZUK5oqP56tSsrBWPzjrTIwPPxwlBV8QoSaGghMru347Nqj8ZBY6u9HFa/TIPcxvFU/zIZMRF8xS/XAWQTzg==} + fallow-type-aware@3.14.0: + resolution: {integrity: sha512-D8+bNELjmoVV2dKpmHplQ1YzuheHltmZKaOVJ//hx7h8vFNyp5H3hcJV8rR26ofBU51z9K5eyA7FpgP3hebjwg==} engines: {node: '>=20'} hasBin: true - fallow@3.17.0: - resolution: {integrity: sha512-tbLuvsPq3I2CzpoF/9MgSQ7hWzOqlj3avFUAmCr1J9OERE6z714SlDmA38NVvNy/zuK/70utXB3/b33xC9z29g==} + fallow@3.14.0: + resolution: {integrity: sha512-hse+sChmWkrffLiZDPGdNSyiLybVEhgoiYCIYgqLwdYhfTCO+f2PS1g+gMhGAJdLlYpZ75tFwRoMBFmZjN3Q6g==} engines: {node: '>=22'} hasBin: true @@ -4078,10 +3974,6 @@ 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'} @@ -4173,8 +4065,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.13.3: - resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + hono@4.13.1: + resolution: {integrity: sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -4250,8 +4142,8 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - input-otp@1.5.0: - resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} 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 @@ -4264,8 +4156,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.5.0: - resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -4517,9 +4409,6 @@ 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==} @@ -4564,9 +4453,6 @@ 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==} @@ -4809,8 +4695,8 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.32.0: - resolution: {integrity: sha512-txX56hMFnRxPi1f9/nH69YN8uvAO6a7Y1KSWKjCDAtdD9+soEgmWuCt6iRm1pkxUZo2+YntSdsE1L6bIuKoY8Q==} + lucide-react@1.31.0: + resolution: {integrity: sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5076,9 +4962,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz: - resolution: {integrity: sha512-MzqeWSaS2lVoiK0coNfY8EPgGNk7QYFS3eRqVYeYBCT0NNm2lQ7T8lLjBuIuMZ6Zh2KAVWhwI2xQIkMdZ7QThA==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz} - version: 0.3.0 + 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 nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} @@ -5179,8 +5065,8 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - open@11.0.1: - resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} open@8.4.2: @@ -5336,10 +5222,6 @@ 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'} @@ -5469,8 +5351,8 @@ packages: '@types/react': optional: true - react-resizable-panels@4.12.3: - resolution: {integrity: sha512-GHMJWnDXui/3RX4bT+cgBP+N3N2nkwcoZATr/2xLFpqQQe7TlBrE0cGM0dUa9ceT7A90tUrSRsiqgRG+g0/2AA==} + react-resizable-panels@4.12.2: + resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -5496,8 +5378,8 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - recast@0.23.21: - resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} + recast@0.23.19: + resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} engines: {node: '>= 4'} recharts@3.10.1: @@ -5750,8 +5632,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.18.0: - resolution: {integrity: sha512-tUFZgkYmfVNQVm3xX7lhSzOvDsp+O14ac5dwgXIr5mIsr79ISueb/Mu+ZtWMz0DH6v77u4eYyvbQ9TTMpSn3aw==} + shadcn@4.16.2: + resolution: {integrity: sha512-M1AvZKFWcCzWRDoyApIqJMSLIpY8Ev4uBGuiPLSFmiTbixXhPmzotSTvLzFmBrfoIxG9aIg2dZOETblEaXGUnQ==} engines: {node: '>=20.18.1'} hasBin: true @@ -5763,8 +5645,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.4.3: - resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + shiki@4.4.2: + resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -5800,18 +5682,10 @@ 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: @@ -6105,13 +5979,6 @@ 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'} @@ -6207,8 +6074,8 @@ packages: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} - update-browserslist-db@1.3.1: - resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + update-browserslist-db@1.3.0: + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -6334,20 +6201,20 @@ packages: yaml: optional: true - vitest@4.1.11: - resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} 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.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 + '@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 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6489,8 +6356,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - wsl-utils@1.0.0: - resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} xmlbuilder@15.1.1: @@ -6626,7 +6493,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.8 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -7306,14 +7173,14 @@ snapshots: dependencies: '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 - '@codemirror/commands@6.11.0': + '@codemirror/commands@6.10.4': dependencies: '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 '@codemirror/lang-css@6.3.1': @@ -7331,7 +7198,7 @@ snapshots: '@codemirror/lang-javascript': 6.2.5 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 '@lezer/css': 1.3.6 '@lezer/html': 1.3.13 @@ -7342,7 +7209,7 @@ snapshots: '@codemirror/language': 6.12.4 '@codemirror/lint': 6.9.7 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 '@lezer/javascript': 1.5.4 @@ -7352,14 +7219,14 @@ snapshots: '@codemirror/lang-html': 6.4.12 '@codemirror/language': 6.12.4 '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/view': 6.43.8 '@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.9 + '@codemirror/view': 6.43.8 '@lezer/common': 1.5.2 '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 @@ -7368,14 +7235,14 @@ snapshots: '@codemirror/lint@6.9.7': dependencies: '@codemirror/state': 6.7.1 - '@codemirror/view': 6.43.9 + '@codemirror/view': 6.43.8 crelt: 1.0.7 '@codemirror/state@6.7.1': dependencies: '@marijn/find-cluster-break': 1.0.3 - '@codemirror/view@6.43.9': + '@codemirror/view@6.43.8': dependencies: '@codemirror/state': 6.7.1 crelt: 1.0.7 @@ -7609,28 +7476,28 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@fallow-cli/darwin-arm64@3.17.0': + '@fallow-cli/darwin-arm64@3.14.0': optional: true - '@fallow-cli/darwin-x64@3.17.0': + '@fallow-cli/darwin-x64@3.14.0': optional: true - '@fallow-cli/linux-arm64-gnu@3.17.0': + '@fallow-cli/linux-arm64-gnu@3.14.0': optional: true - '@fallow-cli/linux-arm64-musl@3.17.0': + '@fallow-cli/linux-arm64-musl@3.14.0': optional: true - '@fallow-cli/linux-x64-gnu@3.17.0': + '@fallow-cli/linux-x64-gnu@3.14.0': optional: true - '@fallow-cli/linux-x64-musl@3.17.0': + '@fallow-cli/linux-x64-musl@3.14.0': optional: true - '@fallow-cli/win32-arm64-msvc@3.17.0': + '@fallow-cli/win32-arm64-msvc@3.14.0': optional: true - '@fallow-cli/win32-x64-msvc@3.17.0': + '@fallow-cli/win32-x64-msvc@3.14.0': optional: true '@floating-ui/core@1.8.0': @@ -7657,9 +7524,9 @@ snapshots: '@fontsource-variable/public-sans@5.3.0': {} - '@hono/node-server@2.1.1(hono@4.13.3)': + '@hono/node-server@2.1.0(hono@4.13.1)': dependencies: - hono: 4.13.3 + hono: 4.13.1 '@humanfs/core@0.19.2': dependencies: @@ -7785,12 +7652,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.11.0 + '@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.9 - '@shikijs/langs': 4.4.3 + '@codemirror/view': 6.43.8 + '@shikijs/langs': 4.4.2 '@tauri-apps/api': 2.11.1 class-variance-authority: 0.7.1 clsx: 2.1.1 @@ -7798,14 +7665,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.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + input-otp: 1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) katex: 0.18.4 - lucide-react: 1.32.0(react@19.2.8) + lucide-react: 1.31.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.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-resizable-panels: 4.12.2(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 @@ -7813,8 +7680,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.18.0(supports-color@7.2.0)(typescript@6.0.3) - shiki: 4.4.3 + shadcn: 4.16.2(supports-color@7.2.0)(typescript@6.0.3) + shiki: 4.4.2 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 @@ -7835,18 +7702,18 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(supports-color@7.2.0)(zod@3.25.76)': dependencies: - '@hono/node-server': 2.1.1(hono@4.13.3) + '@hono/node-server': 2.1.0(hono@4.13.1) 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.1 + eventsource-parser: 3.1.0 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.3 - jose: 6.2.9 + hono: 4.13.1 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -8211,40 +8078,40 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@shikijs/core@4.4.3': + '@shikijs/core@4.4.2': dependencies: - '@shikijs/primitive': 4.4.3 - '@shikijs/types': 4.4.3 + '@shikijs/primitive': 4.4.2 + '@shikijs/types': 4.4.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.4.3': + '@shikijs/engine-javascript@4.4.2': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.4.2 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.4.3': + '@shikijs/engine-oniguruma@4.4.2': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.4.2 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.4.3': + '@shikijs/langs@4.4.2': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.4.2 - '@shikijs/primitive@4.4.3': + '@shikijs/primitive@4.4.2': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.4.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - '@shikijs/themes@4.4.3': + '@shikijs/themes@4.4.2': dependencies: - '@shikijs/types': 4.4.3 + '@shikijs/types': 4.4.2 - '@shikijs/types@4.4.3': + '@shikijs/types@4.4.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -8470,15 +8337,6 @@ 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 @@ -8607,22 +8465,6 @@ 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 @@ -8635,18 +8477,6 @@ 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) @@ -8656,33 +8486,15 @@ 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 @@ -8695,22 +8507,8 @@ 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) @@ -8726,21 +8524,6 @@ 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)) @@ -8752,27 +8535,11 @@ 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 @@ -8840,44 +8607,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.11': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.1 - '@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/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))': dependencies: - '@vitest/spy': 4.1.11 + '@vitest/spy': 4.1.10 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.11': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.11': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.11 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.11': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.11 - '@vitest/utils': 4.1.11 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.11': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.11': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.11 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -8924,7 +8691,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.3.0: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: @@ -9061,14 +8828,14 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.11.15: {} + baseline-browser-mapping@2.11.12: {} bluebird@3.7.2: {} body-parser@2.3.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 - content-type: 2.1.0 + content-type: 2.0.0 debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.3 @@ -9099,13 +8866,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.8: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.15 + baseline-browser-mapping: 2.11.12 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.411 + electron-to-chromium: 1.5.402 node-releases: 2.0.53 - update-browserslist-db: 1.3.1(browserslist@4.28.8) + update-browserslist-db: 1.3.0(browserslist@4.28.7) buffer-from@1.1.2: {} @@ -9287,7 +9054,7 @@ snapshots: content-type@1.0.5: {} - content-type@2.1.0: {} + content-type@2.0.0: {} convert-source-map@2.0.0: {} @@ -9299,7 +9066,7 @@ snapshots: core-js-compat@3.50.0: dependencies: - browserslist: 4.28.8 + browserslist: 4.28.7 core-util-is@1.0.3: {} @@ -9422,7 +9189,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.1: + default-browser@5.5.0: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -9546,7 +9313,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.411: {} + electron-to-chromium@1.5.402: {} electron-winstaller@5.4.0(supports-color@7.2.0): dependencies: @@ -9686,7 +9453,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.2: {} + es-module-lexer@2.3.1: {} es-object-atoms@1.1.2: dependencies: @@ -9845,11 +9612,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.1.1: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.1 + eventsource-parser: 3.1.0 execa@5.1.1: dependencies: @@ -9886,7 +9653,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@7.2.0) express: 5.2.1(supports-color@7.2.0) - ip-address: 10.5.0 + ip-address: 10.4.0 transitivePeerDependencies: - supports-color @@ -9925,24 +9692,24 @@ snapshots: extend@3.0.2: {} - fallow-type-aware@3.17.0: + fallow-type-aware@3.14.0: dependencies: typescript: 7.0.2 optional: true - fallow@3.17.0: + fallow@3.14.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - '@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 + '@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 fast-deep-equal@3.1.3: {} @@ -10188,8 +9955,6 @@ snapshots: serialize-error: 7.0.1 optional: true - globals@17.11.0: {} - globals@17.9.0: {} globalthis@1.0.4: @@ -10345,7 +10110,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.13.3: {} + hono@4.13.1: {} hosted-git-info@4.1.0: dependencies: @@ -10416,7 +10181,7 @@ snapshots: inline-style-parser@0.2.7: {} - input-otp@1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + input-otp@1.4.2(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) @@ -10429,7 +10194,7 @@ snapshots: internmap@2.0.3: {} - ip-address@10.5.0: {} + ip-address@10.4.0: {} ipaddr.js@1.9.1: {} @@ -10634,8 +10399,6 @@ snapshots: jose@6.2.8: {} - jose@6.2.9: {} - js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -10667,12 +10430,6 @@ 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 @@ -10859,7 +10616,7 @@ snapshots: dependencies: react: 19.2.8 - lucide-react@1.32.0(react@19.2.8): + lucide-react@1.31.0(react@19.2.8): dependencies: react: 19.2.8 @@ -11328,7 +11085,7 @@ snapshots: ms@2.1.3: {} - mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz: + mtp@https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz: dependencies: yaml: 2.9.0 @@ -11426,14 +11183,14 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - open@11.0.1: + open@11.0.0: dependencies: - default-browser: 5.5.1 + default-browser: 5.5.0 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.2.0 - wsl-utils: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 open@8.4.2: dependencies: @@ -11592,8 +11349,6 @@ snapshots: powershell-utils@0.1.0: {} - powershell-utils@0.2.0: {} - prelude-ls@1.2.1: {} prettier@3.9.6: {} @@ -11719,7 +11474,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - react-resizable-panels@4.12.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-resizable-panels@4.12.2(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) @@ -11750,7 +11505,7 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - recast@0.23.21: + recast@0.23.19: dependencies: ast-types: 0.16.1 esprima: 4.0.1 @@ -12139,7 +11894,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.18.0(supports-color@7.2.0)(typescript@6.0.3): + shadcn@4.16.2(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 @@ -12148,7 +11903,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.8 + browserslist: 4.28.7 commander: 14.0.3 cosmiconfig: 9.0.2(typescript@6.0.3) dedent: 1.7.2 @@ -12159,13 +11914,12 @@ snapshots: fs-extra: 11.4.0 fuzzysort: 3.1.0 kleur: 4.1.5 - open: 11.0.1 + open: 11.0.0 ora: 8.2.0 postcss: 8.5.26 postcss-selector-parser: 7.1.5 prompts: 2.4.2 - recast: 0.23.21 - socks: 2.8.9 + recast: 0.23.19 stringify-object: 5.0.0 tailwind-merge: 3.6.0 ts-morph: 26.0.0 @@ -12186,14 +11940,14 @@ snapshots: shebang-regex@3.0.0: {} - shiki@4.4.3: + shiki@4.4.2: dependencies: - '@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/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/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 @@ -12237,15 +11991,8 @@ 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 @@ -12368,7 +12115,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.3.0 + ansi-regex: 6.2.2 strip-bom@3.0.0: {} @@ -12513,7 +12260,7 @@ snapshots: type-is@2.1.0: dependencies: - content-type: 2.1.0 + content-type: 2.0.0 media-typer: 1.1.1 mime-types: 3.0.2 @@ -12565,17 +12312,6 @@ 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: @@ -12693,9 +12429,9 @@ snapshots: upath@1.2.0: {} - update-browserslist-db@1.3.1(browserslist@4.28.8): + update-browserslist-db@1.3.0(browserslist@4.28.7): dependencies: - browserslist: 4.28.8 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 @@ -12802,16 +12538,16 @@ snapshots: 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)): + 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)): dependencies: - '@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 + '@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 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 @@ -13030,7 +12766,7 @@ snapshots: wrappy@1.0.2: {} - wsl-utils@1.0.0: + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6ec3531..ee3c57a 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.3.0-dev-c7c7afe/mtp-0.3.0.tgz" + mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz" diff --git a/utils/scripts/build-packages.ts b/utils/scripts/build-packages.ts index 1f1f4fd..9518c49 100644 --- a/utils/scripts/build-packages.ts +++ b/utils/scripts/build-packages.ts @@ -15,14 +15,7 @@ function getPackageDirs(dir: string): string[] { for (const entry of entries) { const fullPath = join(dir, entry); - if (entry === "node_modules" || entry.startsWith(".")) continue; - let stats; - try { - stats = statSync(fullPath); - } catch { - continue; - } - if (!stats.isDirectory()) continue; + if (!statSync(fullPath).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 c84e0e0..0aea8c3 100644 --- a/utils/scripts/lint-packages.ts +++ b/utils/scripts/lint-packages.ts @@ -16,14 +16,7 @@ function getPackageDirs(dir: string): string[] { for (const entry of entries) { const fullPath = join(dir, entry); - if (entry === "node_modules" || entry.startsWith(".")) continue; - let stats; - try { - stats = statSync(fullPath); - } catch { - continue; - } - if (!stats.isDirectory()) continue; + if (!statSync(fullPath).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 90b3b0c..22b258f 100644 --- a/utils/scripts/update-packages.ts +++ b/utils/scripts/update-packages.ts @@ -15,14 +15,7 @@ function getPackageDirs(dir: string): string[] { for (const entry of entries) { const fullPath = join(dir, entry); - if (entry === "node_modules" || entry.startsWith(".")) continue; - let stats; - try { - stats = statSync(fullPath); - } catch { - continue; - } - if (!stats.isDirectory()) continue; + if (!statSync(fullPath).isDirectory()) continue; if (existsSync(join(fullPath, "package.json"))) { dirs.push(fullPath);