diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 42761f9..c65efc2 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -22,7 +22,7 @@ jobs: with: path: | ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} - name: Cache Gradle uses: actions/cache@v4 diff --git a/.github/workflows/android-debug-build.yml b/.github/workflows/android-debug-build.yml index 0965989..7e486b2 100644 --- a/.github/workflows/android-debug-build.yml +++ b/.github/workflows/android-debug-build.yml @@ -23,7 +23,7 @@ jobs: with: path: | ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} - name: Cache Gradle uses: actions/cache@v4 @@ -78,7 +78,7 @@ jobs: x86_64-linux-android - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Patch Tauri identifier and Android path run: | diff --git a/.gitignore b/.gitignore index 57fe8e0..0fdc28b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* .vscode -bun.lock .prevgit **/*.pem **/*.jks diff --git a/README.md b/README.md index b813d82..20a8bdd 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,11 @@ -Что НЕ реализовано: -➖ Звонки, работа с папками, настройки приватности - Что реализовано: -➕ Вход, регистрация, работа с контактами, каналы и чаты, стикеры, медиа, веб-приложения (Сферум и Цифровой ID), работа с папками, сохранение удаленных сообщений, включение шифрования +➕ Двухслойное шифрование сообщений и медиа, сохранение сообщений, работа с несколькими аккаунтами, кружки и голосовые из файла, цифровой ID и сферум Что еще будет: -➕ Светлая тема, Telegram Proxy, разные интерфейсы, шифрованные звонки, поддержка кастомного сервера +➕ Безопасные шифруемые звонки, плагины, поддержка кастомных серверов ## Предостережения @@ -59,16 +56,13 @@ ### Особенности - Открытый исходный код -- Приложение не требует доступ к файлам устройства, микрофону, камере -- Прозрачность запросов к oneme[.]ru и ok[.]ru, можно просматривать каждый запрос в логах -- Сквозное E2E шифрование (можно включить для отдельных чатов) -- В планах: поддержка звонков, многопользовательских чатов +- Приложение весит всего от 5 до 20 МБ +- Написано с нуля, есть почти все из официального клиента +- Возможность шифровать все сообщения и медиафайлы +- Просмотр и блокировка запросов к oneme[.]ru и ok[.]ru Небольшой размер приложения достигается использованием системного WebView (Tauri) -> [!NOTE] -> Для покупки пива в Магните можно разрешить доступ к камере (QR-сканер) - ## Использование Предварительные версии доступны в разделе [Releases](https://github.com/me0wkie/maxplus/releases) @@ -221,4 +215,4 @@ $ rm -rf ~/.local/share/org.meowkie.max - [PyMax](https://github.com/noxzion/PyMax) — работа с Max API (портировано на Rust в репозитории [rumax](https://github.com/me0wkie/rumax)) - [Tauri](https://github.com/tauri-apps/tauri) — фреймворк для разработки приложений на WebView -- [libsodium-wrappers-sumo](https://www.npmjs.com/package/libsodium-wrappers-sumo) — криптография для сквозного шифрования +- [x25519-dalek](https://crates.io/crates/x25519-dalek), [ed25519-dalek](https://crates.io/crates/ed25519-dalek) и [chacha20poly1305](https://crates.io/crates/chacha20poly1305) — криптография для сквозного шифрования diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0e7074d --- /dev/null +++ b/bun.lock @@ -0,0 +1,296 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "maxplus", + "dependencies": { + "@choochmeque/tauri-plugin-notifications-api": "^0.5.0-rc.13", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-barcode-scanner": "^2.4.6", + "@tauri-apps/plugin-dialog": "^2.7.3", + "@tauri-apps/plugin-opener": "^2.5.5", + "@tauri-apps/plugin-os": "^2.3.2", + "jsqr": "^1.4.0", + "lottie-web": "^5.13.0", + "mp4-muxer": "^5.2.2", + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.64.0", + "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@tauri-apps/cli": "^2.11.4", + "svelte": "^5.56.3", + "svelte-check": "^4.6.0", + "typescript": "~5.6.3", + "vite": "^6.4.3", + }, + }, + }, + "packages": { + "@choochmeque/tauri-plugin-notifications-api": ["@choochmeque/tauri-plugin-notifications-api@0.5.0-rc.13", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-F+zQGsJuElZSyKi1RBSaOI5WydoNeJb4/KfrsyOT4SQMM2E/SNEhiknVmyiyGUYIAQKuMjmk++n4Jj82H9aQtQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.66.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-7nN4Ur4+nofZ36DVo83JbRe02m61Vc+I441mML/DYa1pUTZ/x26+lbrdqPen8gjmsUc6flMtHEqAtn0UfmfvAw=="], + + "@sveltejs/load-config": ["@sveltejs/load-config@0.1.1", "", {}, "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], + + "@tauri-apps/plugin-barcode-scanner": ["@tauri-apps/plugin-barcode-scanner@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9B2u+Sma002p1MzH5kGYFg8dPVNVbIHpyye6PY1A9AOeR7IOUAXS02VaUpnyXqJkUUnF/s2g7MgaB7wY9Ko6cg=="], + + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.3", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA=="], + + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.5", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-xvzGai5aQds8j8R8RsUK/lW6pGG50YgOYIPLzvkqmkwAj7dfySOD7sGtejRzvVdMmv1EQfKVEFh1MvmDp8QR0g=="], + + "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.19", "", {}, "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/wicg-file-system-access": ["@types/wicg-file-system-access@2020.9.8", "", {}, "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "jsqr": ["jsqr@1.4.0", "", {}, "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mp4-muxer": ["mp4-muxer@5.2.2", "", { "dependencies": { "@types/dom-webcodecs": "^0.1.6", "@types/wicg-file-system-access": "^2020.9.5" } }, "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.14", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "svelte": ["svelte@5.56.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA=="], + + "svelte-check": ["svelte-check@4.6.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "0.1.1", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + } +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 97e5286..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1996 +0,0 @@ -{ - "name": "maxplus", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "maxplus", - "version": "0.1.0", - "license": "MIT", - "dependencies": { - "@choochmeque/tauri-plugin-notifications-api": "^0.5.0-rc.13", - "@tauri-apps/api": "^2.11.1", - "@tauri-apps/plugin-barcode-scanner": "^2.4.6", - "@tauri-apps/plugin-biometric": "^2.3.3", - "@tauri-apps/plugin-dialog": "^2.7.3", - "@tauri-apps/plugin-http": "^2.6.0", - "@tauri-apps/plugin-notification": "^2.4.0", - "@tauri-apps/plugin-opener": "^2.5.5", - "@tauri-apps/plugin-os": "^2.3.2", - "@tauri-apps/plugin-stronghold": "^2.3.2", - "@tauri-apps/plugin-upload": "^2.4.1", - "fflate": "^0.8.3", - "jsqr": "^1.4.0", - "libsodium-wrappers-sumo": "^0.8.4", - "lottie-web": "^5.13.0", - "mp4-muxer": "^5.2.2", - "opus-recorder": "^8.0.5" - }, - "devDependencies": { - "@playwright/test": "^1.63.0", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.64.0", - "@sveltejs/vite-plugin-svelte": "^5.1.1", - "@tauri-apps/cli": "^2.11.4", - "svelte": "^5.56.3", - "svelte-check": "^4.6.0", - "typescript": "~5.6.3", - "vite": "^6.4.3" - } - }, - "node_modules/@choochmeque/tauri-plugin-notifications-api": { - "version": "0.5.0-rc.13", - "license": "MIT", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.63.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/adapter-static": { - "version": "3.0.10", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@sveltejs/kit": "^2.0.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.66.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.9", - "@types/cookie": "^0.6.0", - "acorn": "^8.16.0", - "cookie": "^0.6.0", - "devalue": "^5.8.1", - "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@sveltejs/load-config": { - "version": "0.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.4", - "@tauri-apps/cli-darwin-x64": "2.11.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", - "@tauri-apps/cli-linux-arm64-musl": "2.11.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-musl": "2.11.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", - "@tauri-apps/cli-win32-x64-msvc": "2.11.4" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/plugin-barcode-scanner": { - "version": "2.4.6", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-biometric": { - "version": "2.3.3", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.3", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-http": { - "version": "2.6.0", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-notification": { - "version": "2.4.0", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.5", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-os": { - "version": "2.3.2", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-stronghold": { - "version": "2.3.2", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@tauri-apps/plugin-upload": { - "version": "2.4.1", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/dom-webcodecs": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz", - "integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/wicg-file-system-access": { - "version": "2020.9.8", - "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", - "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.17.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/devalue": { - "version": "5.8.1", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.12", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fflate": { - "version": "0.8.3", - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/is-reference": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/jsqr": { - "version": "1.4.0", - "license": "Apache-2.0" - }, - "node_modules/kleur": { - "version": "4.1.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/libsodium-sumo": { - "version": "0.8.4", - "license": "ISC" - }, - "node_modules/libsodium-wrappers-sumo": { - "version": "0.8.4", - "license": "ISC", - "dependencies": { - "libsodium-sumo": "^0.8.0" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/lottie-web": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", - "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mp4-muxer": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz", - "integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==", - "deprecated": "This library is superseded by Mediabunny. Please migrate to it.", - "license": "MIT", - "dependencies": { - "@types/dom-webcodecs": "^0.1.6", - "@types/wicg-file-system-access": "^2020.9.5" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.14", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/opus-recorder": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/opus-recorder/-/opus-recorder-8.0.5.tgz", - "integrity": "sha512-tBRXc9Btds7i3bVfA7d5rekAlyOcfsivt5vSIXHxRV1Oa+s6iXFW8omZ0Lm3ABWotVcEyKt96iIIUcgbV07YOw==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.63.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "dev": true, - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/sirv": { - "version": "3.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/svelte": { - "version": "5.56.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.11", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "0.1.1", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index 07c3a74..d46845e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maxplus", - "version": "0.1.3", + "version": "0.1.4", "description": "", "type": "module", "scripts": { @@ -18,20 +18,12 @@ "@choochmeque/tauri-plugin-notifications-api": "^0.5.0-rc.13", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.4.6", - "@tauri-apps/plugin-biometric": "^2.3.3", "@tauri-apps/plugin-dialog": "^2.7.3", - "@tauri-apps/plugin-http": "^2.6.0", - "@tauri-apps/plugin-notification": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.5", "@tauri-apps/plugin-os": "^2.3.2", - "@tauri-apps/plugin-stronghold": "^2.3.2", - "@tauri-apps/plugin-upload": "^2.4.1", - "fflate": "^0.8.3", "jsqr": "^1.4.0", - "libsodium-wrappers-sumo": "^0.8.4", "lottie-web": "^5.13.0", - "mp4-muxer": "^5.2.2", - "opus-recorder": "^8.0.5" + "mp4-muxer": "^5.2.2" }, "devDependencies": { "@playwright/test": "^1.63.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ca4499a..0810120 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maxplus" -version = "0.1.3" +version = "0.1.4" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -23,39 +23,36 @@ tauri = { version = "2.11.2", features = ["protocol-asset"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-android-fs = "29.0.0" -tauri-plugin-stronghold = "2.3.2" tauri-plugin-notifications = { version = "0.5.0-rc.13", features = ["push-notifications"] } -tauri-plugin-http = "~2.6.0" tauri-plugin-opener = "2.5.5" -tauri-plugin-upload = "2.4.1" tauri-plugin-dialog = "2.7.3" tauri-plugin-os = "2.3.2" -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "fs", "io-util", "net"] } regex = "1.11.3" url = "2.5.7" uuid = { version = "1.18.1", features = ["v4"] } futures-util = "0.3.31" -webpki-roots = "1.0.4" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "blocking", "gzip", "brotli", "deflate"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "blocking", "gzip", "brotli", "deflate", "http2"] } rmp-serde = "1.3.0" -openssl = { version = "0.10", features = ["vendored"] } urlencoding = "2.1" tiny_http = "0.12.0" flate2 = "1.0" dirs = "6.0.0" mime_guess = "2.0.5" base64 = "0.22.1" -keyring = "3" -aes-gcm = "0.10" rand = "0.8" argon2 = "0.5" chrono = "0.4.45" chacha20poly1305 = "0.11.0" walkdir = "2.5.0" sha2 = "0.11.0" +hex = "0.4.3" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +ed25519-dalek = { version = "2.1", features = ["rand_core"] } jni = "0.22.4" tauri-plugin-single-instance = { version = "2.4.5", features = ["deep-link"] } tauri-plugin-deep-link = "2.4.10" +zip = { version = "2.2", default-features = false, features = ["deflate"] } [profile.dev] incremental = true @@ -63,14 +60,12 @@ incremental = true [profile.release] codegen-units = 1 lto = true -opt-level = "s" +opt-level = "z" panic = "abort" strip = true - [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] tauri-plugin-barcode-scanner = "2.4.6" -tauri-plugin-biometric = "2.3.3" [target.'cfg(target_os = "ios")'.dependencies] tauri-plugin-safe-area-insets-css = "0.2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5e4d951..edb9dd2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,13 +7,7 @@ "core:default", "opener:default", "dialog:default", - "upload:default", - "upload:allow-download", "notifications:default", - { - "identifier": "http:default", - "allow": [{ "url": "https://**" }] - }, "deep-link:default" ] } diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 85d47ea..70e3c74 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -2,5 +2,5 @@ "identifier": "mobile-capability", "platforms": ["android", "iOS"], "windows": ["main"], - "permissions": ["barcode-scanner:default", "biometric:default", "deep-link:default"] + "permissions": ["barcode-scanner:default", "deep-link:default"] } diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index d63335f..cfd5145 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -75,6 +75,7 @@ android { getByName("release") { signingConfig = signingConfigs.getByName("release") isMinifyEnabled = true + isShrinkResources = true proguardFiles( *fileTree(".") { include("**/*.pro") } .plus(getDefaultProguardFile("proguard-android-optimize.txt")) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 232a100..9a89cc0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -190,10 +190,14 @@ pub async fn set_token(state: State<'_, AppState>, token: String) -> Result) -> Result { - Ok(secret.inner().clone()) +pub async fn get_video_secret() -> Result { + Ok(crate::proxy_auth::get_video_token()) +} + +#[tauri::command] +pub async fn get_proxy_config() -> Result { + Ok(crate::proxy_auth::get_proxy_config()) } #[tauri::command] @@ -251,4 +255,69 @@ pub async fn get_system_trace_info(app: AppHandle) -> Result { })) } +#[derive(serde::Serialize, serde::Deserialize)] +pub struct ExtApiResponse { + pub status: u16, + pub ok: bool, + pub data: Value, +} + +#[tauri::command] +pub async fn ext_api_request( + method: String, + path: String, + headers: Option>, + body: Option, +) -> Result { + let base_url = "https://ext-api.max.ru"; + let url = if path.starts_with("https://") || path.starts_with("http://") { + path + } else if path.starts_with('/') { + format!("{}{}", base_url, path) + } else { + format!("{}/{}", base_url, path) + }; + + let client = rumax::shared_http_client(); + let req_method = match method.to_uppercase().as_str() { + "GET" => reqwest::Method::GET, + "POST" => reqwest::Method::POST, + "PUT" => reqwest::Method::PUT, + "DELETE" => reqwest::Method::DELETE, + "PATCH" => reqwest::Method::PATCH, + _ => return Err(format!("Unsupported method: {}", method)), + }; + + let mut req = client.request(req_method, &url); + if let Some(hdrs) = headers { + for (k, v) in hdrs { + let header_val = reqwest::header::HeaderValue::from_str(&v) + .or_else(|_| reqwest::header::HeaderValue::from_bytes(v.as_bytes())); + if let (Ok(name), Ok(val)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + header_val, + ) { + req = req.header(name, val); + } + } + } + + if let Some(b) = body { + req = req.json(&b); + } + + let res = req.send().await.map_err(|e| e.to_string())?; + let status = res.status().as_u16(); + let ok = res.status().is_success(); + let text = res.text().await.map_err(|e| e.to_string())?; + + let data = if text.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)) + }; + + Ok(ExtApiResponse { status, ok, data }) +} + diff --git a/src-tauri/src/crypto/asymmetric.rs b/src-tauri/src/crypto/asymmetric.rs new file mode 100644 index 0000000..902ad5a --- /dev/null +++ b/src-tauri/src/crypto/asymmetric.rs @@ -0,0 +1,199 @@ +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub struct KeypairBundle { + pub ed_sk: [u8; 32], + pub ed_pk: [u8; 32], + pub x_sk: [u8; 32], + pub x_pk: [u8; 32], +} + +pub fn generate_keypair_bundle() -> KeypairBundle { + let ed_signing = SigningKey::generate(&mut OsRng); + let ed_verifying = ed_signing.verifying_key(); + + let x_secret = StaticSecret::random_from_rng(OsRng); + let x_public = PublicKey::from(&x_secret); + + KeypairBundle { + ed_sk: ed_signing.to_bytes(), + ed_pk: ed_verifying.to_bytes(), + x_sk: x_secret.to_bytes(), + x_pk: *x_public.as_bytes(), + } +} + +pub fn compute_shared_secret(my_x_sk: &[u8; 32], peer_x_pk: &[u8; 32]) -> [u8; 32] { + let my_secret = StaticSecret::from(*my_x_sk); + let peer_public = PublicKey::from(*peer_x_pk); + let shared = my_secret.diffie_hellman(&peer_public); + + let mut hasher = Sha256::new(); + hasher.update(b"maxplus-e2e-session-v1"); + hasher.update(shared.as_bytes()); + let result = hasher.finalize(); + + let mut key = [0u8; 32]; + key.copy_from_slice(&result); + key +} + +pub fn sign_data(ed_sk: &[u8; 32], data: &[u8]) -> [u8; 64] { + let signing_key = SigningKey::from_bytes(ed_sk); + let signature = signing_key.sign(data); + signature.to_bytes() +} + +pub fn verify_signature(ed_pk: &[u8; 32], data: &[u8], sig_bytes: &[u8; 64]) -> bool { + let verifying_key = match VerifyingKey::from_bytes(ed_pk) { + Ok(k) => k, + Err(_) => return false, + }; + let signature = Signature::from_bytes(sig_bytes); + verifying_key.verify(data, &signature).is_ok() +} + +pub fn create_handshake_init( + ed_sk: &[u8; 32], + ed_pk: &[u8; 32], + x_pk: &[u8; 32], + timestamp: i64, +) -> Vec { + let mut data_to_sign = Vec::with_capacity(74); + data_to_sign.push(0x01); + data_to_sign.extend_from_slice(ed_pk); + data_to_sign.extend_from_slice(x_pk); + data_to_sign.extend_from_slice(×tamp.to_be_bytes()); + + let sig = sign_data(ed_sk, &data_to_sign); + + let mut packet = Vec::with_capacity(1 + 74 + 64); + packet.push(0x01); + packet.extend_from_slice(ed_pk); + packet.extend_from_slice(x_pk); + packet.extend_from_slice(×tamp.to_be_bytes()); + packet.extend_from_slice(&sig); + packet +} + +pub fn create_handshake_accept( + ed_sk: &[u8; 32], + ed_pk: &[u8; 32], + x_pk: &[u8; 32], + timestamp: i64, +) -> Vec { + let mut data_to_sign = Vec::with_capacity(74); + data_to_sign.push(0x02); + data_to_sign.extend_from_slice(ed_pk); + data_to_sign.extend_from_slice(x_pk); + data_to_sign.extend_from_slice(×tamp.to_be_bytes()); + + let sig = sign_data(ed_sk, &data_to_sign); + + let mut packet = Vec::with_capacity(1 + 74 + 64); + packet.push(0x02); + packet.extend_from_slice(ed_pk); + packet.extend_from_slice(x_pk); + packet.extend_from_slice(×tamp.to_be_bytes()); + packet.extend_from_slice(&sig); + packet +} + +#[allow(dead_code)] +pub struct ParsedHandshake { + pub subtype: u8, + pub ed_pk: [u8; 32], + pub x_pk: [u8; 32], + pub timestamp: i64, +} + +pub fn parse_and_verify_handshake(bytes: &[u8]) -> Result { + if bytes.len() != 1 + 32 + 32 + 8 + 64 { + return Err("Invalid handshake packet length".into()); + } + + let subtype = bytes[0]; + if subtype != 0x01 && subtype != 0x02 { + return Err("Unknown handshake subtype".into()); + } + + let mut ed_pk = [0u8; 32]; + ed_pk.copy_from_slice(&bytes[1..33]); + + let mut x_pk = [0u8; 32]; + x_pk.copy_from_slice(&bytes[33..65]); + + let ts_bytes: [u8; 8] = bytes[65..73].try_into().map_err(|e| format!("{e}"))?; + let timestamp = i64::from_be_bytes(ts_bytes); + + let mut sig = [0u8; 64]; + sig.copy_from_slice(&bytes[73..137]); + + let data_to_verify = &bytes[0..73]; + if !verify_signature(&ed_pk, data_to_verify, &sig) { + return Err("Invalid handshake signature".into()); + } + + Ok(ParsedHandshake { + subtype, + ed_pk, + x_pk, + timestamp, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dh_shared_secret_agreement() { + let alice = generate_keypair_bundle(); + let bob = generate_keypair_bundle(); + + let alice_shared = compute_shared_secret(&alice.x_sk, &bob.x_pk); + let bob_shared = compute_shared_secret(&bob.x_sk, &alice.x_pk); + + assert_eq!(alice_shared, bob_shared); + } + + #[test] + fn test_signature_verification() { + let bundle = generate_keypair_bundle(); + let data = b"synthetic verification payload"; + + let sig = sign_data(&bundle.ed_sk, data); + assert!(verify_signature(&bundle.ed_pk, data, &sig)); + + let corrupted = b"synthetic verification corrupted"; + assert!(!verify_signature(&bundle.ed_pk, corrupted, &sig)); + } + + #[test] + fn test_handshake_init_parse() { + let bundle = generate_keypair_bundle(); + let ts = 1700000000000; + let init_packet = create_handshake_init(&bundle.ed_sk, &bundle.ed_pk, &bundle.x_pk, ts); + + let parsed = parse_and_verify_handshake(&init_packet).unwrap(); + assert_eq!(parsed.subtype, 0x01); + assert_eq!(parsed.ed_pk, bundle.ed_pk); + assert_eq!(parsed.x_pk, bundle.x_pk); + assert_eq!(parsed.timestamp, ts); + } + + #[test] + fn test_handshake_accept_parse() { + let bundle = generate_keypair_bundle(); + let ts = 1700000005000; + let accept_packet = create_handshake_accept(&bundle.ed_sk, &bundle.ed_pk, &bundle.x_pk, ts); + + let parsed = parse_and_verify_handshake(&accept_packet).unwrap(); + assert_eq!(parsed.subtype, 0x02); + assert_eq!(parsed.ed_pk, bundle.ed_pk); + assert_eq!(parsed.x_pk, bundle.x_pk); + assert_eq!(parsed.timestamp, ts); + } +} diff --git a/src-tauri/src/crypto/commands.rs b/src-tauri/src/crypto/commands.rs new file mode 100644 index 0000000..84a9e54 --- /dev/null +++ b/src-tauri/src/crypto/commands.rs @@ -0,0 +1,678 @@ +use crate::crypto::asymmetric::{ + compute_shared_secret, create_handshake_accept, create_handshake_init, generate_keypair_bundle, + parse_and_verify_handshake, +}; +use crate::crypto::emoji::generate_fingerprint; +use crate::crypto::media::{decrypt_media_bytes, encrypt_media_bytes, MediaDescriptor}; +use crate::crypto::obfuscation::{ChineseObfuscator, DictionaryData, WordsObfuscator}; +use crate::crypto::protocol::{pack_message, unpack_message, PayloadData}; +use crate::stores::{crypto_key, Paths, Storage}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use futures_util::future::join_all; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct IncomingMessageDto { + pub id: Value, + pub text: Option, + pub sender: Option, + pub time: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DecryptedMessageDto { + pub text: String, + pub obf: Option, + pub is_encrypted: bool, + pub media: Option, + pub is_handshake_request: bool, + pub is_handshake_accept: bool, + pub handshake_data: Option, + pub error: Option, +} + +fn load_chat_settings_json(app: &AppHandle, account: u64, chat_id: i64) -> Value { + let key = crypto_key(app, account); + Storage::new(key) + .load(Paths::new(app, account).settings(chat_id)) + .unwrap_or_else(|| { + json!({ + "version": 1, + "keys": { + "current": null, + "keys": [], + "messages": [] + }, + "password": null, + "obfs": null, + "reader": true + }) + }) +} + +fn save_chat_settings_json( + app: &AppHandle, + account: u64, + chat_id: i64, + data: &Value, +) -> Result<(), String> { + let key = crypto_key(app, account); + Storage::new(key).save(Paths::new(app, account).settings(chat_id), data) +} + +fn get_active_session_key(settings: &Value) -> Option<[u8; 32]> { + let hex_key = settings + .get("session") + .and_then(|s| s.get("shared_secret")) + .and_then(|k| k.as_str())?; + + let bytes = hex::decode(hex_key).ok()?; + if bytes.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bytes); + Some(arr) + } else { + None + } +} + +fn load_dictionary_data(app: &AppHandle) -> Option { + let dict_path = app + .path() + .app_data_dir() + .ok()? + .join("data") + .join("dictionary"); + let stored: Value = Storage::new(None).load(&dict_path)?; + let data_val = stored.get("data")?; + serde_json::from_value(data_val.clone()).ok() +} + +#[tauri::command] +fn decrypt_single_message_dto( + msg: IncomingMessageDto, + session_key: Option<&[u8; 32]>, + effective_password: Option<&str>, + dict_opt: Option<&DictionaryData>, +) -> Option<(String, DecryptedMessageDto)> { + let msg_id_str = match &msg.id { + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + _ => return None, + }; + + let raw_text = match msg.text.as_deref() { + Some(t) if !t.trim().is_empty() => t.trim(), + _ => return None, + }; + + let mut obf_name: Option = None; + let mut raw_bytes: Option> = None; + + if ChineseObfuscator::detect(raw_text) { + if let Ok(b) = ChineseObfuscator::deobfuscate(raw_text) { + obf_name = Some("zh".into()); + raw_bytes = Some(b); + } + } + + if raw_bytes.is_none() { + if let Some(dict) = dict_opt { + if WordsObfuscator::detect(raw_text, dict) { + if let Ok(b) = WordsObfuscator::deobfuscate(raw_text, dict) { + obf_name = Some("words".into()); + raw_bytes = Some(b); + } + } + } + } + + if raw_bytes.is_none() { + if let Ok(b) = STANDARD.decode(raw_text) { + if !b.is_empty() && (b[0] >> 6) == 0 { + raw_bytes = Some(b); + } + } + } + + let bytes = match raw_bytes { + Some(b) => b, + None => return None, + }; + + let dto = match unpack_message(&bytes, session_key, effective_password) { + Ok(PayloadData::Text(t)) => DecryptedMessageDto { + text: t, + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: None, + }, + Ok(PayloadData::Media { text, media }) => DecryptedMessageDto { + text, + obf: obf_name, + is_encrypted: true, + media: Some(media), + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: None, + }, + Ok(PayloadData::Handshake(hs_bytes)) => { + let parsed = parse_and_verify_handshake(&hs_bytes); + match parsed { + Ok(p) => { + let is_init = p.subtype == 0x01; + let notice_text = if is_init { + "Запрос на секретный чат".to_string() + } else { + "Секретный чат установлен".to_string() + }; + DecryptedMessageDto { + text: notice_text, + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: is_init, + is_handshake_accept: !is_init, + handshake_data: Some(hex::encode(&hs_bytes)), + error: None, + } + } + Err(e) => DecryptedMessageDto { + text: "Ошибка проверки рукопожатия".into(), + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: Some(e), + }, + } + } + Err(e) => DecryptedMessageDto { + text: format!("Ошибка! {e}"), + obf: obf_name, + is_encrypted: true, + media: None, + is_handshake_request: false, + is_handshake_accept: false, + handshake_data: None, + error: Some(e), + }, + }; + + Some((msg_id_str, dto)) +} + +#[tauri::command] +pub async fn batch_decrypt_messages( + app: AppHandle, + account: u64, + chat_id: i64, + messages: Vec, + password: Option, +) -> Result, String> { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = get_active_session_key(&settings); + let effective_password = password.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + }); + + let dict_opt = Arc::new(load_dictionary_data(&app)); + let session_key = Arc::new(session_key); + let effective_password = Arc::new(effective_password); + + let tasks: Vec<_> = messages + .into_iter() + .map(|msg| { + let session_key = session_key.clone(); + let effective_password = effective_password.clone(); + let dict_opt = dict_opt.clone(); + tokio::task::spawn_blocking(move || { + decrypt_single_message_dto( + msg, + session_key.as_ref().as_ref(), + effective_password.as_deref(), + dict_opt.as_ref().as_ref(), + ) + }) + }) + .collect(); + + let results_vec = join_all(tasks).await; + + let mut results = HashMap::with_capacity(results_vec.len()); + for res in results_vec { + if let Ok(Some((msg_id_str, dto))) = res { + results.insert(msg_id_str, dto); + } + } + + Ok(results) +} + +#[tauri::command] +pub async fn encrypt_message( + app: AppHandle, + account: u64, + chat_id: i64, + text: String, + media: Option, + password: Option, + use_session: bool, + obf: Option, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let session_key = if use_session { + get_active_session_key(&settings) + } else { + None + }; + + let effective_password = password.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + .map(|s| s.to_string()) + }); + + let payload = match media { + Some(m) => PayloadData::Media { text, media: m }, + None => PayloadData::Text(text), + }; + + let packed = pack_message( + payload, + session_key.as_ref(), + effective_password.as_deref(), + )?; + + let target_obf = obf.or_else(|| { + settings + .get("obfs") + .and_then(|o| o.as_str()) + .map(|s| s.to_string()) + }); + + match target_obf.as_deref() { + Some("zh") => Ok(ChineseObfuscator::obfuscate(&packed)), + Some("words") => { + let dict = load_dictionary_data(&app) + .ok_or_else(|| "Dictionary not loaded. Please download dictionary in settings.".to_string())?; + WordsObfuscator::obfuscate(&packed, &dict) + } + _ => Ok(STANDARD.encode(&packed)), + } +} + +#[tauri::command] +pub async fn init_e2e_handshake( + app: AppHandle, + account: u64, + chat_id: i64, + obf: Option, +) -> Result { + let bundle = generate_keypair_bundle(); + let timestamp = Utc::now().timestamp_millis(); + + let mut settings = load_chat_settings_json(&app, account, chat_id); + settings["pending"] = json!({ + "ed_sk": hex::encode(bundle.ed_sk), + "ed_pk": hex::encode(bundle.ed_pk), + "x_sk": hex::encode(bundle.x_sk), + "x_pk": hex::encode(bundle.x_pk), + "timestamp": timestamp + }); + save_chat_settings_json(&app, account, chat_id, &settings)?; + + let hs_packet = + create_handshake_init(&bundle.ed_sk, &bundle.ed_pk, &bundle.x_pk, timestamp); + let packed = pack_message(PayloadData::Handshake(hs_packet), None, None)?; + + let target_obf = obf.unwrap_or_else(|| "zh".into()); + if target_obf == "words" { + if let Some(dict) = load_dictionary_data(&app) { + return WordsObfuscator::obfuscate(&packed, &dict); + } + } + + Ok(ChineseObfuscator::obfuscate(&packed)) +} + +#[tauri::command] +pub async fn accept_e2e_handshake( + app: AppHandle, + account: u64, + chat_id: i64, + handshake_data: String, + obf: Option, +) -> Result { + let hs_bytes = hex::decode(&handshake_data).map_err(|e| e.to_string())?; + let parsed = parse_and_verify_handshake(&hs_bytes)?; + + let my_bundle = generate_keypair_bundle(); + let shared_secret = compute_shared_secret(&my_bundle.x_sk, &parsed.x_pk); + let fingerprint = generate_fingerprint(&shared_secret); + let timestamp = Utc::now().timestamp_millis(); + + let mut settings = load_chat_settings_json(&app, account, chat_id); + settings["session"] = json!({ + "shared_secret": hex::encode(shared_secret), + "fingerprint": fingerprint, + "peer_ed_pk": hex::encode(parsed.ed_pk), + "peer_x_pk": hex::encode(parsed.x_pk), + "my_ed_sk": hex::encode(my_bundle.ed_sk), + "my_ed_pk": hex::encode(my_bundle.ed_pk), + "established_at": timestamp + }); + settings["keys"]["current"] = json!(1); + save_chat_settings_json(&app, account, chat_id, &settings)?; + + let hs_packet = create_handshake_accept( + &my_bundle.ed_sk, + &my_bundle.ed_pk, + &my_bundle.x_pk, + timestamp, + ); + let packed = pack_message(PayloadData::Handshake(hs_packet), None, None)?; + + let target_obf = obf.unwrap_or_else(|| "zh".into()); + if target_obf == "words" { + if let Some(dict) = load_dictionary_data(&app) { + return WordsObfuscator::obfuscate(&packed, &dict); + } + } + + Ok(ChineseObfuscator::obfuscate(&packed)) +} + +#[tauri::command] +pub async fn process_e2e_accept( + app: AppHandle, + account: u64, + chat_id: i64, + handshake_data: String, +) -> Result { + let mut settings = load_chat_settings_json(&app, account, chat_id); + + let existing_fingerprint = settings + .get("session") + .and_then(|s| s.get("fingerprint")) + .and_then(|f| f.as_str()) + .map(|s| s.to_string()); + + let pending = settings.get("pending"); + let x_sk_hex = pending.and_then(|p| p.get("x_sk")).and_then(|x| x.as_str()); + + let x_sk_hex = match x_sk_hex { + Some(hex) => hex, + None => { + if let Some(fp) = existing_fingerprint { + return Ok(fp); + } + return Err("Missing pending x_sk".to_string()); + } + }; + + let hs_bytes = hex::decode(&handshake_data).map_err(|e| e.to_string())?; + let parsed = parse_and_verify_handshake(&hs_bytes)?; + + let x_sk_vec = hex::decode(x_sk_hex).map_err(|e| e.to_string())?; + if x_sk_vec.len() != 32 { + return Err("Invalid x_sk length".into()); + } + let mut my_x_sk = [0u8; 32]; + my_x_sk.copy_from_slice(&x_sk_vec); + + let shared_secret = compute_shared_secret(&my_x_sk, &parsed.x_pk); + let fingerprint = generate_fingerprint(&shared_secret); + + settings["session"] = json!({ + "shared_secret": hex::encode(shared_secret), + "fingerprint": fingerprint.clone(), + "peer_ed_pk": hex::encode(parsed.ed_pk), + "peer_x_pk": hex::encode(parsed.x_pk), + "established_at": Utc::now().timestamp_millis() + }); + settings["keys"]["current"] = json!(1); + settings.as_object_mut().map(|o| o.remove("pending")); + save_chat_settings_json(&app, account, chat_id, &settings)?; + + Ok(fingerprint) +} + +#[tauri::command] +pub async fn get_chat_encryption_info( + app: AppHandle, + account: u64, + chat_id: i64, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let has_session = settings.get("session").is_some(); + let fingerprint = settings + .get("session") + .and_then(|s| s.get("fingerprint")) + .and_then(|f| f.as_str()) + .map(|s| s.to_string()); + + let has_pending = settings.get("pending").is_some(); + + Ok(json!({ + "active": has_session, + "fingerprint": fingerprint, + "pending": has_pending + })) +} + +#[tauri::command] +pub async fn make_dictionary(app: AppHandle, text: Value) -> Result { + let text_str = match text { + Value::String(s) => s, + Value::Array(arr) => { + let bytes: Vec = arr + .into_iter() + .filter_map(|v| v.as_u64().map(|b| b as u8)) + .collect(); + String::from_utf8_lossy(&bytes).to_string() + } + _ => return Err("Invalid dictionary text input".into()), + }; + + let dict = WordsObfuscator::build_dictionary(&text_str)?; + let val = serde_json::to_value(&dict).map_err(|e| e.to_string())?; + + let path = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("data") + .join("dictionary"); + + let mut store = Storage::new(None) + .load(&path) + .unwrap_or_else(|| json!({ "url": null, "data": null })); + store["data"] = val.clone(); + Storage::new(None).save(&path, &store)?; + + Ok(val) +} + +fn get_media_crypto_key(settings: &Value, password_arg: Option<&str>) -> Option<[u8; 32]> { + if let Some(sk) = get_active_session_key(settings) { + return Some(sk); + } + + let effective_password = password_arg.or_else(|| { + settings + .get("password") + .and_then(|p| p.as_str()) + }); + + if let Some(pwd) = effective_password { + let salt = b"maxplus_media_salt_bytes"; + if let Ok(derived) = crate::crypto::symmetric::derive_key(pwd, salt) { + return Some(derived); + } + } + + let mut hasher = Sha256::new(); + hasher.update(b"maxplus_media_obfuscation_fixed_key_v1"); + let res: [u8; 32] = hasher.finalize().into(); + Some(res) +} + +#[tauri::command] +pub async fn encrypt_media_file( + app: AppHandle, + account: u64, + chat_id: i64, + file_path: String, + dummy_type: Option, + password: Option, +) -> Result { + let settings = load_chat_settings_json(&app, account, chat_id); + let key = get_media_crypto_key(&settings, password.as_deref()) + .ok_or_else(|| "Failed to derive encryption key for media".to_string())?; + + let raw_bytes = fs::read(&file_path).map_err(|e| e.to_string())?; + let dummy = dummy_type.as_deref().unwrap_or("pdf"); + let encrypted = encrypt_media_bytes(&raw_bytes, &key, dummy)?; + + let ext = match dummy { + "docx" => "docx", + "xlsx" => "xlsx", + "mp3" => "mp3", + "ogg" => "ogg", + _ => "pdf", + }; + + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|e| e.to_string())? + .join("encrypted_outgoing"); + let _ = fs::create_dir_all(&cache_dir); + let rand_id = uuid::Uuid::new_v4().to_string(); + let out_path = cache_dir.join(format!("{}.{}", rand_id, ext)); + fs::write(&out_path, encrypted).map_err(|e| e.to_string())?; + + Ok(out_path.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn decrypt_media_file( + app: AppHandle, + account: u64, + chat_id: i64, + file_path: String, + out_path: Option, + target_path: Option, + password: Option, +) -> Result { + let effective_out = out_path + .or(target_path) + .ok_or_else(|| "Missing out_path or target_path".to_string())?; + + let settings = load_chat_settings_json(&app, account, chat_id); + let key = get_media_crypto_key(&settings, password.as_deref()) + .ok_or_else(|| "Failed to derive decryption key for media".to_string())?; + + let encrypted_bytes = fs::read(&file_path).map_err(|e| e.to_string())?; + let decrypted = decrypt_media_bytes(&encrypted_bytes, &key)?; + + if let Some(parent) = Path::new(&effective_out).parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&effective_out, decrypted).map_err(|e| e.to_string())?; + + if file_path.ends_with(".enc") { + let _ = fs::remove_file(&file_path); + } + + Ok(effective_out) +} + +#[tauri::command] +pub async fn cache_encrypted_media( + app: AppHandle, + account: u64, + chat_id: i64, + file_id: u64, + src: String, + password: Option, +) -> Result { + let cache_key = format!("enc_media_{}", file_id); + + if let Ok(Some(existing)) = crate::stores::get_cached_file(app.clone(), account, cache_key.clone()) { + if Path::new(&existing).exists() { + return Ok(existing); + } + } + + let encrypted_bytes = if src.starts_with("http://") || src.starts_with("https://") { + let client = rumax::shared_http_client(); + let resp = client.get(&src) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.bytes().await.map_err(|e| e.to_string())?.to_vec() + } else { + fs::read(&src).map_err(|e| e.to_string())? + }; + + let settings = load_chat_settings_json(&app, account, chat_id); + let key = get_media_crypto_key(&settings, password.as_deref()) + .ok_or_else(|| "Failed to derive decryption key for media".to_string())?; + + let decrypted = decrypt_media_bytes(&encrypted_bytes, &key)?; + + let path = crate::stores::set_cached_file_with_meta( + app, + account, + cache_key, + decrypted, + Some(chat_id), + None, + )?; + + Ok(path) +} + +#[tauri::command] +pub async fn register_media_cache( + app: AppHandle, + account: u64, + chat_id: i64, + file_id: u64, + local_path: String, +) -> Result { + let cache_key = format!("enc_media_{}", file_id); + let bytes = fs::read(&local_path).map_err(|e| e.to_string())?; + crate::stores::set_cached_file_with_meta( + app, + account, + cache_key, + bytes, + Some(chat_id), + None, + ) +} diff --git a/src-tauri/src/crypto/emoji.rs b/src-tauri/src/crypto/emoji.rs new file mode 100644 index 0000000..2d98411 --- /dev/null +++ b/src-tauri/src/crypto/emoji.rs @@ -0,0 +1,50 @@ +use sha2::{Digest, Sha256}; + +pub const EMOJI_TABLE: [&str; 256] = [ + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", + "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", + "🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🦇", "🐺", + "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", + "🐜", "🦟", "🐢", "🐍", "🦎", "🐙", "🦑", "🦐", + "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "鲨", + "🐊", "🐅", "🐆", "🦓", "🦍", "🐘", "🦛", "🦏", + "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", + "🐖", "🐏", "🐑", "🐐", "🦌", "🐕", "🐩", "🐈", + "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", + "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", + "🦔", "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", + "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", + "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", + "🌶", "🌽", "🥕", "🧄", "🧅", "🥔", "🍠", "🥐", + "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", + "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🌭", "🍔", + "🍟", "🍕", "🥪", "🥙", "🧆", "🌮", "🌯", "🥗", + "🥘", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", + "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🍢", + "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", + "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", + "🥜", "🍯", "🥛", "☕", "🍵", "🧃", "🥤", "🧊", + "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", + "🥏", "🎱", "🪀", "🏓", "🏸", "🏒", "🥍", "🏏", + "🥊", "🥋", "🥅", "⛳", "🏹", "🎣", "🤿", "🎽", + "🛹", "🛼", "🛷", "⛸", "🎯", "🪄", "🪅", "🎮", + "🚀", "🛸", "🚁", "⛵", "🛶", "🚤", "⚓", "⚡", + "🌙", "⭐", "🌟", "✨", "💥", "🔥", "🌈", "☀️", + "💎", "🔔", "🔑", "🗝", "🎸", "🎷", "🎺", "🎻", + "🥁", "🎹", "🎙", "📻", "🎨", "🎭", "🎬", "🎪", + "🧭", "⏰", "⏱", "⌛", "💡", "🔦", "🕯", "🪔", +]; + +pub fn generate_fingerprint(shared_secret: &[u8; 32]) -> String { + let mut hasher = Sha256::new(); + hasher.update(shared_secret); + let hash = hasher.finalize(); + + format!( + "{} {} {} {}", + EMOJI_TABLE[hash[0] as usize], + EMOJI_TABLE[hash[1] as usize], + EMOJI_TABLE[hash[2] as usize], + EMOJI_TABLE[hash[3] as usize] + ) +} diff --git a/src-tauri/src/crypto/media.rs b/src-tauri/src/crypto/media.rs new file mode 100644 index 0000000..6fc9835 --- /dev/null +++ b/src-tauri/src/crypto/media.rs @@ -0,0 +1,120 @@ +use crate::crypto::symmetric; +use rand::{RngCore, thread_rng}; +use serde::{Deserialize, Serialize}; + +pub const BINARY_MAGIC: [u8; 2] = [0x8F, 0x3D]; +const LEGACY_MAGIC: &[u8] = b"MAXMEDIA\x01"; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MediaDescriptor { + pub attach_index: usize, + pub name: String, + pub mime: String, + pub media_type: String, + pub size: u64, + pub width: Option, + pub height: Option, + pub duration: Option, + #[serde(default)] + pub wave: Option>, + #[serde(default)] + pub video_type: Option, + #[serde(default)] + pub color: Option, +} + +pub fn encrypt_media_bytes( + raw_bytes: &[u8], + key: &[u8; 32], + dummy_type: &str, +) -> Result, String> { + let mut nonce = [0u8; 12]; + thread_rng().fill_bytes(&mut nonce); + + let ciphertext = symmetric::encrypt(key, &nonce, raw_bytes, Some(&BINARY_MAGIC))?; + + let dummy_header: &[u8] = match dummy_type { + "docx" | "xlsx" => b"PK\x03\x04\x14\x00\x08\x00\x08\x00", + "mp3" => b"ID3\x03\x00\x00\x00\x00\x00", + "ogg" => b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00", + _ => b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n", + }; + + let mut out = Vec::with_capacity(dummy_header.len() + BINARY_MAGIC.len() + 12 + ciphertext.len()); + out.extend_from_slice(dummy_header); + out.extend_from_slice(&BINARY_MAGIC); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + + Ok(out) +} + +pub fn decrypt_media_bytes(encrypted_bytes: &[u8], key: &[u8; 32]) -> Result, String> { + let marker_pos = encrypted_bytes + .windows(BINARY_MAGIC.len()) + .position(|window| window == BINARY_MAGIC); + + if let Some(pos) = marker_pos { + let payload = &encrypted_bytes[pos + BINARY_MAGIC.len()..]; + if payload.len() >= 28 { + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&payload[..12]); + let ciphertext = &payload[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, Some(&BINARY_MAGIC)) { + return Ok(dec); + } + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } + } + + let legacy_pos = encrypted_bytes + .windows(LEGACY_MAGIC.len()) + .position(|window| window == LEGACY_MAGIC); + + if let Some(pos) = legacy_pos { + let payload = &encrypted_bytes[pos + LEGACY_MAGIC.len()..]; + if payload.len() >= 28 { + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&payload[..12]); + let ciphertext = &payload[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, Some(LEGACY_MAGIC)) { + return Ok(dec); + } + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } + } + + let known_headers: &[&[u8]] = &[ + b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n", + b"PK\x03\x04\x14\x00\x08\x00\x08\x00", + b"ID3\x03\x00\x00\x00\x00\x00", + b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00", + ]; + + for hdr in known_headers { + if encrypted_bytes.starts_with(hdr) && encrypted_bytes.len() >= hdr.len() + 28 { + let payload = &encrypted_bytes[hdr.len()..]; + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&payload[..12]); + let ciphertext = &payload[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } + } + + if encrypted_bytes.len() >= 28 { + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&encrypted_bytes[..12]); + let ciphertext = &encrypted_bytes[12..]; + if let Ok(dec) = symmetric::decrypt(key, &nonce, ciphertext, None) { + return Ok(dec); + } + } + + Err("Failed to decrypt media bytes: invalid key or corrupted envelope".into()) +} diff --git a/src-tauri/src/crypto/mod.rs b/src-tauri/src/crypto/mod.rs new file mode 100644 index 0000000..b886c44 --- /dev/null +++ b/src-tauri/src/crypto/mod.rs @@ -0,0 +1,7 @@ +pub mod asymmetric; +pub mod commands; +pub mod emoji; +pub mod media; +pub mod obfuscation; +pub mod protocol; +pub mod symmetric; diff --git a/src-tauri/src/crypto/obfuscation.rs b/src-tauri/src/crypto/obfuscation.rs new file mode 100644 index 0000000..52f19dd --- /dev/null +++ b/src-tauri/src/crypto/obfuscation.rs @@ -0,0 +1,414 @@ +use rand::Rng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; + +const ZH_BASE: u32 = 0x4E00; +const ZH_COUNT: usize = 2048; + +pub struct ChineseObfuscator; + +impl ChineseObfuscator { + pub fn detect(text: &str) -> bool { + let chars: Vec = text.chars().take(5).collect(); + if chars.len() < 5 { + return false; + } + + for c in chars { + let cp = c as u32; + if cp < ZH_BASE || cp >= ZH_BASE + ZH_COUNT as u32 { + return false; + } + let idx = cp - ZH_BASE; + if (idx & 1) != 0 { + return false; + } + } + + true + } + + pub fn obfuscate(bytes: &[u8]) -> String { + let mut rng = rand::thread_rng(); + let mut marker = String::with_capacity(5); + let max_even = (ZH_COUNT - 1) / 2; + for _ in 0..5 { + let r = rng.gen_range(0..=max_even); + let idx = (r * 2) as u32; + if let Some(ch) = char::from_u32(ZH_BASE + idx) { + marker.push(ch); + } + } + + let len = bytes.len() as u16; + let mut payload_bytes = Vec::with_capacity(2 + bytes.len()); + payload_bytes.extend_from_slice(&len.to_be_bytes()); + payload_bytes.extend_from_slice(bytes); + + let bits_per = 11; + let mut bit_buffer: u64 = 0; + let mut bit_count = 0; + let mut out = marker; + + for &b in &payload_bytes { + bit_buffer = (bit_buffer << 8) | (b as u64); + bit_count += 8; + while bit_count >= bits_per { + bit_count -= bits_per; + let idx = ((bit_buffer >> bit_count) & ((1 << bits_per) - 1)) as u32; + if let Some(ch) = char::from_u32(ZH_BASE + idx) { + out.push(ch); + } + bit_buffer &= (1 << bit_count) - 1; + } + } + + if bit_count > 0 { + let idx = ((bit_buffer << (bits_per - bit_count)) & ((1 << bits_per) - 1)) as u32; + if let Some(ch) = char::from_u32(ZH_BASE + idx) { + out.push(ch); + } + } + + out + } + + pub fn deobfuscate(text: &str) -> Result, String> { + let chars: Vec = text.chars().collect(); + if chars.len() < 5 { + return Err("Text too short for Chinese deobfuscation".into()); + } + + let payload_chars = &chars[5..]; + let bits_per = 11; + let mut bit_buffer: u64 = 0; + let mut bit_count = 0; + let mut raw_bytes = Vec::new(); + + for &ch in payload_chars { + let cp = ch as u32; + if cp < ZH_BASE || cp >= ZH_BASE + ZH_COUNT as u32 { + continue; + } + let idx = cp - ZH_BASE; + bit_buffer = (bit_buffer << bits_per) | (idx as u64); + bit_count += bits_per; + while bit_count >= 8 { + bit_count -= 8; + let byte = ((bit_buffer >> bit_count) & 0xFF) as u8; + raw_bytes.push(byte); + bit_buffer &= (1 << bit_count) - 1; + } + } + + if raw_bytes.len() < 2 { + return Err("Payload too short".into()); + } + + let expected_len = u16::from_be_bytes([raw_bytes[0], raw_bytes[1]]) as usize; + let content = &raw_bytes[2..]; + if content.len() < expected_len { + return Err("Corrupted payload length".into()); + } + + Ok(content[..expected_len].to_vec()) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DictionaryData { + pub dict_sha256: String, + pub punct: Vec<(String, u32)>, + pub dict8: Vec, + pub dict16: Vec, +} + +pub struct WordsObfuscator; + +impl WordsObfuscator { + pub fn build_dictionary(text: &str) -> Result { + let mut punct_count: HashMap = HashMap::new(); + let punctuation = ['.', ',', '?', '!', '—', ':']; + for c in text.chars() { + if punctuation.contains(&c) { + *punct_count.entry(c).or_insert(0) += 1; + } + } + + let re = regex::Regex::new(r"[А-Яа-яЁё]+").map_err(|e| e.to_string())?; + let mut normalized: Vec = re + .find_iter(text) + .map(|m| m.as_str().to_lowercase()) + .collect(); + normalized.sort(); + + let mut unique = Vec::new(); + let mut seen = HashSet::new(); + for w in normalized { + if seen.insert(w.clone()) { + unique.push(w); + } + } + + let min_size = 65536 + 256; + if unique.len() < min_size { + return Err(format!( + "Not enough unique words: have {}, need {}", + unique.len(), + min_size + )); + } + + let mut dict8 = Vec::with_capacity(256); + let mut dict16 = Vec::with_capacity(65536); + + for (i, word) in unique.into_iter().enumerate() { + if i % 257 == 0 && dict8.len() < 256 { + dict8.push(word); + } else if dict16.len() < 65536 { + dict16.push(word); + } + } + + if dict8.len() < 256 || dict16.len() < 65536 { + return Err("Failed to construct complete dictionary tables".into()); + } + + let dict_concat = format!("{} {}", dict8.join(" "), dict16.join(" ")); + let mut hasher = Sha256::new(); + hasher.update(dict_concat.as_bytes()); + let dict_sha256 = hex::encode(hasher.finalize()); + + let words_gross = text.split_whitespace().count().max(1) as u32; + let mut punct = Vec::new(); + for &p in &punctuation { + let count = punct_count.get(&p).copied().unwrap_or(0); + let prob = (count * 10000) / words_gross; + punct.push((p.to_string(), prob)); + } + + Ok(DictionaryData { + dict_sha256, + punct, + dict8, + dict16, + }) + } + + pub fn detect(text: &str, dict: &DictionaryData) -> bool { + if text.contains('\n') || !text.contains(' ') { + return false; + } + + let words: Vec<&str> = text.split_whitespace().collect(); + if words.len() < 2 { + return false; + } + + let clean_first = clean_word(words[0]); + let clean_second = clean_word(words[1]); + + let dict8_map: HashMap<&str, usize> = dict + .dict8 + .iter() + .enumerate() + .map(|(i, w)| (w.as_str(), i)) + .collect(); + + let first_idx = match dict8_map.get(clean_first.as_str()) { + Some(&idx) => idx, + None => return false, + }; + + let second_idx = match dict8_map.get(clean_second.as_str()) { + Some(&idx) => idx, + None => return false, + }; + + let mini_hash = match u8::from_str_radix(&dict.dict_sha256[..1], 16) { + Ok(h) => (h & 0x0F) as usize, + Err(_) => return false, + }; + + let expected_hash = first_idx & 0x0F; + if expected_hash != mini_hash { + return false; + } + + let seed = (first_idx >> 4) & 0x0F; + let expected_second = ((seed ^ mini_hash ^ 0x5A) & 0xFF) % dict.dict8.len(); + + second_idx == expected_second + } + + pub fn obfuscate(bytes: &[u8], dict: &DictionaryData) -> Result { + let mut rng = rand::thread_rng(); + let seed = rng.gen_range(0..16) as usize; + let mini_hash = u8::from_str_radix(&dict.dict_sha256[..1], 16).map_err(|e| e.to_string())? as usize & 0x0F; + + let prefix_0 = (seed << 4) | mini_hash; + let first_word = &dict.dict8[prefix_0 % dict.dict8.len()]; + + let prefix_1 = ((seed ^ mini_hash ^ 0x5A) & 0xFF) % dict.dict8.len(); + let second_word = &dict.dict8[prefix_1]; + + let mut out = Vec::new(); + out.push(capitalize(first_word)); + out.push(capitalize(second_word)); + + let len16 = dict.dict16.len(); + let len8 = dict.dict8.len(); + + let mut i = 0; + let mut sentence_start = false; + + let mut punct_ranges = Vec::new(); + let mut cumulative = 0; + for (char_str, prob) in &dict.punct { + let start = cumulative; + cumulative += prob; + if let Some(ch) = char_str.chars().next() { + punct_ranges.push((start, cumulative.saturating_sub(1), ch)); + } + } + + while i < bytes.len() { + let mut word = if i + 1 < bytes.len() { + let idx = ((bytes[i] as usize) << 8) | (bytes[i + 1] as usize); + i += 2; + dict.dict16[(idx + seed) % len16].clone() + } else { + let idx = bytes[i] as usize; + i += 1; + dict.dict8[(idx + seed) % len8].clone() + }; + + if sentence_start { + word = capitalize(&word); + sentence_start = false; + } + + out.push(word); + + let roll: u32 = rng.gen_range(0..10000); + for &(start, end, ch) in &punct_ranges { + if roll >= start && roll <= end { + if ch == '—' { + out.push("—".to_string()); + } else { + if let Some(last) = out.last_mut() { + last.push(ch); + } + if ch == '.' || ch == '!' || ch == '?' { + sentence_start = true; + } + } + break; + } + } + } + + let mut text = out.join(" "); + if !text.ends_with('.') && !text.ends_with('!') && !text.ends_with('?') { + text.push('.'); + } + + Ok(text) + } + + pub fn deobfuscate(text: &str, dict: &DictionaryData) -> Result, String> { + let tokens: Vec<&str> = text.split_whitespace().collect(); + if tokens.len() < 2 { + return Err("Too few tokens for word deobfuscation".into()); + } + + let dict8_map: HashMap<&str, usize> = dict + .dict8 + .iter() + .enumerate() + .map(|(i, w)| (w.as_str(), i)) + .collect(); + let dict16_map: HashMap<&str, usize> = dict + .dict16 + .iter() + .enumerate() + .map(|(i, w)| (w.as_str(), i)) + .collect(); + + let first = clean_word(tokens[0]); + let prefix_0 = dict8_map + .get(first.as_str()) + .copied() + .ok_or_else(|| format!("Unknown prefix word: {first}"))?; + let seed = (prefix_0 >> 4) & 0x0F; + + let len16 = dict.dict16.len(); + let len8 = dict.dict8.len(); + + let mut bytes = Vec::new(); + + for &tok in &tokens[2..] { + let cleaned = clean_word(tok); + if cleaned.is_empty() || cleaned == "—" { + continue; + } + + if let Some(&idx16) = dict16_map.get(cleaned.as_str()) { + let real_idx = (idx16 + len16 - (seed % len16)) % len16; + bytes.push(((real_idx >> 8) & 0xFF) as u8); + bytes.push((real_idx & 0xFF) as u8); + continue; + } + + if let Some(&idx8) = dict8_map.get(cleaned.as_str()) { + let real_idx = (idx8 + len8 - (seed % len8)) % len8; + bytes.push(real_idx as u8); + continue; + } + + return Err(format!("Word not found in dictionary: {cleaned}")); + } + + Ok(bytes) + } +} + +fn clean_word(s: &str) -> String { + s.chars() + .filter(|&c| c != '.' && c != ',' && c != '?' && c != '!' && c != ':' && c != '—' && c != '-') + .collect::() + .to_lowercase() +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chinese_roundtrip_various_lengths() { + for len in [1, 2, 3, 5, 7, 10, 11, 16, 22, 33, 64, 128] { + let original: Vec = (0..len).map(|i| (i * 17) as u8).collect(); + let obfuscated = ChineseObfuscator::obfuscate(&original); + assert!(ChineseObfuscator::detect(&obfuscated)); + + let deobfuscated = ChineseObfuscator::deobfuscate(&obfuscated).unwrap(); + assert_eq!(deobfuscated, original, "Failed for length {}", len); + } + } + + #[test] + fn test_chinese_detect_rejects_plain_text() { + assert!(!ChineseObfuscator::detect("Hello world")); + assert!(!ChineseObfuscator::detect("Привет мир")); + assert!(!ChineseObfuscator::detect("")); + } +} diff --git a/src-tauri/src/crypto/protocol.rs b/src-tauri/src/crypto/protocol.rs new file mode 100644 index 0000000..59d43f6 --- /dev/null +++ b/src-tauri/src/crypto/protocol.rs @@ -0,0 +1,330 @@ +use crate::crypto::media::MediaDescriptor; +use crate::crypto::symmetric; +use flate2::read::DeflateDecoder; +use flate2::write::DeflateEncoder; +use flate2::Compression; +use rand::{RngCore, thread_rng}; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; + +pub const PROTOCOL_VERSION: u8 = 0x00; + +pub const FLAG_SESSION: u8 = 1 << 5; +pub const FLAG_PASSWORD: u8 = 1 << 4; +pub const FLAG_COMPRESSED: u8 = 1 << 3; +pub const FLAG_CONTROL: u8 = 1 << 2; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum PayloadData { + Text(String), + Media { + text: String, + media: MediaDescriptor, + }, + Handshake(Vec), +} + +pub struct Header { + pub version: u8, + pub has_session: bool, + pub has_password: bool, + pub is_compressed: bool, + pub is_control: bool, +} + +impl Header { + pub fn encode(&self) -> u8 { + ((self.version & 0x03) << 6) + | if self.has_session { FLAG_SESSION } else { 0 } + | if self.has_password { FLAG_PASSWORD } else { 0 } + | if self.is_compressed { FLAG_COMPRESSED } else { 0 } + | if self.is_control { FLAG_CONTROL } else { 0 } + } + + pub fn decode(byte: u8) -> Self { + Self { + version: (byte >> 6) & 0x03, + has_session: (byte & FLAG_SESSION) != 0, + has_password: (byte & FLAG_PASSWORD) != 0, + is_compressed: (byte & FLAG_COMPRESSED) != 0, + is_control: (byte & FLAG_CONTROL) != 0, + } + } +} + +pub fn compress_bytes(bytes: &[u8]) -> Result, String> { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(bytes).map_err(|e| e.to_string())?; + encoder.finish().map_err(|e| e.to_string()) +} + +pub fn decompress_bytes(bytes: &[u8]) -> Result, String> { + let mut decoder = DeflateDecoder::new(bytes); + let mut out = Vec::new(); + decoder.read_to_end(&mut out).map_err(|e| e.to_string())?; + Ok(out) +} + +pub fn pack_message( + payload: PayloadData, + session_key: Option<&[u8; 32]>, + password: Option<&str>, +) -> Result, String> { + let is_control = matches!(payload, PayloadData::Handshake(_)); + let mut raw_bytes = match payload { + PayloadData::Text(t) => { + let mut b = Vec::with_capacity(1 + t.len()); + b.push(0x01); + b.extend_from_slice(t.as_bytes()); + b + } + PayloadData::Media { text, media } => { + let mut b = Vec::new(); + b.push(0x02); + let encoded = rmp_serde::to_vec(&(text, media)).map_err(|e| e.to_string())?; + b.extend_from_slice(&encoded); + b + } + PayloadData::Handshake(bytes) => bytes, + }; + + let should_compress = !is_control && raw_bytes.len() >= 64; + let mut is_compressed = false; + if should_compress { + if let Ok(compressed) = compress_bytes(&raw_bytes) { + if compressed.len() < raw_bytes.len() { + raw_bytes = compressed; + is_compressed = true; + } + } + } + + let has_session = session_key.is_some(); + if let Some(s_key) = session_key { + let mut nonce = [0u8; 12]; + thread_rng().fill_bytes(&mut nonce); + let ciphertext = symmetric::encrypt(s_key, &nonce, &raw_bytes, None)?; + let mut session_payload = Vec::with_capacity(12 + ciphertext.len()); + session_payload.extend_from_slice(&nonce); + session_payload.extend_from_slice(&ciphertext); + raw_bytes = session_payload; + } + + let has_password = password.is_some(); + if let Some(pwd) = password { + let mut salt = [0u8; 16]; + let mut nonce = [0u8; 12]; + thread_rng().fill_bytes(&mut salt); + thread_rng().fill_bytes(&mut nonce); + + let p_key = symmetric::derive_key(pwd, &salt)?; + let ciphertext = symmetric::encrypt(&p_key, &nonce, &raw_bytes, None)?; + + let mut pass_payload = Vec::with_capacity(16 + 12 + ciphertext.len()); + pass_payload.extend_from_slice(&salt); + pass_payload.extend_from_slice(&nonce); + pass_payload.extend_from_slice(&ciphertext); + raw_bytes = pass_payload; + } + + let header = Header { + version: PROTOCOL_VERSION, + has_session, + has_password, + is_compressed, + is_control, + }; + + let mut out = Vec::with_capacity(1 + raw_bytes.len()); + out.push(header.encode()); + out.extend_from_slice(&raw_bytes); + + Ok(out) +} + +pub fn unpack_message( + bytes: &[u8], + session_key: Option<&[u8; 32]>, + password: Option<&str>, +) -> Result { + if bytes.is_empty() { + return Err("Empty packet".into()); + } + + let header = Header::decode(bytes[0]); + if header.version != PROTOCOL_VERSION { + return Err("Unsupported protocol version".into()); + } + + let mut current = bytes[1..].to_vec(); + + if header.has_password { + let pwd = password.ok_or("Password required to decrypt this message")?; + if current.len() < 16 + 12 + 16 { + return Err("Password payload too short".into()); + } + let salt = ¤t[..16]; + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(¤t[16..28]); + let ciphertext = ¤t[28..]; + + let p_key = symmetric::derive_key(pwd, salt)?; + current = symmetric::decrypt(&p_key, &nonce, ciphertext, None) + .map_err(|_| "Wrong password".to_string())?; + } + + if header.has_session { + let s_key = session_key.ok_or("Session key required to decrypt this message")?; + if current.len() < 12 + 16 { + return Err("Session payload too short".into()); + } + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(¤t[..12]); + let ciphertext = ¤t[12..]; + + current = symmetric::decrypt(s_key, &nonce, ciphertext, None) + .map_err(|_| "Failed to decrypt with session key".to_string())?; + } + + if header.is_compressed { + current = decompress_bytes(¤t)?; + } + + if header.is_control { + return Ok(PayloadData::Handshake(current)); + } + + if current.is_empty() { + return Ok(PayloadData::Text(String::new())); + } + + match current[0] { + 0x01 => { + let s = String::from_utf8(current[1..].to_vec()).map_err(|e| e.to_string())?; + Ok(PayloadData::Text(s)) + } + 0x02 => { + let (text, media): (String, MediaDescriptor) = + rmp_serde::from_slice(¤t[1..]).map_err(|e| e.to_string())?; + Ok(PayloadData::Media { text, media }) + } + _ => { + if let Ok(s) = String::from_utf8(current.clone()) { + Ok(PayloadData::Text(s)) + } else { + Err("Unknown payload format".into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plain_message_roundtrip() { + let text = "Synthetic plain message test".to_string(); + let packed = pack_message(PayloadData::Text(text.clone()), None, None).unwrap(); + let unpacked = unpack_message(&packed, None, None).unwrap(); + + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + } + + #[test] + fn test_session_encrypted_roundtrip() { + let session_key = [55u8; 32]; + let text = "Synthetic session encrypted message".to_string(); + let packed = + pack_message(PayloadData::Text(text.clone()), Some(&session_key), None).unwrap(); + assert_ne!(&packed[1..], text.as_bytes()); + + let unpacked = unpack_message(&packed, Some(&session_key), None).unwrap(); + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + } + + #[test] + fn test_password_encrypted_roundtrip() { + let password = "synthetic_password_xyz"; + let text = "Synthetic password encrypted message".to_string(); + let packed = pack_message(PayloadData::Text(text.clone()), None, Some(password)).unwrap(); + + let unpacked = unpack_message(&packed, None, Some(password)).unwrap(); + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + + let wrong_unpacked = unpack_message(&packed, None, Some("wrong_password")); + assert!(wrong_unpacked.is_err()); + } + + #[test] + fn test_both_layers_roundtrip() { + let session_key = [99u8; 32]; + let password = "dual_layer_secret"; + let text = "Synthetic dual layer message".to_string(); + + let packed = pack_message( + PayloadData::Text(text.clone()), + Some(&session_key), + Some(password), + ) + .unwrap(); + + let unpacked = unpack_message(&packed, Some(&session_key), Some(password)).unwrap(); + match unpacked { + PayloadData::Text(t) => assert_eq!(t, text), + _ => panic!("Expected text payload"), + } + } + + #[test] + fn test_media_descriptor_messagepack_roundtrip() { + let session_key = [77u8; 32]; + let desc = MediaDescriptor { + attach_index: 0, + name: "synthetic_image.jpg".into(), + mime: "image/jpeg".into(), + media_type: "PHOTO".into(), + size: 1048576, + width: Some(1920), + height: Some(1080), + duration: None, + wave: None, + video_type: None, + color: None, + }; + + let caption = "Photo caption".to_string(); + let payload = PayloadData::Media { + text: caption.clone(), + media: desc.clone(), + }; + + let packed = pack_message(payload, Some(&session_key), None).unwrap(); + let unpacked = unpack_message(&packed, Some(&session_key), None).unwrap(); + + match unpacked { + PayloadData::Media { + text, + media: unpacked_media, + } => { + assert_eq!(text, caption); + assert_eq!(unpacked_media.name, desc.name); + assert_eq!(unpacked_media.mime, desc.mime); + assert_eq!(unpacked_media.media_type, desc.media_type); + assert_eq!(unpacked_media.size, desc.size); + assert_eq!(unpacked_media.width, desc.width); + assert_eq!(unpacked_media.height, desc.height); + } + _ => panic!("Expected media payload"), + } + } +} diff --git a/src-tauri/src/crypto/symmetric.rs b/src-tauri/src/crypto/symmetric.rs new file mode 100644 index 0000000..9757542 --- /dev/null +++ b/src-tauri/src/crypto/symmetric.rs @@ -0,0 +1,118 @@ +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; + +use std::collections::HashMap; +use std::sync::{LazyLock, RwLock}; + +static KEY_CACHE: LazyLock), [u8; 32]>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +pub fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32], String> { + let cache_key = (password.to_string(), salt.to_vec()); + if let Ok(guard) = KEY_CACHE.read() { + if let Some(cached_key) = guard.get(&cache_key) { + return Ok(*cached_key); + } + } + + let params = Params::new(19456, 2, 1, Some(32)).map_err(|e| e.to_string())?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let mut key = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| e.to_string())?; + + if let Ok(mut guard) = KEY_CACHE.write() { + guard.insert(cache_key, key); + } + + Ok(key) +} + +pub fn encrypt( + key: &[u8; 32], + nonce: &[u8; 12], + plaintext: &[u8], + aad: Option<&[u8]>, +) -> Result, String> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let n = Nonce::from_slice(nonce); + + match aad { + Some(ad) => { + use chacha20poly1305::aead::Payload; + let payload = Payload { + msg: plaintext, + aad: ad, + }; + cipher.encrypt(&n, payload).map_err(|e| e.to_string()) + } + None => cipher.encrypt(&n, plaintext).map_err(|e| e.to_string()), + } +} + +pub fn decrypt( + key: &[u8; 32], + nonce: &[u8; 12], + ciphertext: &[u8], + aad: Option<&[u8]>, +) -> Result, String> { + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); + let n = Nonce::from_slice(nonce); + + match aad { + Some(ad) => { + use chacha20poly1305::aead::Payload; + let payload = Payload { + msg: ciphertext, + aad: ad, + }; + cipher.decrypt(&n, payload).map_err(|e| e.to_string()) + } + None => cipher.decrypt(&n, ciphertext).map_err(|e| e.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_symmetric_roundtrip() { + let key = [42u8; 32]; + let nonce = [7u8; 12]; + let plaintext = b"synthetic test message payload"; + let encrypted = encrypt(&key, &nonce, plaintext, None).unwrap(); + assert_ne!(encrypted, plaintext); + + let decrypted = decrypt(&key, &nonce, &encrypted, None).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_symmetric_wrong_key() { + let key = [42u8; 32]; + let wrong_key = [43u8; 32]; + let nonce = [7u8; 12]; + let plaintext = b"synthetic test message"; + let encrypted = encrypt(&key, &nonce, plaintext, None).unwrap(); + + let result = decrypt(&wrong_key, &nonce, &encrypted, None); + assert!(result.is_err()); + } + + #[test] + fn test_argon2_derive_key() { + let salt = [9u8; 16]; + let key1 = derive_key("password123", &salt).unwrap(); + let key2 = derive_key("password123", &salt).unwrap(); + assert_eq!(key1, key2); + + let key3 = derive_key("different", &salt).unwrap(); + assert_ne!(key1, key3); + } +} diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 7449b3c..d616633 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -8,10 +8,27 @@ static IN_FLIGHT_CACHE: LazyLock> fn unwrap_media_source(raw: &str) -> (Option, Option) { let mut s = raw.to_string(); - if let Some(idx) = s.find("127.0.0.1:11447/") { - let after = &s[idx + 16..]; - if let Ok(dec) = urlencoding::decode(after) { - s = dec.into_owned(); + if let Some(idx) = s.find("127.0.0.1:") { + if let Some(slash_idx) = s[idx..].find("/proxy?url=") { + let after = &s[idx + slash_idx + 11..]; + if let Ok(dec) = urlencoding::decode(after) { + s = dec.into_owned(); + } + } else if let Some(slash_idx) = s[idx..].find('/') { + let rest = &s[idx + slash_idx + 1..]; + let after = if let Some(next_slash) = rest.find('/') { + let segment = &rest[..next_slash]; + if segment.len() == 64 && segment.chars().all(|c| c.is_ascii_hexdigit()) { + &rest[next_slash + 1..] + } else { + rest + } + } else { + rest + }; + if let Ok(dec) = urlencoding::decode(after) { + s = dec.into_owned(); + } } } else if let Some(idx) = s.find("proxy?url=") { let after = &s[idx + 10..]; @@ -22,6 +39,22 @@ fn unwrap_media_source(raw: &str) -> (Option, Option) { if s.starts_with("http://asset.localhost/") { let path = s.trim_start_matches("http://asset.localhost/"); let path = urlencoding::decode(path).map(|c| c.into_owned()).unwrap_or_else(|_| path.to_string()); + #[cfg(not(target_os = "windows"))] + let path = if path.starts_with('/') { path } else { format!("/{}", path) }; + return (Some(path), None); + } + if s.starts_with("asset://localhost/") { + let path = s.trim_start_matches("asset://localhost/"); + let path = urlencoding::decode(path).map(|c| c.into_owned()).unwrap_or_else(|_| path.to_string()); + #[cfg(not(target_os = "windows"))] + let path = if path.starts_with('/') { path } else { format!("/{}", path) }; + return (Some(path), None); + } + if s.starts_with("asset://") { + let path = s.trim_start_matches("asset://"); + let path = urlencoding::decode(path).map(|c| c.into_owned()).unwrap_or_else(|_| path.to_string()); + #[cfg(not(target_os = "windows"))] + let path = if path.starts_with('/') { path } else { format!("/{}", path) }; return (Some(path), None); } if s.starts_with('/') { @@ -244,6 +277,7 @@ pub async fn upload( token: Option, mime: Option, video_type: Option, + file_name: Option, ) -> Result { use tokio::fs::File; @@ -277,16 +311,24 @@ pub async fn upload( tokio::fs::File::from_std(std_file) }; + let upload_name = file_name.unwrap_or_else(|| { + std::path::Path::new(&effective_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string() + }); + match attach_type.as_str() { - "PHOTO" => Ok(state.client.upload_photo(upload_url, file, effective_path, mime).await), + "PHOTO" => Ok(state.client.upload_photo(upload_url, file, upload_name, mime).await), "VIDEO" | "AUDIO" => { let video_id = video_id.ok_or("No video_id")?; let token = token.ok_or("No token")?; - Ok(state.client.upload_video(upload_url, video_id, token, file, effective_path).await) + Ok(state.client.upload_video(upload_url, video_id, token, file, upload_name).await) } "FILE" => { let file_id = file_id.ok_or("No file_id")?; - Ok(state.client.upload_file(upload_url, file_id, file, effective_path).await) + Ok(state.client.upload_file(upload_url, file_id, file, upload_name).await) } _ => Err("Wrong type".into()), } @@ -470,6 +512,32 @@ pub async fn save_trace_zip( } } +#[tauri::command] +pub async fn save_trace_archive( + app: tauri::AppHandle, + name: String, + files: HashMap>, +) -> Result { + let zip_bytes = tokio::task::spawn_blocking(move || -> Result, String> { + use std::io::{Cursor, Write}; + let mut buffer = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buffer); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (filename, content) in files { + writer.start_file(filename, options).map_err(|e| e.to_string())?; + writer.write_all(&content).map_err(|e| e.to_string())?; + } + writer.finish().map_err(|e| e.to_string())?; + } + Ok(buffer.into_inner()) + }) + .await + .map_err(|e| e.to_string())??; + + save_trace_zip(app, name, zip_bytes).await +} #[tauri::command] pub async fn save_temp_media( @@ -655,13 +723,27 @@ pub async fn fetch_url_bytes(url: String) -> Result, String> { Ok(bytes.to_vec()) } +#[derive(Clone, serde::Serialize)] +pub struct DownloadProgress { + pub progress: usize, + pub total: usize, +} + #[tauri::command] -pub async fn download_to_path(url: String, path: String) -> Result<(), String> { +pub async fn download_to_path( + app: tauri::AppHandle, + url: String, + path: String, + on_progress: tauri::ipc::Channel, +) -> Result<(), String> { + let _ = &app; let (local_src, remote_url) = unwrap_media_source(&url); if let Some(src) = local_src { tokio::fs::copy(&src, &path) .await .map_err(|e| e.to_string())?; + let len = tokio::fs::metadata(&path).await.map(|m| m.len() as usize).unwrap_or(0); + let _ = on_progress.send(DownloadProgress { progress: len, total: len }); return Ok(()); } @@ -676,10 +758,37 @@ pub async fn download_to_path(url: String, path: String) -> Result<(), String> { return Err(format!("HTTP {}", resp.status())); } - let bytes = resp.bytes().await.map_err(|e| e.to_string())?; - tokio::fs::write(path, bytes) - .await - .map_err(|e| e.to_string())?; + let total = resp.content_length().unwrap_or(0) as usize; + let mut downloaded = 0usize; + #[cfg(target_os = "android")] + if path.starts_with("content://") { + use tauri_plugin_android_fs::AndroidFsExt; + let api = app.android_fs_async(); + let uri = tauri_plugin_android_fs::FsUri::from_uri(&path); + let mut file = api.open_file_writable(&uri).await.map_err(|e| e.to_string())?; + use futures_util::StreamExt; + use std::io::Write; + let mut stream = resp.bytes_stream(); + while let Some(chunk_res) = stream.next().await { + let chunk = chunk_res.map_err(|e| e.to_string())?; + file.write_all(&chunk).map_err(|e| e.to_string())?; + downloaded += chunk.len(); + let _ = on_progress.send(DownloadProgress { progress: downloaded, total }); + } + return Ok(()); + } + + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + let mut file = tokio::fs::File::create(&path).await.map_err(|e| e.to_string())?; + let mut stream = resp.bytes_stream(); + while let Some(chunk_res) = stream.next().await { + let chunk = chunk_res.map_err(|e| e.to_string())?; + file.write_all(&chunk).await.map_err(|e| e.to_string())?; + downloaded += chunk.len(); + let _ = on_progress.send(DownloadProgress { progress: downloaded, total }); + } + file.flush().await.map_err(|e| e.to_string())?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 30f1ab3..e035848 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,8 @@ +mod proxy_auth; mod commands; +mod crypto; mod files; +mod media_validator; mod notifications; mod ssl; mod state; @@ -30,8 +33,6 @@ pub fn run() { let builder = builder .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_upload::init()) - .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_notifications::init()) @@ -39,7 +40,6 @@ pub fn run() { #[cfg(any(target_os = "android", target_os = "ios"))] let builder = builder - .plugin(tauri_plugin_biometric::init()) .plugin(tauri_plugin_barcode_scanner::init()); #[cfg(target_os = "ios")] @@ -80,11 +80,22 @@ pub fn run() { } }); + let p_cfg = proxy_auth::get_proxy_config(); + if let Some(w) = app.get_webview_window("main") { + let script = format!( + "window.__MAXPLUS_PROXY__ = {{ videoPort: {}, videoToken: '{}', webappPort: {}, webappToken: '{}' }};", + p_cfg.video_port, p_cfg.video_token, p_cfg.webapp_port, p_cfg.webapp_token + ); + let _ = w.eval(&script); + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::ext_api_request, commands::get_system_trace_info, commands::get_video_secret, + commands::get_proxy_config, commands::init, commands::start_auth, commands::check_code, @@ -161,6 +172,7 @@ pub fn run() { files::write_file_string, files::write_file_bytes, files::save_trace_zip, + files::save_trace_archive, files::save_temp_media, files::prepare_video_for_preview, files::crop_video_note, @@ -168,6 +180,9 @@ pub fn run() { files::fetch_url_text, files::fetch_url_bytes, files::download_to_path, + media_validator::validate_media_batch, + media_validator::sanitize_mp4_edit_list, + media_validator::calculate_ogg_crc, stores::accounts_get, stores::accounts_add, stores::account_get, @@ -208,6 +223,17 @@ pub fn run() { webapp_proxy::get_webapp_filter_rules, webapp_proxy::get_webapp_ram_logs, webapp_proxy::clear_webapp_ram_logs, + crypto::commands::batch_decrypt_messages, + crypto::commands::encrypt_message, + crypto::commands::init_e2e_handshake, + crypto::commands::accept_e2e_handshake, + crypto::commands::process_e2e_accept, + crypto::commands::get_chat_encryption_info, + crypto::commands::make_dictionary, + crypto::commands::encrypt_media_file, + crypto::commands::decrypt_media_file, + crypto::commands::cache_encrypted_media, + crypto::commands::register_media_cache, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/media_validator.rs b/src-tauri/src/media_validator.rs new file mode 100644 index 0000000..d6b353c --- /dev/null +++ b/src-tauri/src/media_validator.rs @@ -0,0 +1,354 @@ +use futures_util::future::join_all; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::sync::LazyLock; + +static OGG_CRC_TABLE: LazyLock<[u32; 256]> = LazyLock::new(|| { + let mut table = [0u32; 256]; + for i in 0..256 { + let mut r = (i as u32) << 24; + for _ in 0..8 { + if (r & 0x8000_0000) != 0 { + r = (r << 1) ^ 0x04c1_1db7; + } else { + r <<= 1; + } + } + table[i] = r; + } + table +}); + +pub fn calculate_ogg_crc_slice(data: &[u8]) -> u32 { + let mut crc = 0u32; + for &b in data { + let idx = ((crc >> 24) ^ (b as u32)) & 0xff; + crc = (crc << 8) ^ OGG_CRC_TABLE[idx as usize]; + } + crc +} + +#[tauri::command] +pub fn calculate_ogg_crc(data: Vec) -> u32 { + calculate_ogg_crc_slice(&data) +} + +pub fn sanitize_mp4_bytes(bytes: &mut [u8]) -> bool { + let mut sanitized = false; + if bytes.len() >= 4 { + for i in 0..=bytes.len() - 4 { + if &bytes[i..i + 4] == b"edts" { + bytes[i..i + 4].copy_from_slice(b"free"); + sanitized = true; + } + } + } + sanitized +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MediaValidateRequest { + pub id: String, + pub media_type: String, + pub path: Option, + pub bytes: Option>, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MediaValidateResult { + pub id: String, + pub valid: bool, + pub format: Option, + pub error: Option, + pub reason: Option, + pub channels: Option, + pub sample_rate: Option, +} + +pub fn validate_audio_slice(bytes: &[u8]) -> Result<(u8, u32), (String, String)> { + if bytes.len() < 28 { + return Err(("AUDIO_VALIDATION_FAILED".into(), "File too small".into())); + } + + if bytes.len() >= 4 && &bytes[0..4] == b"\x1a\x45\xdf\xa3" { + return Err(("AUDIO_VALIDATION_FAILED".into(), "WebM format rejected for audio messages".into())); + } + + if bytes.len() >= 4 && &bytes[0..4] == b"RIFF" { + return Err(("AUDIO_VALIDATION_FAILED".into(), "WAV format rejected for audio messages".into())); + } + + if (bytes.len() >= 3 && &bytes[0..3] == b"ID3") + || (bytes.len() >= 2 && bytes[0] == 0xff && (bytes[1] & 0xe0) == 0xe0) + { + return Err(("AUDIO_VALIDATION_FAILED".into(), "MP3 format rejected for audio messages".into())); + } + + if bytes.len() < 4 || &bytes[0..4] != b"OggS" { + return Err(("AUDIO_VALIDATION_FAILED".into(), "Missing OggS signature".into())); + } + + let header_type = bytes[5]; + if (header_type & 0x02) == 0 { + return Err(("AUDIO_VALIDATION_FAILED".into(), "First page is not BOS".into())); + } + + let segment_count = bytes[26] as usize; + if bytes.len() < 27 + segment_count + 19 { + return Err(("AUDIO_VALIDATION_FAILED".into(), "Incomplete OpusHead page".into())); + } + + let head_offset = 27 + segment_count; + if &bytes[head_offset..head_offset + 8] != b"OpusHead" { + return Err(("AUDIO_VALIDATION_FAILED".into(), "Missing OpusHead".into())); + } + + let channels = bytes[head_offset + 9]; + let sample_rate = u32::from_le_bytes([ + bytes[head_offset + 12], + bytes[head_offset + 13], + bytes[head_offset + 14], + bytes[head_offset + 15], + ]); + + if channels != 1 { + return Err(("AUDIO_VALIDATION_FAILED".into(), "Audio message must be mono".into())); + } + + if sample_rate != 48000 { + return Err(("AUDIO_VALIDATION_FAILED".into(), "Audio message sample rate must be 48000Hz".into())); + } + + Ok((channels, sample_rate)) +} + +pub fn validate_video_slice(bytes: &[u8]) -> Result<(), (String, String)> { + if bytes.len() < 16 { + return Err(("VIDEO_VALIDATION_FAILED".into(), "File too small".into())); + } + + if bytes.len() >= 4 && &bytes[0..4] == b"\x1a\x45\xdf\xa3" { + return Err(("VIDEO_VALIDATION_FAILED".into(), "WebM format rejected for video notes".into())); + } + + if &bytes[4..8] != b"ftyp" { + return Err(("VIDEO_VALIDATION_FAILED".into(), "Not an MP4 container".into())); + } + + for i in 0..=bytes.len().saturating_sub(4) { + if &bytes[i..i + 4] == b"edts" { + return Err(("VIDEO_VALIDATION_FAILED".into(), "Video note contains edit list (edts)".into())); + } + } + + let mut moov_offset: Option = None; + let mut mdat_offset: Option = None; + let mut offset = 0; + + while offset + 8 <= bytes.len() { + let size = u32::from_be_bytes([bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]]) as usize; + let tag = &bytes[offset + 4..offset + 8]; + if tag == b"moov" && moov_offset.is_none() { + moov_offset = Some(offset); + } + if tag == b"mdat" && mdat_offset.is_none() { + mdat_offset = Some(offset); + } + if size == 0 { + break; + } + let actual_size = if size == 1 && offset + 16 <= bytes.len() { + u64::from_be_bytes([ + bytes[offset + 8], bytes[offset + 9], bytes[offset + 10], bytes[offset + 11], + bytes[offset + 12], bytes[offset + 13], bytes[offset + 14], bytes[offset + 15], + ]) as usize + } else { + size + }; + if actual_size < 8 { + break; + } + offset += actual_size; + } + + let moov = match moov_offset { + Some(m) => m, + None => return Err(("VIDEO_VALIDATION_FAILED".into(), "Missing moov box".into())), + }; + + if let Some(mdat) = mdat_offset { + if moov > mdat { + return Err(("VIDEO_VALIDATION_FAILED".into(), "moov after mdat (not faststart)".into())); + } + } + + let has_video = bytes.windows(4).any(|w| w == b"vide"); + if !has_video { + return Err(("VIDEO_VALIDATION_FAILED".into(), "Missing video track".into())); + } + + let has_audio = bytes.windows(4).any(|w| w == b"soun"); + if !has_audio { + return Err(("VIDEO_VALIDATION_FAILED".into(), "Missing audio track".into())); + } + + if let Some(tkhd_pos) = bytes.windows(4).position(|w| w == b"tkhd") { + if tkhd_pos + 88 <= bytes.len() { + let ver = bytes[tkhd_pos + 4]; + let w_off = tkhd_pos + 4 + if ver == 1 { 84 } else { 76 }; + if w_off + 6 <= bytes.len() { + let w = u16::from_be_bytes([bytes[w_off], bytes[w_off + 1]]); + let h = u16::from_be_bytes([bytes[w_off + 4], bytes[w_off + 5]]); + if w > 0 && h > 0 && w != h { + return Err(("VIDEO_VALIDATION_FAILED".into(), "Video note must be square (1:1)".into())); + } + } + } + } + + Ok(()) +} + +fn process_single_item(req: MediaValidateRequest) -> MediaValidateResult { + let data = if let Some(bytes) = req.bytes { + Some(bytes) + } else if let Some(path) = req.path { + let clean = path.strip_prefix("file://").unwrap_or(&path); + fs::read(clean).ok() + } else { + None + }; + + let bytes = match data { + Some(b) => b, + None => { + return MediaValidateResult { + id: req.id, + valid: false, + format: None, + error: Some("VALIDATION_FAILED".into()), + reason: Some("Failed to load media bytes".into()), + channels: None, + sample_rate: None, + }; + } + }; + + match req.media_type.as_str() { + "audio" => match validate_audio_slice(&bytes) { + Ok((channels, sample_rate)) => MediaValidateResult { + id: req.id, + valid: true, + format: Some("ogg/opus".into()), + error: None, + reason: None, + channels: Some(channels), + sample_rate: Some(sample_rate), + }, + Err((error, reason)) => MediaValidateResult { + id: req.id, + valid: false, + format: None, + error: Some(error), + reason: Some(reason), + channels: None, + sample_rate: None, + }, + }, + "video" => match validate_video_slice(&bytes) { + Ok(()) => MediaValidateResult { + id: req.id, + valid: true, + format: Some("mp4".into()), + error: None, + reason: None, + channels: None, + sample_rate: None, + }, + Err((error, reason)) => MediaValidateResult { + id: req.id, + valid: false, + format: None, + error: Some(error), + reason: Some(reason), + channels: None, + sample_rate: None, + }, + }, + _ => MediaValidateResult { + id: req.id, + valid: false, + format: None, + error: Some("INVALID_TYPE".into()), + reason: Some("Unknown media type".into()), + channels: None, + sample_rate: None, + }, + } +} + +#[tauri::command] +pub async fn validate_media_batch(items: Vec) -> Vec { + let tasks: Vec<_> = items + .into_iter() + .map(|item| tokio::task::spawn_blocking(move || process_single_item(item))) + .collect(); + + join_all(tasks) + .await + .into_iter() + .filter_map(|res| res.ok()) + .collect() +} + +#[tauri::command] +pub async fn sanitize_mp4_edit_list(mut bytes: Vec) -> Result, String> { + tokio::task::spawn_blocking(move || { + sanitize_mp4_bytes(&mut bytes); + bytes + }) + .await + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ogg_crc_calculation() { + let synthetic_data = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00"; + let crc = calculate_ogg_crc_slice(synthetic_data); + assert_ne!(crc, 0); + } + + #[test] + fn test_mp4_edts_sanitization() { + let mut data = b"moovedtsfree".to_vec(); + let changed = sanitize_mp4_bytes(&mut data); + assert!(changed); + assert_eq!(&data[4..8], b"free"); + } + + #[test] + fn test_audio_validation_rejection() { + let invalid_riff = b"RIFF\x00\x00\x00\x00WAVE".to_vec(); + let res = validate_audio_slice(&invalid_riff); + assert!(res.is_err()); + + let invalid_webm = b"\x1a\x45\xdf\xa3dummy".to_vec(); + let res2 = validate_audio_slice(&invalid_webm); + assert!(res2.is_err()); + } + + #[test] + fn test_video_validation_rejection() { + let too_short = b"short".to_vec(); + let res = validate_video_slice(&too_short); + assert!(res.is_err()); + + let not_mp4 = b"\x00\x00\x00\x18webmdatahere".to_vec(); + let res2 = validate_video_slice(¬_mp4); + assert!(res2.is_err()); + } +} diff --git a/src-tauri/src/proxy_auth.rs b/src-tauri/src/proxy_auth.rs new file mode 100644 index 0000000..5a1c9b0 --- /dev/null +++ b/src-tauri/src/proxy_auth.rs @@ -0,0 +1,163 @@ +use rand::RngCore; +use std::sync::{Arc, OnceLock, RwLock}; +use tiny_http::Request; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ProxyConfig { + pub video_port: u16, + pub video_token: String, + pub webapp_port: u16, + pub webapp_token: String, +} + +static PROXY_CONFIG: OnceLock>> = OnceLock::new(); + +fn get_config_lock() -> &'static Arc> { + PROXY_CONFIG.get_or_init(|| { + Arc::new(RwLock::new(ProxyConfig { + video_port: 0, + video_token: generate_token(), + webapp_port: 0, + webapp_token: generate_token(), + })) + }) +} + +pub fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +pub fn set_video_proxy_info(port: u16, token: String) { + if let Ok(mut lock) = get_config_lock().write() { + lock.video_port = port; + lock.video_token = token; + } +} + +pub fn set_webapp_proxy_info(port: u16, token: String) { + if let Ok(mut lock) = get_config_lock().write() { + lock.webapp_port = port; + lock.webapp_token = token; + } +} + +pub fn get_proxy_config() -> ProxyConfig { + get_config_lock() + .read() + .map(|c| c.clone()) + .unwrap_or(ProxyConfig { + video_port: 0, + video_token: String::new(), + webapp_port: 0, + webapp_token: String::new(), + }) +} + +pub fn get_video_token() -> String { + get_config_lock() + .read() + .map(|c| c.video_token.clone()) + .unwrap_or_default() +} + +pub fn get_webapp_token() -> String { + get_config_lock() + .read() + .map(|c| c.webapp_token.clone()) + .unwrap_or_default() +} + +pub fn validate_and_strip_token(request: &Request, expected_token: &str) -> Option { + validate_and_strip_token_with_port(request, expected_token, 0) +} + +pub fn validate_and_strip_token_with_port( + request: &Request, + expected_token: &str, + port: u16, +) -> Option { + if expected_token.is_empty() { + return Some(request.url().to_string()); + } + + let url = request.url(); + let prefix = format!("/{}", expected_token); + + if url == prefix { + return Some("/".to_string()); + } + + if let Some(rest) = url.strip_prefix(&format!("{}/", prefix)) { + return Some(format!("/{}", rest)); + } + + if let Some(rest) = url.strip_prefix(&format!("{}?", prefix)) { + return Some(format!("/?{}", rest)); + } + + for header in request.headers() { + let name = header.field.as_str().as_str(); + let value = header.value.as_str().trim(); + + if name.eq_ignore_ascii_case("x-proxy-token") || name.eq_ignore_ascii_case("x-maxplus-token") { + if value == expected_token { + return Some(url.to_string()); + } + } + + if name.eq_ignore_ascii_case("authorization") { + if let Some(bearer) = value.strip_prefix("Bearer ") { + if bearer.trim() == expected_token { + return Some(url.to_string()); + } + } + } + + if name.eq_ignore_ascii_case("cookie") { + for item in value.split(';') { + let mut parts = item.trim().splitn(2, '='); + if let (Some(k), Some(v)) = (parts.next(), parts.next()) { + if (k == "__mp_token" || k == "__mp_proxy_token") && v == expected_token { + return Some(url.to_string()); + } + } + } + } + + if name.eq_ignore_ascii_case("referer") { + if value.contains(&format!("/{}", expected_token)) || value.contains(expected_token) { + return Some(url.to_string()); + } + if port > 0 { + let local_127 = format!("127.0.0.1:{}", port); + let local_host = format!("localhost:{}", port); + if value.contains(&local_127) || value.contains(&local_host) { + return Some(url.to_string()); + } + } + } + + if port > 0 && name.eq_ignore_ascii_case("origin") { + let local_127 = format!("127.0.0.1:{}", port); + let local_host = format!("localhost:{}", port); + if value.contains(&local_127) || value.contains(&local_host) { + return Some(url.to_string()); + } + } + } + + if let Some((_, query)) = url.split_once('?') { + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + if let (Some(k), Some(v)) = (parts.next(), parts.next()) { + if (k == "token" || k == "secret" || k == "_token") && v == expected_token { + return Some(url.to_string()); + } + } + } + } + + None +} diff --git a/src-tauri/src/stores.rs b/src-tauri/src/stores.rs index 4551d28..fd91a5c 100644 --- a/src-tauri/src/stores.rs +++ b/src-tauri/src/stores.rs @@ -124,7 +124,7 @@ impl Storage { } } -fn crypto_key( +pub(crate) fn crypto_key( app: &AppHandle, account: u64 ) -> Option<[u8;32]> { @@ -137,13 +137,13 @@ fn crypto_key( .map(|x| x.key) } -struct Paths { - root: PathBuf, - cache: PathBuf, +pub(crate) struct Paths { + pub(crate) root: PathBuf, + pub(crate) cache: PathBuf, } impl Paths { - fn new( + pub(crate) fn new( app: &AppHandle, account: u64, ) -> Self { @@ -176,7 +176,7 @@ impl Paths { self.chat(chat).join("info") } - fn settings(&self, chat: i64) -> PathBuf { + pub(crate) fn settings(&self, chat: i64) -> PathBuf { self.chat(chat).join("settings") } diff --git a/src-tauri/src/video.rs b/src-tauri/src/video.rs index 127874d..45efc78 100644 --- a/src-tauri/src/video.rs +++ b/src-tauri/src/video.rs @@ -16,12 +16,25 @@ fn handle_local_file(request: tiny_http::Request, mut path: &str) { if let Some(stripped) = path.strip_prefix("file://") { path = stripped; } + let fixed_path; + let path = if !path.starts_with('/') && !path.contains("://") && !path.starts_with('\\') { + let candidate = format!("/{}", path); + if std::path::Path::new(&candidate).exists() { + fixed_path = candidate; + &fixed_path + } else { + path + } + } else { + path + }; use std::fs::File; use std::io::{Seek, SeekFrom}; let mut file = match File::open(path) { Ok(f) => f, - Err(_) => { + Err(e) => { + eprintln!("[VideoProxy] File Not Found: {} (error: {})", path, e); let _ = request.respond(Response::from_string("File Not Found").with_status_code(404)); return; } @@ -61,6 +74,8 @@ fn handle_local_file(request: tiny_http::Request, mut path: &str) { } } + println!("[VideoProxy] Serving local file: {}, mime: {}, size: {}", path, mime, file_len); + let mut range_header = None; for h in request.headers() { if h.field.as_str().as_str().eq_ignore_ascii_case("Range") { @@ -150,14 +165,22 @@ fn handle_local_file(request: tiny_http::Request, mut path: &str) { let _ = request.respond(res); } -fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Client) { +fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Client, token: &str) { + let clean_path = match crate::proxy_auth::validate_and_strip_token(&request, token) { + Some(p) => p, + None => { + drop(request.into_writer()); + return; + } + }; + if request.method() == &Method::Options { let headers = vec![ Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), Header::from_bytes(&b"Access-Control-Allow-Methods"[..], b"GET, HEAD, OPTIONS").unwrap(), Header::from_bytes( &b"Access-Control-Allow-Headers"[..], - b"Range, Origin, X-Requested-With, Content-Type, Accept", + b"Range, Origin, X-Requested-With, Content-Type, Accept, X-Proxy-Token, X-Maxplus-Token, Authorization", ) .unwrap(), Header::from_bytes(&b"Access-Control-Max-Age"[..], b"86400").unwrap(), @@ -167,7 +190,11 @@ fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Clien return; } - let raw_url = &request.url()[1..]; + let raw_url = if clean_path.starts_with('/') { + &clean_path[1..] + } else { + &clean_path + }; let url = match urlencoding::decode(raw_url) { Ok(u) => u.into_owned(), Err(_) => { @@ -184,16 +211,34 @@ fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Clien handle_local_file(request, local_path); return; } - if let Some(asset_path) = url.strip_prefix("asset://localhost/") { - let decoded = urlencoding::decode(asset_path).unwrap_or(std::borrow::Cow::Borrowed(asset_path)); - handle_local_file(request, &decoded); + if let Some(asset_path) = url.strip_prefix("asset://") { + let trimmed = asset_path.strip_prefix("localhost/").unwrap_or(asset_path); + let decoded = urlencoding::decode(trimmed).unwrap_or(std::borrow::Cow::Borrowed(trimmed)); + let path = if !decoded.starts_with('/') && !decoded.contains("://") { + format!("/{}", decoded) + } else { + decoded.into_owned() + }; + handle_local_file(request, &path); return; } if let Some(asset_path) = url.strip_prefix("http://asset.localhost/") { let decoded = urlencoding::decode(asset_path).unwrap_or(std::borrow::Cow::Borrowed(asset_path)); - handle_local_file(request, &decoded); + let path = if !decoded.starts_with('/') && !decoded.contains("://") { + format!("/{}", decoded) + } else { + decoded.into_owned() + }; + handle_local_file(request, &path); return; } + if !url.starts_with("http://") && !url.starts_with("https://") { + let candidate = format!("/{}", url.trim_start_matches('/')); + if std::path::Path::new(&candidate).exists() { + handle_local_file(request, &candidate); + return; + } + } if url.starts_with("http://") || url.starts_with("https://") { if let Some(data_dir) = dirs::data_local_dir().or_else(dirs::data_dir) { @@ -391,6 +436,26 @@ fn handle_request(request: tiny_http::Request, client: &reqwest::blocking::Clien } pub fn start_video_proxy() { + let token = crate::proxy_auth::get_video_token(); + let bind_addr = std::env::var("MAXPLUS_VIDEO_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .map(|p| format!("127.0.0.1:{}", p)) + .unwrap_or_else(|| "127.0.0.1:0".to_string()); + + let server = match Server::http(&bind_addr) { + Ok(s) => { + if let Some(addr) = s.server_addr().to_ip() { + crate::proxy_auth::set_video_proxy_info(addr.port(), token.clone()); + } + Arc::new(s) + } + Err(e) => { + eprintln!("Failed to bind video proxy: {}", e); + return; + } + }; + thread::spawn(move || { let mut builder = reqwest::blocking::Client::builder() .connect_timeout(Duration::from_secs(10)); @@ -399,18 +464,11 @@ pub fn start_video_proxy() { } let client = Arc::new(builder.build().unwrap_or_default()); - let server = match Server::http("127.0.0.1:11447") { - Ok(s) => Arc::new(s), - Err(e) => { - eprintln!("Failed to bind video proxy: {}", e); - return; - } - }; - for request in server.incoming_requests() { let client = Arc::clone(&client); + let token = token.clone(); thread::spawn(move || { - handle_request(request, &client); + handle_request(request, &client, &token); }); } }); diff --git a/src-tauri/src/webapp_proxy.rs b/src-tauri/src/webapp_proxy.rs index c519011..83e0111 100644 --- a/src-tauri/src/webapp_proxy.rs +++ b/src-tauri/src/webapp_proxy.rs @@ -109,12 +109,58 @@ fn check_blocked(url: &str) -> Option { struct ProxyState { last_origin: Mutex, last_base_url: Mutex, + last_full_url: Mutex, } static RE_DOMAIN: OnceLock = OnceLock::new(); static RE_SECURE: OnceLock = OnceLock::new(); static RE_PATH: OnceLock = OnceLock::new(); static RE_SAMESITE: OnceLock = OnceLock::new(); +fn is_js_ident_char(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b == b'$' +} + +fn rewrite_js_locations(text: &str) -> String { + let bytes = text.as_bytes(); + let n = bytes.len(); + let mut out = String::with_capacity(n + 64); + let mut i = 0; + + const PATTERNS: [(&str, &str); 6] = [ + ("window.location.hostname", "(window.__mpHostname||window.location.hostname)"), + ("location.hostname", "(window.__mpHostname||location.hostname)"), + ("window.location.origin", "(window.__mpOrigin||window.location.origin)"), + ("location.origin", "(window.__mpOrigin||location.origin)"), + ("window.location.host", "(window.__mpHost||window.location.host)"), + ("location.host", "(window.__mpHost||location.host)"), + ]; + + while i < n { + let mut matched = false; + for (pat, repl) in PATTERNS.iter() { + let pat_bytes = pat.as_bytes(); + let pat_len = pat_bytes.len(); + if i + pat_len <= n && &bytes[i..i + pat_len] == pat_bytes { + let prev_ok = i == 0 || (!is_js_ident_char(bytes[i - 1]) && bytes[i - 1] != b'.'); + let next_pos = i + pat_len; + let next_ok = next_pos == n || !is_js_ident_char(bytes[next_pos]); + if prev_ok && next_ok { + out.push_str(repl); + i = next_pos; + matched = true; + break; + } + } + } + if !matched { + let ch = text[i..].chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + } + } + + out +} fn clean_set_cookie_header(raw: &str) -> String { let re_dom = RE_DOMAIN.get_or_init(|| Regex::new(r"(?i)\bDomain=[^;]+;?\s*").unwrap()); @@ -152,55 +198,63 @@ fn resolve_location_url(base_url: &str, loc: &str) -> String { } } -const SHIM_SCRIPT: &str = concat!( - r#""#, - r#""# -); +fn build_shim_script(port: u16, token: &str) -> String { + let proxy_prefix = format!("http://127.0.0.1:{}/{}/proxy", port, token); + let proxy_origin = format!("http://127.0.0.1:{}", port); + format!( + concat!( + r#""#, + r#""# + ), + proxy_prefix, + proxy_origin, + port, + proxy_prefix, + proxy_prefix + ) +} fn extract_query_param(query: &str, param: &str) -> Option { for pair in query.split('&') { @@ -217,9 +271,20 @@ fn extract_query_param(query: &str, param: &str) -> Option { } fn get_origin_from_url(u: &str) -> String { + let parsed_opt = if u.starts_with("//") { + url::Url::parse(&format!("https:{}", u)).ok() + } else { + url::Url::parse(u).ok() + }; + if let Some(parsed) = parsed_opt { + let origin = parsed.origin().ascii_serialization(); + if origin != "null" && !origin.is_empty() { + return origin; + } + } if let Some(pos) = u.find("://") { let rest = &u[pos + 3..]; - let host = match rest.find('/') { + let host = match rest.find(|c| c == '/' || c == '?' || c == '#') { Some(p) => &rest[..p], None => rest, }; @@ -229,17 +294,95 @@ fn get_origin_from_url(u: &str) -> String { } } +fn is_js_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + let clean = lower.split('?').next().unwrap_or(&lower); + let clean = clean.split('#').next().unwrap_or(clean).trim_end_matches('/'); + clean.ends_with(".js") + || clean.ends_with(".mjs") + || clean.ends_with(".cjs") + || clean.ends_with(".ts") + || clean.ends_with(".tsx") + || clean.ends_with(".jsx") + || lower.contains(".js?") + || lower.contains(".mjs?") + || lower.contains(".cjs?") + || lower.contains(".js#") + || lower.contains("/js/") + || lower.contains("/javascript/") +} + +fn is_css_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + let clean = lower.split('?').next().unwrap_or(&lower); + let clean = clean.split('#').next().unwrap_or(clean).trim_end_matches('/'); + clean.ends_with(".css") || lower.contains(".css?") || lower.contains(".css#") +} + +fn decompress_body_if_needed(bytes: Vec) -> Vec { + if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b { + let mut decoder = flate2::read::GzDecoder::new(&bytes[..]); + let mut decompressed = Vec::new(); + if decoder.read_to_end(&mut decompressed).is_ok() { + return decompressed; + } + } else if bytes.len() >= 2 + && bytes[0] == 0x78 + && (bytes[1] == 0x9c || bytes[1] == 0x01 || bytes[1] == 0xda) + { + let mut decoder = flate2::read::ZlibDecoder::new(&bytes[..]); + let mut decompressed = Vec::new(); + if decoder.read_to_end(&mut decompressed).is_ok() { + return decompressed; + } + } + bytes +} + +fn is_html_bytes(bytes: &[u8]) -> bool { + let trimmed = match bytes.iter().position(|&b| !b.is_ascii_whitespace()) { + Some(pos) => &bytes[pos..], + None => bytes, + }; + trimmed.starts_with(b" p, + None => { + let headers = vec![ + Header::from_bytes(&b"Content-Type"[..], b"text/plain; charset=utf-8").unwrap(), + Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), + ]; + let res = Response::new( + 403.into(), + headers, + Cursor::new(b"Forbidden: invalid proxy token".to_vec()), + Some(30), + None, + ); + let _ = request.respond(res); + return; + } + }; + if request.method() == &Method::Options { let headers = vec![ Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), Header::from_bytes( &b"Access-Control-Allow-Methods"[..], - b"GET, POST, PUT, DELETE, OPTIONS, HEAD", + b"GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH", ) .unwrap(), Header::from_bytes(&b"Access-Control-Allow-Headers"[..], b"*").unwrap(), @@ -251,42 +394,62 @@ fn handle_request( return; } - let req_url = request.url().to_string(); + let req_url = clean_path; let query_str = req_url.split_once('?').map(|x| x.1).unwrap_or(""); + let req_path_only = req_url.split('?').next().unwrap_or(&req_url); + let is_proxy_endpoint = req_path_only == "/proxy" || req_path_only == "proxy"; - let mut direct_target = extract_query_param(query_str, "url") - .or_else(|| extract_query_param(query_str, "target")); + let mut direct_target = if is_proxy_endpoint { + extract_query_param(query_str, "url").or_else(|| extract_query_param(query_str, "target")) + } else { + None + }; - if let Some(ref target) = direct_target { - let o = get_origin_from_url(target); - if !o.is_empty() { - if let Ok(mut lock) = state.last_origin.lock() { - *lock = o.clone(); - } - if let Ok(parsed) = url::Url::parse(target) { - let mut p = parsed.path().to_string(); - if let Some(pos) = p.rfind('/') { - p.truncate(pos + 1); - } else { - p = "/".to_string(); - } - if let Ok(mut lock) = state.last_base_url.lock() { - *lock = format!("{}{}", o.trim_end_matches('/'), p); - } - } - } - } + let req_dest_is_script = request.headers().iter().any(|h| { + h.field.as_str().as_str().eq_ignore_ascii_case("sec-fetch-dest") + && matches!( + h.value.as_str().trim(), + "script" | "worker" | "sharedworker" | "serviceworker" + ) + }); + let req_dest_is_style = request.headers().iter().any(|h| { + h.field.as_str().as_str().eq_ignore_ascii_case("sec-fetch-dest") + && h.value.as_str().trim().eq_ignore_ascii_case("style") + }); + let req_accepts_js = request.headers().iter().any(|h| { + h.field.as_str().as_str().eq_ignore_ascii_case("accept") + && (h.value.as_str().contains("javascript") || h.value.as_str().contains("ecmascript")) + }); + let req_accepts_css = request.headers().iter().any(|h| { + h.field.as_str().as_str().eq_ignore_ascii_case("accept") + && h.value.as_str().contains("text/css") + }); + + let req_is_js = req_dest_is_script || req_accepts_js || is_js_path(&req_url); + let req_is_css = req_dest_is_style || req_accepts_css || is_css_path(&req_url); let mut caller_origin = String::new(); let mut caller_base = String::new(); + let mut referer_dir = String::new(); + let mut upstream_referer = String::new(); + let mut client_ua = None; + let mut browser_has_origin = false; + let mut req_headers_map = serde_json::Map::new(); + let mut passthrough_headers: Vec<(String, String)> = Vec::new(); + for h in request.headers() { - let name = h.field.as_str().as_str(); - if name.eq_ignore_ascii_case("Referer") { - let ref_val = h.value.as_str(); - if let Some((_, ref_q)) = ref_val.split_once('?') { - if let Some(u) = extract_query_param(ref_q, "url") - .or_else(|| extract_query_param(ref_q, "target")) - { + let name = h.field.as_str().to_string(); + let val = h.value.as_str().to_string(); + req_headers_map.insert(name.clone(), serde_json::Value::String(val.clone())); + + if name.eq_ignore_ascii_case("origin") { + browser_has_origin = true; + } else if name.eq_ignore_ascii_case("user-agent") { + client_ua = Some(val.clone()); + } else if name.eq_ignore_ascii_case("referer") { + if let Some((_, ref_q)) = val.split_once('?') { + if let Some(u) = extract_query_param(ref_q, "url").or_else(|| extract_query_param(ref_q, "target")) { + upstream_referer = u.clone(); caller_origin = get_origin_from_url(&u); if let Ok(parsed) = url::Url::parse(&u) { let mut p = parsed.path().to_string(); @@ -300,13 +463,26 @@ fn handle_request( } } if caller_origin.is_empty() { - caller_origin = get_origin_from_url(ref_val); + caller_origin = get_origin_from_url(&val); if caller_origin.contains("127.0.0.1") || caller_origin.contains("localhost") { caller_origin.clear(); + if let Ok(parsed_ref) = url::Url::parse(&val) { + let mut p = parsed_ref.path().to_string(); + let token_prefix = format!("/{}", token); + if let Some(rest) = p.strip_prefix(&token_prefix) { + p = rest.to_string(); + } + if let Some(pos) = p.rfind('/') { + let d = &p[..pos + 1]; + if d != "/" && !d.is_empty() && d != "/proxy" && d != "/proxy/" { + referer_dir = d.to_string(); + } + } + } } } - } else if name.eq_ignore_ascii_case("Cookie") { - for c in h.value.as_str().split(';') { + } else if name.eq_ignore_ascii_case("cookie") { + for c in val.split(';') { let mut cp = c.trim().splitn(2, '='); if let (Some(k), Some(v)) = (cp.next(), cp.next()) { if k == "webapp_target" && caller_origin.is_empty() { @@ -316,6 +492,25 @@ fn handle_request( } } } + let filtered: Vec<&str> = val + .split(';') + .map(|c| c.trim()) + .filter(|c| !c.starts_with("webapp_target=") && !c.starts_with("webapp_base=") && !c.starts_with("__mp_token=")) + .collect(); + if !filtered.is_empty() { + passthrough_headers.push(("Cookie".to_string(), filtered.join("; "))); + } + continue; + } + + if !name.eq_ignore_ascii_case("host") + && !name.eq_ignore_ascii_case("content-length") + && !name.eq_ignore_ascii_case("origin") + && !name.eq_ignore_ascii_case("referer") + && !name.eq_ignore_ascii_case("user-agent") + && !name.eq_ignore_ascii_case("accept-encoding") + { + passthrough_headers.push((name, val)); } } @@ -325,7 +520,9 @@ fn handle_request( } } - if caller_base.is_empty() { + if !referer_dir.is_empty() && !caller_origin.is_empty() { + caller_base = format!("{}{}", caller_origin.trim_end_matches('/'), referer_dir); + } else if caller_base.is_empty() { if let Ok(lock) = state.last_base_url.lock() { caller_base = lock.clone(); } @@ -334,44 +531,94 @@ fn handle_request( let origin = caller_origin.clone(); let base_url = caller_base.clone(); - let clean_req = req_url.split('?').next().unwrap_or(&req_url); - let is_css_req = clean_req.ends_with(".css"); - let is_js_req = clean_req.ends_with(".js") || clean_req.ends_with(".mjs"); - let is_asset_path = req_url.starts_with("/assets/") || req_url.starts_with("assets/"); - - let mut target_url = if let Some(t) = direct_target.take() { - t - } else if !base_url.is_empty() && is_asset_path && !base_url.ends_with("/assets/") { - let relative = req_url.trim_start_matches('/'); - format!("{}{}", base_url, relative) - } else if !origin.is_empty() { - format!("{}{}", origin.trim_end_matches('/'), req_url) + let candidates: Vec = if let Some(t) = direct_target.take() { + vec![t] } else { - if is_css_req { - let headers = vec![ - Header::from_bytes(&b"Content-Type"[..], b"text/css; charset=utf-8").unwrap(), - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), - ]; - let res = Response::new(200.into(), headers, Cursor::new(Vec::new()), Some(0), None); - let _ = request.respond(res); - return; - } else if is_js_req { - let headers = vec![ - Header::from_bytes(&b"Content-Type"[..], b"application/javascript; charset=utf-8").unwrap(), - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), - ]; - let res = Response::new(200.into(), headers, Cursor::new(Vec::new()), Some(0), None); - let _ = request.respond(res); - return; + let mut list = Vec::new(); + let rel = req_url.trim_start_matches('/'); + + let c_base = if !base_url.is_empty() { + let base_clean = base_url.trim_end_matches('/'); + if base_clean.ends_with("/assets") && rel.starts_with("assets/") { + Some(format!("{}/{}", &base_clean[..base_clean.len() - 7], rel)) + } else { + Some(format!("{}/{}", base_clean, rel)) + } + } else { + None + }; + + let c_origin = if !origin.is_empty() { + Some(format!("{}/{}", origin.trim_end_matches('/'), rel)) + } else { + None + }; + + let c_origin_assets = if !origin.is_empty() && (req_is_js || req_is_css) && !rel.starts_with("assets/") { + Some(format!("{}/assets/{}", origin.trim_end_matches('/'), rel)) + } else { + None + }; + + let c_base_assets = if !base_url.is_empty() && (req_is_js || req_is_css) && !rel.starts_with("assets/") { + let base_clean = base_url.trim_end_matches('/'); + if !base_clean.ends_with("/assets") { + Some(format!("{}/assets/{}", base_clean, rel)) + } else { + None + } + } else { + None + }; + + let to_add = if req_is_js || req_is_css || !req_url.starts_with('/') { + vec![c_base, c_base_assets, c_origin, c_origin_assets] + } else { + vec![c_origin, c_base] + }; + + for cand_opt in to_add { + if let Some(u) = cand_opt { + if !list.contains(&u) { + list.push(u); + } + } } - let _ = request.respond(Response::from_string("Missing target URL").with_status_code(400)); - return; + + if list.is_empty() { + if let Ok(lock) = state.last_base_url.lock() { + if !lock.is_empty() { + let fallback = format!("{}/{}", lock.trim_end_matches('/'), rel); + if !list.contains(&fallback) { + list.push(fallback); + } + } + } + } + + list }; - if target_url.contains("externalCallback=1") { + if candidates.is_empty() { + let headers = vec![ + Header::from_bytes(&b"Content-Type"[..], b"text/plain; charset=utf-8").unwrap(), + Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), + ]; + let res = Response::new( + 400.into(), + headers, + Cursor::new(b"Missing target origin".to_vec()), + Some(21), + None, + ); + let _ = request.respond(res); + return; + } + + if let Some(callback_url) = candidates.iter().find(|c| c.contains("externalCallback=1")) { let callback_html = format!( r#""#, - target_url.replace('"', "%22") + callback_url.replace('"', "%22") ); let bytes = callback_html.into_bytes(); let len = bytes.len(); @@ -392,49 +639,25 @@ fn handle_request( .unwrap_or_default() .as_millis() as u64; - let mut req_headers_map = serde_json::Map::new(); - for h in request.headers() { - req_headers_map.insert( - h.field.as_str().to_string(), - serde_json::Value::String(h.value.as_str().to_string()), - ); - } let req_headers_val = serde_json::Value::Object(req_headers_map); - let method_str = request.method().as_str().to_string(); - if let Some(reason) = check_blocked(&target_url) { - let entry = WebAppLogEntry { - id: LOG_ID_COUNTER.fetch_add(1, Ordering::SeqCst), - timestamp, - time_epoch, - method: method_str, - url: target_url, - origin, - status: 0, - status_text: "DROPPED".to_string(), - duration_ms: start_instant.elapsed().as_millis() as u64, - request_headers: req_headers_val, - request_body: None, - response_headers: serde_json::json!({}), - response_body: None, - content_type: String::new(), - content_length: 0, - blocked: true, - block_reason: Some(reason), - host_ip: None, - }; - emit_log(entry); - drop(request); - return; + let mut req_body = Vec::new(); + if method_str == "POST" || method_str == "PUT" || method_str == "PATCH" { + let _ = request.as_reader().read_to_end(&mut req_body); } - - let mut rb = match method_str.as_str() { - "POST" => client.post(&target_url), - "PUT" => client.put(&target_url), - "DELETE" => client.delete(&target_url), - "HEAD" => client.head(&target_url), - _ => client.get(&target_url), + let req_body_str = if !req_body.is_empty() { + if let Ok(s) = std::str::from_utf8(&req_body) { + if s.len() > 32768 { + Some(format!("{}... [truncated]", &s[..32768])) + } else { + Some(s.to_string()) + } + } else { + Some(format!("", req_body.len())) + } + } else { + None }; let origin_header = if !caller_origin.is_empty() { @@ -442,82 +665,137 @@ fn handle_request( } else if !origin.is_empty() { origin.clone() } else { - get_origin_from_url(&target_url) + get_origin_from_url(&candidates[0]) }; - rb = rb.header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - ); - - if !origin_header.is_empty() { - rb = rb.header("Referer", format!("{}/", origin_header)); - let browser_has_origin = request - .headers() - .iter() - .any(|h| h.field.as_str().as_str().eq_ignore_ascii_case("Origin")); - if browser_has_origin || (method_str != "GET" && method_str != "HEAD") { - rb = rb.header("Origin", origin_header.as_str()); + if upstream_referer.is_empty() { + if let Ok(lock) = state.last_full_url.lock() { + if !lock.is_empty() { + upstream_referer = lock.clone(); + } } } - for h in request.headers() { - let name = h.field.as_str().as_str(); - if name.eq_ignore_ascii_case("Host") - || name.eq_ignore_ascii_case("Content-Length") - || name.eq_ignore_ascii_case("Origin") - || name.eq_ignore_ascii_case("Referer") - || name.eq_ignore_ascii_case("User-Agent") - || name.eq_ignore_ascii_case("Accept-Encoding") - { - continue; - } - if name.eq_ignore_ascii_case("Cookie") { - let filtered: Vec<&str> = h - .value - .as_str() - .split(';') - .map(|c| c.trim()) - .filter(|c| !c.starts_with("webapp_target=") && !c.starts_with("webapp_base=")) - .collect(); - if !filtered.is_empty() { - rb = rb.header("Cookie", filtered.join("; ")); - } - continue; + if upstream_referer.is_empty() { + if !origin_header.is_empty() { + upstream_referer = format!("{}/", origin_header.trim_end_matches('/')); + } else { + upstream_referer = "https://max.ru/".to_string(); } - rb = rb.header(name, h.value.as_str()); } - let mut req_body = Vec::new(); - if method_str == "POST" || method_str == "PUT" || method_str == "PATCH" { - let _ = request.as_reader().read_to_end(&mut req_body); + let upstream_ua = client_ua.as_deref().unwrap_or( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + ); + + let upstream_origin = if !origin_header.is_empty() { + origin_header.clone() + } else { + get_origin_from_url(&candidates[0]) + }; + + let build_request = |url: &str| -> reqwest::blocking::RequestBuilder { + let mut rb = match method_str.as_str() { + "POST" => client.post(url), + "PUT" => client.put(url), + "DELETE" => client.delete(url), + "HEAD" => client.head(url), + "PATCH" => client.patch(url), + _ => client.get(url), + }; + rb = rb.header("User-Agent", upstream_ua); + rb = rb.header("Referer", &upstream_referer); + if browser_has_origin || (method_str != "GET" && method_str != "HEAD") { + if !upstream_origin.is_empty() { + rb = rb.header("Origin", &upstream_origin); + } + } + for (name, val) in &passthrough_headers { + rb = rb.header(name.as_str(), val.as_str()); + } if !req_body.is_empty() { rb = rb.body(req_body.clone()); } - } - let req_body_str = if !req_body.is_empty() { - if let Ok(s) = std::str::from_utf8(&req_body) { - if s.len() > 32768 { - Some(format!("{}... [truncated]", &s[..32768])) - } else { - Some(s.to_string()) + rb + }; + + let mut chosen_target = candidates[0].clone(); + let mut chosen_res = None; + let mut chosen_bytes = Vec::new(); + let mut last_err = None; + + for (idx, cand) in candidates.iter().enumerate() { + chosen_target = cand.clone(); + if let Some(reason) = check_blocked(&chosen_target) { + let entry = WebAppLogEntry { + id: LOG_ID_COUNTER.fetch_add(1, Ordering::SeqCst), + timestamp: timestamp.clone(), + time_epoch, + method: method_str.clone(), + url: chosen_target.clone(), + origin: origin.clone(), + status: 0, + status_text: "DROPPED".to_string(), + duration_ms: start_instant.elapsed().as_millis() as u64, + request_headers: req_headers_val.clone(), + request_body: req_body_str.clone(), + response_headers: serde_json::json!({}), + response_body: None, + content_type: String::new(), + content_length: 0, + blocked: true, + block_reason: Some(reason), + host_ip: None, + }; + emit_log(entry); + drop(request.into_writer()); + return; + } + + let rb = build_request(&chosen_target); + match rb.send() { + Ok(mut r) => { + let status = r.status().as_u16(); + let mut b = Vec::new(); + let _ = r.copy_to(&mut b); + let decomp = decompress_body_if_needed(b); + let html_body = is_html_bytes(&decomp); + + let is_js = req_is_js || is_js_path(&chosen_target); + let is_css = req_is_css || is_css_path(&chosen_target); + let is_redirect = status >= 300 && status < 400; + + let is_failure = (status >= 400) || (!is_redirect && (is_js || is_css) && html_body); + let has_more = idx + 1 < candidates.len(); + + if is_failure && has_more && (method_str == "GET" || method_str == "HEAD") { + continue; + } + + chosen_res = Some(r); + chosen_bytes = decomp; + break; + } + Err(e) => { + last_err = Some(e); + if idx + 1 < candidates.len() && (method_str == "GET" || method_str == "HEAD") { + continue; + } + break; } - } else { - Some(format!("", req_body.len())) } - } else { - None - }; + } - let mut res = match rb.send() { - Ok(r) => r, - Err(err) => { + let res = match chosen_res { + Some(r) => r, + None => { + let err_msg = last_err.map(|e| e.to_string()).unwrap_or_else(|| "Bad Gateway".to_string()); let entry = WebAppLogEntry { id: LOG_ID_COUNTER.fetch_add(1, Ordering::SeqCst), timestamp, time_epoch, method: method_str, - url: target_url.clone(), + url: chosen_target.clone(), origin, status: 502, status_text: "Bad Gateway".to_string(), @@ -525,7 +803,7 @@ fn handle_request( request_headers: req_headers_val, request_body: req_body_str, response_headers: serde_json::json!({}), - response_body: Some(err.to_string()), + response_body: Some(err_msg.clone()), content_type: "text/plain".to_string(), content_length: 0, blocked: false, @@ -534,119 +812,27 @@ fn handle_request( }; emit_log(entry); - if is_css_req || target_url.split('?').next().unwrap_or(&target_url).ends_with(".css") { - let headers = vec![ - Header::from_bytes(&b"Content-Type"[..], b"text/css; charset=utf-8").unwrap(), - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), - ]; - let res = Response::new(200.into(), headers, Cursor::new(Vec::new()), Some(0), None); - let _ = request.respond(res); - return; - } else if is_js_req || target_url.split('?').next().unwrap_or(&target_url).ends_with(".js") { - let headers = vec![ - Header::from_bytes(&b"Content-Type"[..], b"application/javascript; charset=utf-8").unwrap(), - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), - ]; - let res = Response::new(200.into(), headers, Cursor::new(Vec::new()), Some(0), None); - let _ = request.respond(res); - return; - } - - let _ = request.respond(Response::from_string("Bad Gateway").with_status_code(502)); + let err_body = err_msg.into_bytes(); + let err_len = err_body.len(); + let headers = vec![ + Header::from_bytes(&b"Content-Type"[..], b"text/plain; charset=utf-8").unwrap(), + Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), + Header::from_bytes(&b"Content-Length"[..], err_len.to_string().as_bytes()).unwrap(), + ]; + let res = Response::new(502.into(), headers, Cursor::new(err_body), Some(err_len), None); + let _ = request.respond(res); return; } }; - let clean_target = target_url.split('?').next().unwrap_or(&target_url); - let is_asset_request = clean_target.ends_with(".css") - || clean_target.ends_with(".js") - || clean_target.ends_with(".mjs") - || clean_target.ends_with(".svg") - || clean_target.ends_with(".png") - || clean_target.ends_with(".woff2") - || clean_target.ends_with(".woff") - || is_css_req - || is_js_req; - - let initial_status = res.status().as_u16(); - let initial_ct = res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - if is_asset_request && (initial_status == 404 || (res.status().is_success() && initial_ct.contains("text/html"))) { - let alt_target = if !base_url.is_empty() && target_url.starts_with(&base_url) { - format!("{}{}", origin.trim_end_matches('/'), req_url) - } else if !base_url.is_empty() && target_url.starts_with(&origin) { - let relative = req_url.trim_start_matches('/'); - format!("{}{}", base_url, relative) - } else { - String::new() - }; - - if !alt_target.is_empty() && alt_target != target_url { - let mut alt_rb = match method_str.as_str() { - "HEAD" => client.head(&alt_target), - _ => client.get(&alt_target), - }; - alt_rb = alt_rb.header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - ); - if !origin_header.is_empty() { - alt_rb = alt_rb.header("Referer", format!("{}/", origin_header)); - } - for h in request.headers() { - let name = h.field.as_str().as_str(); - if name.eq_ignore_ascii_case("Host") - || name.eq_ignore_ascii_case("Content-Length") - || name.eq_ignore_ascii_case("Origin") - || name.eq_ignore_ascii_case("Referer") - || name.eq_ignore_ascii_case("User-Agent") - || name.eq_ignore_ascii_case("Accept-Encoding") - { - continue; - } - if name.eq_ignore_ascii_case("Cookie") { - let filtered: Vec<&str> = h - .value - .as_str() - .split(';') - .map(|c| c.trim()) - .filter(|c| !c.starts_with("webapp_target=") && !c.starts_with("webapp_base=")) - .collect(); - if !filtered.is_empty() { - alt_rb = alt_rb.header("Cookie", filtered.join("; ")); - } - continue; - } - alt_rb = alt_rb.header(name, h.value.as_str()); - } - if let Ok(alt_res) = alt_rb.send() { - let alt_ct = alt_res - .headers() - .get(CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if alt_res.status().is_success() && !alt_ct.contains("text/html") { - res = alt_res; - target_url = alt_target; - } - } - } - } - - let clean_final = target_url.split('?').next().unwrap_or(&target_url); - let final_is_css = is_css_req || clean_final.ends_with(".css") || clean_req.ends_with(".css"); - let final_is_js = is_js_req - || clean_final.ends_with(".js") - || clean_req.ends_with(".js") - || clean_final.ends_with(".mjs") - || clean_req.ends_with(".mjs"); - let mut status = res.status().as_u16(); let status_text = res.status().canonical_reason().unwrap_or("").to_string(); + let effective_target = res.url().as_str().to_string(); + let final_url = effective_target.replace('"', "%22"); + + let is_js = req_is_js || is_js_path(&chosen_target) || is_js_path(&effective_target); + let is_css = req_is_css || is_css_path(&chosen_target) || is_css_path(&effective_target); + let is_redirect = status >= 300 && status < 400; let mut resp_headers_map = serde_json::Map::new(); for (k, v) in res.headers() { @@ -666,22 +852,20 @@ fn handle_request( .unwrap_or("") .to_string(); - if final_is_css { - content_type = "text/css; charset=utf-8".to_string(); - } else if final_is_js { + let is_js = is_js || content_type.contains("javascript"); + let is_css = is_css || content_type.contains("text/css"); + let html_body = is_html_bytes(&chosen_bytes); + + if is_js { content_type = "application/javascript; charset=utf-8".to_string(); - } else if clean_final.ends_with(".svg") || clean_req.ends_with(".svg") { - content_type = "image/svg+xml".to_string(); - } else if clean_final.ends_with(".woff2") || clean_req.ends_with(".woff2") { - content_type = "font/woff2".to_string(); - } else if clean_final.ends_with(".woff") || clean_req.ends_with(".woff") { - content_type = "font/woff".to_string(); - } else if clean_final.ends_with(".png") || clean_req.ends_with(".png") { - content_type = "image/png".to_string(); + } else if is_css { + content_type = "text/css; charset=utf-8".to_string(); + } else if content_type.is_empty() { + if let Some(mime) = mime_guess::from_path(&chosen_target).first().or_else(|| mime_guess::from_path(&req_url).first()) { + content_type = mime.to_string(); + } } - let final_url = res.url().as_str().replace('"', "%22"); - let mut headers = vec![ Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), Header::from_bytes( @@ -693,12 +877,6 @@ fn handle_request( Header::from_bytes(&b"Access-Control-Allow-Credentials"[..], b"true").unwrap(), ]; - if !content_type.is_empty() { - if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) { - headers.push(h); - } - } - for (k, v) in res.headers() { let name = k.as_str(); if name.eq_ignore_ascii_case("content-security-policy") @@ -709,6 +887,7 @@ fn handle_request( || name.eq_ignore_ascii_case("content-encoding") || name.eq_ignore_ascii_case("access-control-allow-origin") || name.eq_ignore_ascii_case("transfer-encoding") + || name.eq_ignore_ascii_case("connection") { continue; } @@ -722,7 +901,7 @@ fn handle_request( } if name.eq_ignore_ascii_case("location") { let loc_val = v.to_str().unwrap_or(""); - let resolved_loc = resolve_location_url(&target_url, loc_val); + let resolved_loc = resolve_location_url(&chosen_target, loc_val); let o = get_origin_from_url(&resolved_loc); if !o.is_empty() { if let Ok(mut lock) = state.last_origin.lock() { @@ -769,7 +948,7 @@ fn handle_request( return; } let proxy_loc = if resolved_loc.starts_with("http://") || resolved_loc.starts_with("https://") { - format!("http://127.0.0.1:11448/proxy?url={}", urlencoding::encode(&resolved_loc)) + format!("http://127.0.0.1:{}/{}/proxy?url={}", port, token, urlencoding::encode(&resolved_loc)) } else { resolved_loc }; @@ -783,127 +962,74 @@ fn handle_request( } } - let mut bytes = match res.bytes() { - Ok(b) => b.to_vec(), - Err(_) => { - if final_is_css { - let headers = vec![ - Header::from_bytes(&b"Content-Type"[..], b"text/css; charset=utf-8").unwrap(), - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), - ]; - let res = Response::new(200.into(), headers, Cursor::new(Vec::new()), Some(0), None); - let _ = request.respond(res); - return; - } else if final_is_js { - let headers = vec![ - Header::from_bytes(&b"Content-Type"[..], b"application/javascript; charset=utf-8").unwrap(), - Header::from_bytes(&b"Access-Control-Allow-Origin"[..], b"*").unwrap(), - ]; - let res = Response::new(200.into(), headers, Cursor::new(Vec::new()), Some(0), None); - let _ = request.respond(res); - return; - } - let _ = request.respond( - Response::from_string("Upstream Read Error").with_status_code(502), - ); - return; + let is_html = content_type.contains("text/html") && !is_js && !is_css; + let page_origin = { + let o = get_origin_from_url(&effective_target); + if o.is_empty() { + get_origin_from_url(&chosen_target) + } else { + o } }; - if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b { - let mut decoder = flate2::read::GzDecoder::new(&bytes[..]); - let mut decompressed = Vec::new(); - if decoder.read_to_end(&mut decompressed).is_ok() { - bytes = decompressed; - } - } else if bytes.len() >= 2 - && bytes[0] == 0x78 - && (bytes[1] == 0x9c || bytes[1] == 0x01 || bytes[1] == 0xda) - { - let mut decoder = flate2::read::ZlibDecoder::new(&bytes[..]); - let mut decompressed = Vec::new(); - if decoder.read_to_end(&mut decompressed).is_ok() { - bytes = decompressed; + let base_path_str = if let Ok(parsed) = url::Url::parse(&effective_target) { + let mut p = parsed.path().to_string(); + if let Some(pos) = p.rfind('/') { + p.truncate(pos + 1); + } else { + p = "/".to_string(); } - } - - if final_is_css && (status >= 400 || bytes.starts_with(b"= 200 && status < 300) { + let mut text = String::from_utf8_lossy(&chosen_bytes).to_string(); + text = rewrite_js_locations(&text); + let host_val = url::Url::parse(&effective_target) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + let port_suffix = url::Url::parse(&effective_target) + .ok() + .and_then(|u| u.port().map(|p| format!(":{}", p))) + .unwrap_or_default(); + let full_host = format!("{}{}", host_val, port_suffix); - if let Ok(re) = Regex::new(r"![a-zA-Z_$][a-zA-Z0-9_$]*\.test\([a-zA-Z_$][a-zA-Z0-9_$]*\.origin\)") { - text = re.replace_all(&text, "false").to_string(); - } - if let Ok(re) = Regex::new(r"[a-zA-Z_$][a-zA-Z0-9_$]*\.test\([a-zA-Z_$][a-zA-Z0-9_$]*\.origin\)") { - text = re.replace_all(&text, "true").to_string(); - } - - text.into_bytes() - } else if is_html { - let mut text = String::from_utf8_lossy(&bytes).to_string(); - let base_path_str = if let Ok(parsed) = url::Url::parse(&target_url) { - let mut p = parsed.path().to_string(); - if let Some(pos) = p.rfind('/') { - p.truncate(pos + 1); - } else { - p = "/".to_string(); - } - format!("{}{}", page_origin.trim_end_matches('/'), p) - } else { - String::new() - }; let origin_var = if !page_origin.is_empty() { format!( - r#""#, - page_origin, base_path_str, final_url + r#""#, + page_origin, base_path_str, final_url, host_val, full_host ) } else { format!(r#""#, final_url) }; - let full_shim = format!("{}{}", origin_var, SHIM_SCRIPT); + let full_shim = format!("{}{}", origin_var, build_shim_script(port, token)); let lower = text.to_ascii_lowercase(); let inject_pos = lower .find("") @@ -925,39 +1051,41 @@ fn handle_request( text = format!("{}{}", full_shim, text); } - if !page_origin.is_empty() { - let proxy_prefix = "http://127.0.0.1:11448/proxy?url="; - if let Ok(re_link) = Regex::new(r#"(<(?:link|script)\b[^>]*?\b(?:href|src)=["'])(/(?:assets/|[^"']+\.(?:css|js|mjs)))(["'])"#) { - let po = page_origin.clone(); - text = re_link.replace_all(&text, |caps: ®ex::Captures| { - let full_url = format!("{}{}", po.trim_end_matches('/'), &caps[2]); - format!("{}{}{}{}", &caps[1], proxy_prefix, urlencoding::encode(&full_url), &caps[3]) - }).to_string(); - } - if !base_path_str.is_empty() { - if let Ok(re_rel) = Regex::new(r#"(<(?:link|script)\b[^>]*?\b(?:href|src)=["'])(\.?/assets/[^"']+)(["'])"#) { - let bp = base_path_str.clone(); - text = re_rel.replace_all(&text, |caps: ®ex::Captures| { - let path = caps[2].trim_start_matches('.').trim_start_matches('/'); - let full_url = format!("{}{}", bp, path); - format!("{}{}{}{}", &caps[1], proxy_prefix, urlencoding::encode(&full_url), &caps[3]) - }).to_string(); - } - } - } - text.into_bytes() + } else if is_js && !is_redirect && (status >= 400 || html_body) { + status = 200; + content_type = "application/javascript; charset=utf-8".to_string(); + b"/* not found */".to_vec() + } else if is_css && !is_redirect && (status >= 400 || html_body) { + status = 200; + content_type = "text/css; charset=utf-8".to_string(); + Vec::new() + } else if is_js && (status >= 200 && status < 300) { + let text = String::from_utf8_lossy(&chosen_bytes).to_string(); + rewrite_js_locations(&text).into_bytes() } else { - bytes + chosen_bytes }; + if !content_type.is_empty() { + if let Ok(h) = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()) { + headers.push(h); + } + } + let len = final_bytes.len(); if let Ok(h) = Header::from_bytes(&b"Content-Length"[..], len.to_string().as_bytes()) { headers.push(h); } let resp_body_preview = if len > 0 { - if is_js || is_html || content_type.contains("json") || content_type.contains("text") || content_type.contains("xml") { + if is_html + || content_type.contains("javascript") + || content_type.contains("json") + || content_type.contains("text") + || content_type.contains("xml") + || content_type.contains("css") + { let slice_len = final_bytes.len().min(32768); if let Ok(s) = std::str::from_utf8(&final_bytes[..slice_len]) { if final_bytes.len() > 32768 { @@ -980,7 +1108,7 @@ fn handle_request( timestamp, time_epoch, method: method_str, - url: target_url, + url: chosen_target, origin, status, status_text, @@ -997,6 +1125,11 @@ fn handle_request( }; emit_log(log_entry); + let cookie_header = format!("__mp_token={}; Path=/; SameSite=Lax", token); + if let Ok(h) = Header::from_bytes(b"Set-Cookie", cookie_header.as_bytes()) { + headers.push(h); + } + let response = Response::new( status.into(), headers, @@ -1008,10 +1141,45 @@ fn handle_request( } pub fn start_webapp_proxy() { + let token = crate::proxy_auth::get_webapp_token(); + let bind_addr = std::env::var("MAXPLUS_WEBAPP_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .map(|p| format!("127.0.0.1:{}", p)) + .unwrap_or_else(|| "127.0.0.1:0".to_string()); + + let server = match Server::http(&bind_addr) { + Ok(s) => { + if let Some(addr) = s.server_addr().to_ip() { + crate::proxy_auth::set_webapp_proxy_info(addr.port(), token.clone()); + } + Arc::new(s) + } + Err(e) => { + eprintln!("Failed to bind webapp proxy: {}", e); + return; + } + }; + + let port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(0); + + let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { + if attempt.url().as_str().contains("externalCallback=1") { + attempt.stop() + } else if attempt.previous().len() >= 10 { + attempt.stop() + } else { + attempt.follow() + } + }); + thread::spawn(move || { let mut builder = reqwest::blocking::Client::builder() - .connect_timeout(Duration::from_secs(15)) - .redirect(reqwest::redirect::Policy::none()); + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(50) + .pool_idle_timeout(Duration::from_secs(90)) + .redirect(redirect_policy); if let Ok(cert) = reqwest::Certificate::from_pem(rumax::MINTSIFRY_ROOT_CA) { builder = builder.add_root_certificate(cert); } @@ -1019,21 +1187,15 @@ pub fn start_webapp_proxy() { let state = Arc::new(ProxyState { last_origin: Mutex::new(String::new()), last_base_url: Mutex::new(String::new()), + last_full_url: Mutex::new(String::new()), }); - let server = match Server::http("127.0.0.1:11448") { - Ok(s) => Arc::new(s), - Err(e) => { - eprintln!("Failed to bind webapp proxy: {}", e); - return; - } - }; - for request in server.incoming_requests() { let client = Arc::clone(&client); let state = Arc::clone(&state); + let token = token.clone(); thread::spawn(move || { - handle_request(request, &client, &state); + handle_request(request, &client, &state, &token, port); }); } }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bc912d9..a3670c6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Max", - "version": "0.1.3", + "productName": "MAX", + "version": "0.1.4", "identifier": "org.meowkie.max", "build": { "removeUnusedCommands": true, diff --git a/src/components/ChatWindow.svelte b/src/components/ChatWindow.svelte index 4b442a6..25d6924 100644 --- a/src/components/ChatWindow.svelte +++ b/src/components/ChatWindow.svelte @@ -7,7 +7,6 @@ beforeUpdate, afterUpdate, } from "svelte"; - import { fade, fly } from "svelte/transition"; import { writable, get } from "svelte/store"; import Message from "$components/ChatWindow/Message.svelte"; @@ -25,42 +24,41 @@ import { getContact } from "$lib/stores/contacts"; - import Session, { + import { openChat, closeChat, get as sessionGet, } from "$lib/stores/session"; import { handleReaction } from "$components/ChatWindow/actions.js"; - import { - checkForEncryptionRequest, - decode_msg, - } from "$components/ChatWindow/e2e.js"; + import { convertFileSrc } from "@tauri-apps/api/core"; + import { getProxiedMediaUrl } from "$lib/utils/images.js"; import { scrollToBottom } from "$lib/utils/scroll.js"; - import { getChatScroll, saveChatScroll } from "$lib/stores/chatScroll.js"; - import * as Caching from "$lib/utils/caching.js"; + import { getChatScroll } from "$lib/stores/chatScroll.js"; import Settings from "$components/ChatWindow/Settings.svelte"; import E2eModal from "$components/ChatWindow/E2eModal.svelte"; import Dropout from "$components/ChatWindow/Dropout.svelte"; - import Signature from "$components/main/Signature.svelte"; import MediaViewer from "$components/ChatWindow/MediaViewer.svelte"; import MediaPlaybackHeader from "$components/media/MediaPlaybackHeader.svelte"; - import { activeMedia, activeChatMessages, buildChatPlaylist } from "$lib/stores/mediaPlayback"; + import { activeMedia, activeChatMessages } from "$lib/stores/mediaPlayback"; import DateSeparator from "$components/ChatWindow/DateSeparator.svelte"; import Input from "$components/ChatWindow/input/Input.svelte"; import BotStart from "$components/ChatWindow/BotStart.svelte"; - import Avatar from "$components/main/Avatar.svelte"; import StickerPackModal from "$components/ChatWindow/Stickers/StickerPackModal.svelte"; import EditHistoryModal from "$components/ChatWindow/EditHistoryModal.svelte"; import { computeTextDiff } from "$lib/utils/diff.js"; import { clearChatNotification } from "$lib/utils/notifications.js"; + import ChatHeader from "$components/ChatWindow/ChatHeader.svelte"; + import ScrollDownButton from "$components/ChatWindow/ScrollDownButton.svelte"; + import { swipeToClose } from "$components/ChatWindow/swipeToClose.js"; + import { createVirtualScrollManager, DEFAULT_HEIGHT } from "$components/ChatWindow/chatVirtualScroll.js"; + import { createMessagesLoader, BATCH_SIZE } from "$components/ChatWindow/chatMessagesLoader.js"; + export let chatId; $: chat = $currentSessionChats?.find((c) => String(c.id) === String(chatId)); let title; - - let startSecretChatRequest = null; let gotSecretChatRequest = null; let replyTo = null; @@ -82,12 +80,7 @@ }; } - let loading = false; - let all_loaded = false; - let loadingNewer = false; - let all_loaded_newer = true; let allRendered = false; - let scrollElement; let scrollLoaderTimeout; let scrollBottomLoaderTimeout; @@ -95,18 +88,12 @@ let viewerOpen = false; let viewerIndex = 0; - - let lastDate; - let clickStartPos = { x: 0, y: 0 }; const messages = writable([]); $: if ($messages) { activeChatMessages.set($messages); } - let initialized = false; - - const BATCH_SIZE = 40; $: avatarUserId = (() => { if (chat?.type !== "DIALOG") return undefined; @@ -125,7 +112,6 @@ })(); $: unreadBadgeCount = Math.max(0, Number($currentSessionChats?.find((x) => x.id === chat?.id)?.newMessages ?? chat?.newMessages ?? 0)); - $: chatSettings = getChatSettings(chat?.id ?? chatId); const onBack = getContext("onBack"); @@ -139,190 +125,11 @@ closeChat(chat?.id ?? chatId); } - let touchStartX = 0; - let touchStartY = 0; let currentDragX = 0; - let isTouchTracking = false; let isSwipingChat = false; - let isScrollingChat = false; let isClosingBySwipe = false; - let chatWindowWidth = 0; - - function handleTouchStart(e) { - if (e.touches.length !== 1) { - isTouchTracking = false; - return; - } - if (viewerOpen || settingsShown || dropoutActiveAt || isClosingBySwipe) { - isTouchTracking = false; - return; - } - if (e.target.closest("input, textarea, button, a, .icon-button, .scroll-down-container, .media-playback-header, .timeline-track-container, .speed-control-wrapper, .volume-control-wrapper, .hdr-btn")) { - isTouchTracking = false; - return; - } - - isTouchTracking = true; - touchStartX = e.touches[0].clientX; - touchStartY = e.touches[0].clientY; - currentDragX = 0; - isSwipingChat = false; - isScrollingChat = false; - chatWindowWidth = window.innerWidth; - } - - function handleTouchMove(e) { - if (!isTouchTracking || isScrollingChat || isClosingBySwipe) return; - if (e.touches.length !== 1) return; - - const currentX = e.touches[0].clientX; - const currentY = e.touches[0].clientY; - const diffX = currentX - touchStartX; - const diffY = currentY - touchStartY; - - if (!isSwipingChat) { - if (Math.abs(diffX) > 10 || Math.abs(diffY) > 10) { - if (diffX > 10 && diffX > Math.abs(diffY) * 1.1) { - isSwipingChat = true; - } else { - isScrollingChat = true; - return; - } - } else { - return; - } - } - - if (isSwipingChat) { - if (diffX > 0) { - currentDragX = diffX; - } else { - currentDragX = 0; - } - if (e.cancelable) e.preventDefault(); - } - } - - function handleTouchEnd() { - isTouchTracking = false; - if (!isSwipingChat || isClosingBySwipe) { - isSwipingChat = false; - isScrollingChat = false; - return; - } - - const threshold = chatWindowWidth * 0.3; - if (currentDragX >= threshold) { - isClosingBySwipe = true; - isSwipingChat = false; - currentDragX = chatWindowWidth; - setTimeout(() => { - handleCloseChat(); - }, 220); - } else { - isSwipingChat = false; - currentDragX = 0; - isScrollingChat = false; - } - } - - function handleTouchCancel() { - isTouchTracking = false; - if (!isClosingBySwipe) { - isSwipingChat = false; - isScrollingChat = false; - currentDragX = 0; - } - } - - let isMouseDragging = false; - let mouseStartX = 0; - let mouseStartY = 0; - let mouseDragEngaged = false; - - function handleMouseDown(e) { - if (isClosingBySwipe || viewerOpen || settingsShown || dropoutActiveAt) return; - if (e.button !== 0) return; - if (e.target.closest("input, textarea, button, a, .icon-button, .scroll-down-container, .media-playback-header, .timeline-track-container, .speed-control-wrapper, .volume-control-wrapper")) return; - - const isHeader = Boolean(e.target.closest("header")); - const isLeftEdge = e.clientX <= 60; - const isMessage = Boolean(e.target.closest(".message, .bubble")); - - if (!isHeader && !isLeftEdge && isMessage) { - return; - } - - mouseStartX = e.clientX; - mouseStartY = e.clientY; - isMouseDragging = true; - mouseDragEngaged = false; - chatWindowWidth = window.innerWidth; - - const onMouseMove = (moveEv) => { - if (!isMouseDragging) return; - const diffX = moveEv.clientX - mouseStartX; - const diffY = moveEv.clientY - mouseStartY; - - if (!mouseDragEngaged) { - if (diffX > 10 && diffX > Math.abs(diffY) * 1.1) { - mouseDragEngaged = true; - isSwipingChat = true; - document.body.style.userSelect = "none"; - document.body.style.cursor = "grabbing"; - } else if (Math.abs(diffY) > 10) { - isMouseDragging = false; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - return; - } - } - - if (mouseDragEngaged) { - currentDragX = Math.max(0, diffX); - moveEv.preventDefault(); - } - }; - - const onMouseUp = () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - document.body.style.userSelect = ""; - document.body.style.cursor = ""; - - if (!isMouseDragging) return; - isMouseDragging = false; - - if (!mouseDragEngaged) { - isSwipingChat = false; - currentDragX = 0; - return; - } - - const threshold = chatWindowWidth * 0.3; - if (currentDragX >= threshold) { - isClosingBySwipe = true; - isSwipingChat = false; - currentDragX = chatWindowWidth; - setTimeout(() => { - handleCloseChat(); - }, 220); - } else { - isSwipingChat = false; - currentDragX = 0; - } - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - } - - $: swipeStyle = (() => { - if (currentDragX > 0) { - return `transform: translate3d(${currentDragX}px, 0, 0);`; - } - return ""; - })(); + $: swipeStyle = currentDragX > 0 ? `transform: translate3d(${currentDragX}px, 0, 0);` : ""; onBack["chat"] = () => { handleCloseChat(); @@ -338,391 +145,39 @@ if (onBack.dropout) delete onBack["dropout"]; if (onBack.chatSettings) delete onBack["chatSettings"]; scrollResizeObserver?.disconnect(); - if (resizeObserver) resizeObserver.disconnect(); + virtualScroll.destroy(); }); - const DEFAULT_HEIGHT = 120; - const OVERSCAN = 1500; + const virtualScroll = createVirtualScrollManager(); + const { messageHeights, observeResize } = virtualScroll; - const messageHeights = writable({}); - let cumulativeHeights = []; let innerList; let visibleMessages = {}; - let scrollAnchor = { messageId: null, offset: 0 }; - - let pendingHeightUpdates = {}; - - let resizeObserver = null; - function setupResizeObserver() { - if (resizeObserver) return; - resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const el = entry.target; - const wrapper = el.closest('.message-wrapper'); - if (!wrapper) continue; - const id = wrapper.id?.replace('m-', ''); - if (!id) continue; - const height = entry.contentRect.height; - if (height > 0) { - pendingHeightUpdates[id] = height; - } - } - }); - } + let currentScrollAnchor = null; - function observeResize(node, id) { - if (resizeObserver) resizeObserver.observe(node); - return { - destroy() { - if (resizeObserver) resizeObserver.unobserve(node); - } - }; + function handleAnchorCapture() { + currentScrollAnchor = virtualScroll.captureScrollAnchor(scrollElement, visibleMessages); } - function applyPendingHeights() { - const updates = pendingHeightUpdates; - pendingHeightUpdates = {}; - const keys = Object.keys(updates); - if (keys.length === 0) return; - messageHeights.update(h => { - const newH = { ...h }; - for (const id of keys) newH[id] = updates[id]; - return newH; - }); - computeCumulativeHeights(); - } - - function computeCumulativeHeights() { - const heights = []; - let sum = 0; - for (const msg of $messages) { - const h = $messageHeights[msg.id] || DEFAULT_HEIGHT; - sum += h; - heights.push(sum); - } - cumulativeHeights = heights; - } - - function findIndexByOffset(target) { - let lo = 0, hi = cumulativeHeights.length; - while (lo < hi) { - const mid = Math.floor((lo + hi) / 2); - if (cumulativeHeights[mid] < target) lo = mid + 1; - else hi = mid; - } - return lo; - } - - function captureScrollAnchor() { - if (!scrollElement) return; - - const containerRect = scrollElement.getBoundingClientRect(); - - for (const id in visibleMessages) { - const el = visibleMessages[id]; - if (!el) continue; - - const rect = el.getBoundingClientRect(); - - if (rect.bottom > containerRect.top) { - scrollAnchor = { - id: el.id, - offset: rect.top - containerRect.top - }; - return; - } - } - } - - function restoreScrollAnchor() { - if (!scrollAnchor || !scrollElement) return; - - const el = document.getElementById(scrollAnchor.id); - if (!el) return; - - const containerRect = scrollElement.getBoundingClientRect(); - const rect = el.getBoundingClientRect(); - - const delta = rect.top - containerRect.top - scrollAnchor.offset; - - if (delta !== 0) { - scrollElement.scrollTop += delta; - } - } - - async function updateVisibleMessages() { - if (!scrollElement) return; - - applyPendingHeights(); - - const { scrollTop, clientHeight } = scrollElement; - const totalHeight = cumulativeHeights.length ? cumulativeHeights[cumulativeHeights.length - 1] : 0; - if (totalHeight === 0) { - visibleMessages = {}; - return; - } - - const isNearBottom = totalHeight - scrollTop - clientHeight < 50; - let startIdx, endIdx; - - if (isNearBottom) { - const targetOffset = Math.max(0, totalHeight - clientHeight - OVERSCAN); - startIdx = findIndexByOffset(targetOffset); - endIdx = $messages.length - 1; - } else { - const viewTop = Math.max(0, scrollTop - OVERSCAN); - const viewBottom = scrollTop + clientHeight + OVERSCAN; - startIdx = findIndexByOffset(viewTop); - endIdx = findIndexByOffset(viewBottom); - endIdx = Math.min(endIdx, $messages.length - 1); - if (startIdx > endIdx) endIdx = startIdx; - } - - const newVisible = {}; - for (let i = startIdx; i <= endIdx && i < $messages.length; i++) { - const id = $messages[i].id; - if (!newVisible[id]) { - newVisible[id] = document.getElementById("m-" + id); - } - } - visibleMessages = newVisible; - - if (userHasScrolled) { - await scheduleRead(); - } - } - - function measureAllHeights() { - if (!innerList) return; - const wrappers = innerList.querySelectorAll('.message-wrapper'); - const updates = {}; - for (const wrapper of wrappers) { - const id = wrapper.id?.replace('m-', ''); - if (!id) continue; - const content = - wrapper.querySelector("#clickable-area") || - wrapper.querySelector(".observer-area") || - wrapper; - if (content) { - const height = content.getBoundingClientRect().height; - if (height > 0) updates[id] = height; - } - } - if (Object.keys(updates).length) { - messageHeights.update(h => ({ ...h, ...updates })); - } + function handleAnchorRestore() { + virtualScroll.restoreScrollAnchor(scrollElement, currentScrollAnchor); } const decodedMessages = writable({}); + $: chatCache = getChat(chat?.id ?? chatId); - const decodeMessagesBatch = async (list) => { - const updates = {}; - const toRemove = []; - - await Promise.all( - list.map(async msg => { - const res = await decode_msg(msg); - if (res) updates[msg.id] = res; - else toRemove.push(msg.id); - }) - ); - - decodedMessages.update(old => { - const next = { ...old, ...updates }; - for (const id of toRemove) delete next[id]; - return next; - }); - }; - - const mergeMessages = async ( - incoming, - updateCache = false - ) => { - if (!incoming?.length) return; - - const map = new Map( - get(messages).map(m => [String(m.id), m]) - ); - - const changed = []; - - for (const msg of incoming) { - const msgId = String(msg.id); - const old = map.get(msgId); - - if (!old) { - const isEdited = msg.status === "EDITED" || !!msg.edited; - const entry = { - ...msg, - id: msgId, - ...(isEdited ? { edited: true } : {}), - }; - map.set(msgId, entry); - changed.push(entry); - continue; - } - - const isEditedStatus = msg.status === "EDITED" || old.status === "EDITED" || old.edited || msg.edited; - const isDeletedStatus = old.deleted || msg.deleted || msg.status === "REMOVED"; - - let newHistory = Array.isArray(old.history) ? [...old.history] : []; - if (Array.isArray(msg.history)) { - for (const h of msg.history) { - if (!newHistory.some(existing => existing.at === h.at)) { - newHistory.push(h); - } - } - } - - const textChanged = old.text && msg.text && old.text !== msg.text; - if (textChanged && (!Array.isArray(msg.history) || !msg.history.length)) { - const textDiff = computeTextDiff(old.text, msg.text); - const at = msg.editTime || msg.edited_at || Date.now(); - newHistory.push({ at, diff: textDiff }); - } - - const merged = { - ...old, - ...msg, - id: msgId, - ...(isDeletedStatus ? { deleted: true, deleted_at: old.deleted_at || msg.deleted_at || Date.now() } : {}), - ...(isEditedStatus ? { edited: true, edited_at: old.edited_at || msg.edited_at || msg.editTime || Date.now() } : {}), - ...(newHistory.length ? { history: newHistory } : {}), - }; - - if (JSON.stringify(old) !== JSON.stringify(merged)) { - map.set(msgId, merged); - changed.push(merged); - } - } - - if (!changed.length) return; - - await decodeMessagesBatch(changed); - - messages.set( - [...map.values()].sort( - (a,b) => a.time - b.time - ) - ); - - if (updateCache) { - chatCache.updateMessages(changed); - } - }; - - const loadHistory = async ( - isInitial = false, - from = Date.now() + sessionGet("drift"), - backward = BATCH_SIZE, - forward = 0 - ) => { - const currentChatId = chat?.id ?? chatId; - if (loading) return; - if (all_loaded && !isInitial) return; - if (currentChatId == null) return; - - loading = true; - - try { - const cached = await chatCache.loadMessages( - from, - backward - ); - - captureScrollAnchor(); - - await mergeMessages(cached, false); - - restoreScrollAnchor(); - captureScrollAnchor(); - - if (!initialized || isInitial) { - const { - error, - messages: serverMessages - } = await $API.getMessages(currentChatId, from, backward, forward); - - if (error) throw new Error(error); - - await mergeMessages(serverMessages, true); - - if (serverMessages.length < backward + forward) { - if (forward === 0) { - all_loaded = true; - } - } - - initialized = true; - } else { - const oldest = get(messages)[0]; - - const { - error, - messages: olderMessages - } = await $API.getMessages( - currentChatId, - oldest?.time ?? from, - backward, - forward - ); - - if (error) throw new Error(error); - - if (olderMessages.length < backward) { - all_loaded = true; - } - - await mergeMessages( - olderMessages, - true - ); - } - } catch(e) { - console.error(e); - } finally { - restoreScrollAnchor(); - loading = false; - } - - restoreScrollAnchor(); - }; - - const loadNewer = async () => { - const currentChatId = chat?.id ?? chatId; - if (loadingNewer || all_loaded_newer) return; - if (currentChatId == null) return; - - loadingNewer = true; - - try { - const msgs = get(messages); - const newest = msgs[msgs.length - 1]; - const fromTime = newest?.time ?? (Date.now() + sessionGet("drift")); - - const { - error, - messages: newerMessages - } = await $API.getNewerMessages(currentChatId, fromTime, BATCH_SIZE); - - if (error) throw new Error(error); - - if (!newerMessages || newerMessages.length < BATCH_SIZE) { - all_loaded_newer = true; - } - - await mergeMessages( - newerMessages, - false - ); - } catch (e) { - console.error(e); - } finally { - loadingNewer = false; - } - - restoreScrollAnchor(); - }; + const loader = createMessagesLoader({ + messages, + decodedMessages, + getChatObj: () => chat, + getChatId: () => chatId, + getChatSettings: () => chatSettings, + getChatCache: () => chatCache, + getApi: () => $API, + onAnchorCapture: handleAnchorCapture, + onAnchorRestore: handleAnchorRestore, + onSecretChatRequest: (req) => { gotSecretChatRequest = req; }, + }); let scrollTimeout = null; let updateScheduled = false; @@ -730,62 +185,29 @@ let savePositionTimeout = null; let isInitialMounting = true; let isProgrammaticScroll = false; - let readTimer = null; let lastReadMessageId = null; - function saveCurrentPosition() { - const targetChatId = chat?.id ?? chatId; - if (!scrollElement || targetChatId == null || isInitialMounting || isProgrammaticScroll) return; - if (scrollElement.clientHeight <= 0 || scrollElement.scrollHeight <= 0) return; - if (scrollElement.scrollHeight <= scrollElement.clientHeight + 20) return; - const distanceFromBottom = - scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - const isAtBottom = all_loaded_newer && distanceFromBottom < 60; - if (isAtBottom) { - saveChatScroll(targetChatId, { - wasAtBottom: true, - lastSeenTime: Date.now(), - }); - return; - } - const containerRect = scrollElement.getBoundingClientRect(); - let bottomMsg = null; - let bottomOffset = 0; - let maxBottom = -Infinity; - - for (const id in visibleMessages) { - const el = visibleMessages[id]; - if (!el) continue; - const rect = el.getBoundingClientRect(); - if (rect.top < containerRect.bottom && rect.bottom > maxBottom) { - maxBottom = rect.bottom; - const m = $messages.find((x) => String(x.id) === String(id)); - if (m) { - bottomMsg = m; - bottomOffset = rect.top - containerRect.top; - } - } - } - - if (!bottomMsg && $messages && $messages.length > 0) { - const currentScroll = scrollElement.scrollTop; - const idx = findIndexByOffset(currentScroll + scrollElement.clientHeight / 2); - if (idx >= 0 && idx < $messages.length) { - bottomMsg = $messages[idx]; - bottomOffset = 40; - } + async function updateVisibleMessages() { + visibleMessages = virtualScroll.calculateVisibleMessages({ + scrollElement, + messagesList: $messages, + }); + if (userHasScrolled) { + await scheduleRead(); } + } - if (bottomMsg) { - saveChatScroll(targetChatId, { - wasAtBottom: false, - bottomMessageId: bottomMsg.id, - bottomMessageTime: bottomMsg.time, - offset: bottomOffset, - lastSeenTime: Date.now(), - }); - } + function saveCurrentPosition() { + virtualScroll.savePosition({ + targetChatId: chat?.id ?? chatId, + scrollElement, + all_loaded_newer: loader.all_loaded_newer, + visibleMessages, + messagesList: $messages, + isInitialMounting, + isProgrammaticScroll, + }); } function queueSavePosition() { @@ -803,9 +225,8 @@ userHasScrolled = true; queueSavePosition(); const target = event.currentTarget; - const distanceFromBottom = - target.scrollHeight - target.scrollTop - target.clientHeight; - showScrollDown = !all_loaded_newer || distanceFromBottom > 50; + const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight; + showScrollDown = !loader.all_loaded_newer || distanceFromBottom > 50; if (!updateScheduled) { updateScheduled = true; @@ -815,40 +236,38 @@ }); } - if (target.scrollTop <= 50 && !loading && !all_loaded) { + if (target.scrollTop <= 50 && !loader.loading && !loader.all_loaded) { if (scrollLoaderTimeout) return; scrollLoaderTimeout = setTimeout(async () => { - await loadHistory(); + await loader.loadHistory(); await updateVisibleMessages(); - setTimeout(() => scrollLoaderTimeout = null, 500); + setTimeout(() => (scrollLoaderTimeout = null), 500); }, 200); } - if (distanceFromBottom <= 100 && !loadingNewer && !all_loaded_newer) { + if (distanceFromBottom <= 100 && !loader.loadingNewer && !loader.all_loaded_newer) { if (scrollBottomLoaderTimeout) return; scrollBottomLoaderTimeout = setTimeout(async () => { - await loadNewer(); + await loader.loadNewer(); await updateVisibleMessages(); - setTimeout(() => scrollBottomLoaderTimeout = null, 500); + setTimeout(() => (scrollBottomLoaderTimeout = null), 500); }, 200); } } function getLowestVisibleMessageId() { if (!scrollElement) return null; - let lowest = null; let lowestTop = -Infinity; for (const key in visibleMessages) { - const entry = $messages.find(x => String(x.id) === String(key)); + const entry = $messages.find((x) => String(x.id) === String(key)); if (!entry || Number(entry.sender) === Number($currentUser)) continue; const el = visibleMessages[key]; if (!el) continue; const rect = el.getBoundingClientRect(); - if (rect.bottom < 0 || rect.top > window.innerHeight) continue; if (rect.top > lowestTop) { @@ -864,7 +283,7 @@ if (readTimer) clearTimeout(readTimer); readTimer = setTimeout(async () => { - const readDisabled = !$chatSettings.reader; + const readDisabled = !$chatSettings?.reader; if (readDisabled) return; const msgId = getLowestVisibleMessageId(); @@ -872,13 +291,13 @@ if (msgId === lastReadMessageId) return; - const index = $messages.length - $messages.findIndex(x => x.id === msgId) - if (index > chat.newMessages) return; + const index = $messages.length - $messages.findIndex((x) => x.id === msgId); + if (index > chat?.newMessages) return; lastReadMessageId = msgId; try { - chat.newMessages = index - 1; + if (chat) chat.newMessages = index - 1; await $API.readMessage(chat.id, msgId); } catch (e) { console.error("readMessage failed", e); @@ -886,8 +305,6 @@ }, 500); } - $: chatCache = getChat(chat?.id ?? chatId); - let unsubReceivedMessage = null; let currentSubscribedChatId = null; let lastProcessedMessageKey = null; @@ -904,7 +321,7 @@ unsubReceivedMessage = currentChatCache.receivedMessage.subscribe(async (message) => { if (!message || String(message.chatId) !== String(targetChatId)) return; - const msgKey = `${message.id}_${message.time || message.created_at || ''}_${message.status || ''}`; + const msgKey = `${message.id}_${message.time || message.created_at || ""}_${message.status || ""}`; if (msgKey === lastProcessedMessageKey) return; lastProcessedMessageKey = msgKey; @@ -922,7 +339,7 @@ let newHistory = Array.isArray(old.history) ? [...old.history] : []; if (Array.isArray(message.history)) { for (const h of message.history) { - if (!newHistory.some(existing => existing.at === h.at)) newHistory.push(h); + if (!newHistory.some((existing) => existing.at === h.at)) newHistory.push(h); } } if (old.text && message.text && old.text !== message.text && (!Array.isArray(message.history) || !message.history.length)) { @@ -941,25 +358,19 @@ } return _messages; }); - const decoded = await decode_msg(message); - decodedMessages.update(d => { - const next = { ...d }; - if (decoded) next[message.id] = decoded; - else delete next[message.id]; - return next; - }); + await loader.decodeMessagesBatch([message]); await tick(); - applyPendingHeights(); - computeCumulativeHeights(); - await updateVisibleMessages(wasAtBottom); + virtualScroll.applyPendingHeights($messages); + virtualScroll.computeCumulativeHeights($messages); + await updateVisibleMessages(); return; } if (message.sender === $currentUser) { - all_loaded_newer = true; + loader.all_loaded_newer = true; } - if (all_loaded_newer) { + if (loader.all_loaded_newer) { messages.update((_messages) => { const idx = _messages.findIndex((x) => String(x.id) === String(message.id)); if (idx !== -1) { @@ -976,22 +387,18 @@ return _messages; }); - const decoded = await decode_msg(message); - if (decoded) decodedMessages.update(d => ({ ...d, [message.id]: decoded })); - + await loader.decodeMessagesBatch([message]); await tick(); - applyPendingHeights(); - computeCumulativeHeights(); + virtualScroll.applyPendingHeights($messages); + virtualScroll.computeCumulativeHeights($messages); const isRecentSelfMessage = message.sender === $currentUser && (Date.now() - (message.time || Date.now()) < 5000); if (isRecentSelfMessage || wasAtBottom) { scrollToBottom(scrollElement, true); } - await updateVisibleMessages(wasAtBottom); + await updateVisibleMessages(); } - - checkForEncryptionRequest(chat, chatSettings, [message]); }); } } @@ -1021,23 +428,10 @@ return result; })(); - $: cachedContact = chat.type === "DIALOG" ? getContact(avatarUserId) : writable(undefined); - $: title = chat.id === 0 ? "Избранное" : (chat.title || $cachedContact?.names?.[0]?.name); + $: cachedContact = chat?.type === "DIALOG" ? getContact(avatarUserId) : writable(undefined); + $: title = chat?.id === 0 ? "Избранное" : (chat?.title || $cachedContact?.names?.[0]?.name); $: isBot = $cachedContact?.options?.includes("BOT") || chat?.options?.BOT === true || chat?.options?.IS_BOT === true; - $: otherReadTime = (() => { - let maxMark = chat?.otherReadTime || 0; - const myId = Number($currentUser); - if (chat?.participants) { - for (const [uid, mark] of Object.entries(chat.participants)) { - if (Number(uid) !== myId && Number(mark) > maxMark) { - maxMark = Number(mark); - } - } - } - return maxMark; - })(); - let botInfo = null; let botCommands = []; let botStarting = false; @@ -1111,7 +505,7 @@ const targetChatId = chat?.id ?? chatId; clearChatNotification(targetChatId); - setupResizeObserver(); + virtualScroll.setupResizeObserver(); startAutoScrollIfAtBottom(); const unreadCount = Number(chat?.newMessages || 0); @@ -1121,18 +515,18 @@ let initialFrom = Date.now() + sessionGet("drift"); if (hasSavedScroll) { initialFrom = Number(savedPos.bottomMessageTime) + 1; - all_loaded_newer = false; + loader.all_loaded_newer = false; showScrollDown = true; - await loadHistory(true, initialFrom, 35, 35); + await loader.loadHistory(true, initialFrom, 35, 35); } else { - all_loaded_newer = true; - await loadHistory(true, initialFrom, 40, 0); + loader.all_loaded_newer = true; + await loader.loadHistory(true, initialFrom, 40, 0); } await tick(); - measureAllHeights(); - computeCumulativeHeights(); + virtualScroll.measureAllHeights(innerList); + virtualScroll.computeCumulativeHeights($messages); if (hasSavedScroll) { let targetId = savedPos.bottomMessageId; @@ -1159,7 +553,7 @@ offset: savedPos.offset || 40, }); } - if (!restored && !all_loaded_newer) { + if (!restored && !loader.all_loaded_newer) { const msgs = $messages; if (msgs.length > 0) { const mid = msgs[Math.floor(msgs.length / 2)].id; @@ -1200,9 +594,9 @@ }); async function jumpToBottom() { - if (!all_loaded_newer) { - all_loaded_newer = true; - await loadHistory(true, Date.now() + sessionGet("drift"), 40, 0); + if (!loader.all_loaded_newer) { + loader.all_loaded_newer = true; + await loader.loadHistory(true, Date.now() + sessionGet("drift"), 40, 0); await tick(); } scrollToBottom(scrollElement, true); @@ -1251,23 +645,32 @@ e.target.closest(".voice-message-bubble") || e.target.closest(".video-note-bubble") || e.target.closest(".transcription-card") || - e.target.closest(".transcription-close-btn") + e.target.closest(".transcription-close-btn") || + e.target.closest(".media-grid") || + e.target.closest(".grid-item") || + e.target.closest(".attaches") || + e.target.closest(".media-download-badge") || + e.target.closest(".file-attachment") || + e.target.closest(".file-attach") || + e.target.closest(".attach") || + e.target.closest(".encrypted-media-placeholder") ) return; if (clicked) { const messageWrapper = e.target.closest(".message-wrapper"); if (messageWrapper) { const id = messageWrapper.id?.replace("m-", ""); - const msg = $messages.find(x => String(x.id) === String(id)); + const msg = $messages.find((x) => String(x.id) === String(id)); if (e.target.closest(".reaction")) { const reaction = e.target.childNodes[0]?.nodeValue?.trim(); if (reaction && msg) { await handleReaction(chat, msg, reaction); - messages.update(x => x); + messages.update((x) => x); } + } else if (msg && !dropoutActiveAt) { + selectMessage(e, msg); } - else if (msg && !dropoutActiveAt) selectMessage(e, msg); } } } @@ -1324,7 +727,15 @@ e.target.closest(".inline-keyboard") || e.target.closest(".inline-btn") || e.target.closest(".avatar-msg-btn") || - e.target.closest(".avatar-wrapper") + e.target.closest(".avatar-wrapper") || + e.target.closest(".media-grid") || + e.target.closest(".grid-item") || + e.target.closest(".attaches") || + e.target.closest(".media-download-badge") || + e.target.closest(".file-attachment") || + e.target.closest(".file-attach") || + e.target.closest(".attach") || + e.target.closest(".encrypted-media-placeholder") ) return; const dx = Math.abs(e.clientX - clickStartPos.x); @@ -1335,7 +746,6 @@ justOpenedDropout = true; requestAnimationFrame(() => (justOpenedDropout = false)); - dropoutActiveAt = { e, msg }; } @@ -1344,10 +754,9 @@ dropoutActiveAt = null; const action = e.detail?.action; - if (action === "delete") { - messages.update(x => { - const idx = x.findIndex(m => String(m.id) === String(msgId)); + messages.update((x) => { + const idx = x.findIndex((m) => String(m.id) === String(msgId)); if (idx !== -1) { x[idx] = { ...x[idx], @@ -1357,21 +766,19 @@ } return [...x]; }); - getChat(chat?.id || chatId).markMessageDeleted?.(msgId); } else if (action === "reaction") { - messages.update(x => x); + messages.update((x) => x); } } - const openSettings = () => { + function openSettings() { settingsShown = !settingsShown; if (settingsShown) onBack.chatSettings = () => (settingsShown = false); else delete onBack["chatSettings"]; - }; + } let dateSeparators = {}; - $: if ($messages.length) { const newSeparators = {}; let lastDateStr = null; @@ -1387,7 +794,6 @@ let scrollResizeObserver; let lastClientHeight = 0; - let prevActiveMediaBool = null; let scrollAnchorBeforeMediaChange = null; @@ -1417,11 +823,10 @@ function startAutoScrollIfAtBottom() { if (!scrollElement) return; - lastClientHeight = scrollElement.clientHeight; scrollResizeObserver = new ResizeObserver(() => { - if (!scrollElement || isInitialMounting || isProgrammaticScroll || !all_loaded_newer) return; + if (!scrollElement || isInitialMounting || isProgrammaticScroll || !loader.all_loaded_newer) return; const { scrollTop, scrollHeight, clientHeight } = scrollElement; const heightDelta = clientHeight - lastClientHeight; lastClientHeight = clientHeight; @@ -1503,28 +908,81 @@ } } - /* media stuff */ - $: allMedia = $messages.flatMap((m) => - (m.attaches || []) + $: otherReadTime = (() => { + let maxMark = chat?.otherReadTime || 0; + const myId = Number($currentUser); + if (chat?.participants) { + for (const [uid, mark] of Object.entries(chat.participants)) { + if (Number(uid) !== myId && Number(mark) > maxMark) { + maxMark = Number(mark); + } + } + } + return maxMark; + })(); + + $: allMedia = $messages.flatMap((m) => { + const decoded = $decodedMessages[String(m.id)]; + const media = decoded?.media; + const isMe = Number(m.sender) === Number($currentUser); + const attaches = (m.attaches || []).map((att, idx) => { + if (media && idx === (media.attach_index ?? 0)) { + const resolvedType = media.media_type || att._type || "FILE"; + const localPath = att.localPath || (isMe ? att.path : null); + return { + ...att, + _type: resolvedType, + type: resolvedType, + originalType: media.media_type, + name: media.name || att.name, + size: media.size || att.size, + mime: media.mime || att.mime, + width: media.width ?? att.width, + height: media.height ?? att.height, + duration: media.duration ?? att.duration, + wave: media.wave ?? att.wave, + videoType: media.video_type ?? att.videoType, + color: media.color || null, + isEncryptedMedia: true, + encryptedAttach: att, + localPath, + baseUrl: att.baseUrl || (localPath ? (resolvedType === "VIDEO" ? getProxiedMediaUrl(localPath) : convertFileSrc(localPath)) : null), + }; + } + return att; + }); + + return attaches .filter((a) => a._type === "PHOTO" || a._type === "VIDEO") - .map((a) => ({ - ...a, - messageId: m.id, - uid: a.videoId || a.photoId || a.url || a.baseUrl, - })), - ); + .map((a) => { + const fid = a.fileId || a.encryptedAttach?.fileId; + const uid = a.videoId || a.photoId || fid || a.url || a.baseUrl || a.localPath || `${m.id}_${a.name || 'media'}`; + return { + ...a, + messageId: m.id, + uid: String(uid), + }; + }); + }); function openMedia(attach) { - const targetUid = - attach.videoId || attach.photoId || attach.url || attach.baseUrl; - const index = allMedia.findIndex((m) => m.uid === targetUid); + const fid = attach.fileId || attach.encryptedAttach?.fileId; + const targetUid = String(attach.videoId || attach.photoId || fid || attach.url || attach.baseUrl || attach.localPath || ""); + const index = allMedia.findIndex((m) => + (targetUid && m.uid === targetUid) || + (attach.baseUrl && m.baseUrl === attach.baseUrl) || + (attach.localPath && m.localPath === attach.localPath) || + (fid && (m.fileId === fid || m.encryptedAttach?.fileId === fid)) + ); if (index !== -1) { + if (attach.baseUrl) allMedia[index].baseUrl = attach.baseUrl; + if (attach.localPath) allMedia[index].localPath = attach.localPath; + allMedia = [...allMedia]; viewerIndex = index; viewerOpen = true; } } -
0 || isClosingBySwipe)} style={swipeStyle} + use:swipeToClose={{ + canSwipe: () => !viewerOpen && !settingsShown && !dropoutActiveAt && !isClosingBySwipe, + onClose: handleCloseChat, + onStateChange: (state) => { + currentDragX = state.currentDragX; + isSwipingChat = state.isSwipingChat; + isClosingBySwipe = state.isClosingBySwipe; + }, + }} on:click|capture={handleClick} - on:mousedown={handleMouseDown} - on:touchstart={handleTouchStart} - on:touchmove={handleTouchMove} - on:touchend={handleTouchEnd} - on:touchcancel={handleTouchCancel} > @@ -1550,41 +1012,13 @@ /> {/if} -
-
- -
{ - if (chat.id === 0) { - $Session.profile = { userId: $currentUser }; - } else if (chat.type === "DIALOG") { - $Session.profile = { userId: avatarUserId }; - } else { - $Session.profile = { chatId: chat.id }; - } - }} - > - -
- {title} - -
-
-
-
- {#if chat.type !== "CHANNEL"} - - {/if} -
-
+ {#if $activeMedia} @@ -1625,40 +1059,39 @@ style={"opacity: " + (allRendered ? "1;" : "0;")} bind:this={innerList} > +
+ + {#each uniqueMessages as msg (msg.id)} +
+ {#if visibleMessages[msg.id] || !$messageHeights[msg.id]} +
+ {#if dateSeparators[msg.id]} + + {/if} + openMedia(e.detail.attach)} + on:openChat={() => openChat(chat.id, msg.id)} + on:openStickerPack={(e) => handleOpenStickerPack(e.detail.sticker)} + on:openHistory={(e) => (historyModalMessage = e.detail.msg)} + /> +
+ {:else} +
+ {/if} +
+ {/each} -
- - {#each uniqueMessages as msg (msg.id)} -
- {#if visibleMessages[msg.id] || !$messageHeights[msg.id]} -
- {#if dateSeparators[msg.id]} - - {/if} - openMedia(e.detail.attach)} - on:openChat={() => openChat(chat.id, msg.id)} - on:openStickerPack={(e) => handleOpenStickerPack(e.detail.sticker)} - on:openHistory={(e) => (historyModalMessage = e.detail.msg)} - /> -
- {:else} -
- {/if} -
- {/each} - -
+
@@ -1710,36 +1143,18 @@ {messages} {chatSettings} {botCommands} + {decodedMessages} /> {/if} - {#if showScrollDown} -
- - {#if unreadBadgeCount > 0} -
- {unreadBadgeCount > 99 ? "99+" : unreadBadgeCount} -
- {/if} -
- {/if} + diff --git a/src/components/ChatWindow/ChatHeader.svelte b/src/components/ChatWindow/ChatHeader.svelte new file mode 100644 index 0000000..50963a4 --- /dev/null +++ b/src/components/ChatWindow/ChatHeader.svelte @@ -0,0 +1,144 @@ + + +
+
+ +
+ +
+ {title} + +
+
+
+
+ {#if chat && chat.type !== "CHANNEL"} + + {/if} +
+
+ + diff --git a/src/components/ChatWindow/E2eModal.svelte b/src/components/ChatWindow/E2eModal.svelte index b743406..1ceea88 100644 --- a/src/components/ChatWindow/E2eModal.svelte +++ b/src/components/ChatWindow/E2eModal.svelte @@ -1,5 +1,6 @@ + + {#if gotSecretChatRequest} - - {:else} + {:else if currentMedia.videoId}
loadVideo(currentMedia.videoId)} > preview
@@ -620,6 +738,10 @@ {/if}
+ {:else if currentMedia.isEncryptedMedia} +
+
+
{/if} {/if} {/key} diff --git a/src/components/ChatWindow/Message.svelte b/src/components/ChatWindow/Message.svelte index f11bdbb..45ce482 100644 --- a/src/components/ChatWindow/Message.svelte +++ b/src/components/ChatWindow/Message.svelte @@ -32,11 +32,40 @@ $: isRead = !isSending && (msg.read === true || msg.status === 3 || msg.status === "read" || (otherReadTime > 0 && otherReadTime >= (msg.time || 0))); const isSystem = msg.attaches?.[0]?._type === "CONTROL"; - $: lines = (decoded?.text || msg.text)?.split("\n"); + $: rawText = decoded ? (decoded.text ?? "") : (msg.text || ""); + $: lines = rawText ? rawText.split("\n") : []; + $: effectiveAttaches = (() => { + if (!msg.attaches || !msg.attaches.length) return []; + if (!decoded?.media) return msg.attaches; + const media = decoded.media; + const targetIdx = media.attach_index ?? 0; + return msg.attaches.map((att, idx) => { + if (idx === targetIdx) { + const resolvedType = media.media_type || att._type || "FILE"; + return { + ...att, + _type: resolvedType, + type: resolvedType, + originalType: media.media_type, + name: media.name || att.name, + size: media.size || att.size, + mime: media.mime || att.mime, + width: media.width ?? att.width, + height: media.height ?? att.height, + duration: media.duration ?? att.duration, + wave: media.wave ?? att.wave, + videoType: media.video_type ?? att.videoType, + color: media.color || null, + isEncryptedMedia: true, + encryptedAttach: att, + localPath: att.localPath || (isMe ? att.path : null), + }; + } + return att; + }); + })(); $: transcription = $transcriptions[String(msg?.id)]; - let innerWidth = 0; - function handleMediaClick(attach) { dispatch("openMedia", { attach }); } @@ -86,20 +115,22 @@ $: linkedMsgContact = cachedLinkedContact && $cachedLinkedContact; $: column = - msg.text?.length > 20 || - msg.attaches?.length || + rawText?.length > 20 || + effectiveAttaches?.length || msg.reactionInfo?.totalCount || msg.link?.messageId; + let innerWidth = typeof window !== "undefined" ? window.innerWidth : 0; + $: showAvatar = chat.type !== "CHANNEL" && - (!isMe || innerWidth > 960) && + (!isMe || innerWidth > 500) && !isSystem; - $: inlineKeyboardAttach = msg.attaches?.find(x => x._type === "INLINE_KEYBOARD"); - $: stickerAttach = msg.attaches?.find(x => x._type === "STICKER"); - $: isStickerOnly = stickerAttach && (!lines || lines.length === 0 || (lines.length === 1 && !lines[0]?.trim())) && (!msg.attaches || msg.attaches.length === 1) && !linkedMsg; - $: isVideoNoteOnly = msg.attaches?.some(x => x._type === "VIDEO" && x.videoType === 1) && (!lines || lines.length === 0 || (lines.length === 1 && !lines[0]?.trim())) && (!msg.attaches || msg.attaches.length === 1) && !linkedMsg; + $: inlineKeyboardAttach = effectiveAttaches?.find(x => x._type === "INLINE_KEYBOARD"); + $: stickerAttach = effectiveAttaches?.find(x => x._type === "STICKER"); + $: isStickerOnly = stickerAttach && (!lines || lines.length === 0 || (lines.length === 1 && !lines[0]?.trim())) && (!effectiveAttaches || effectiveAttaches.length === 1) && !linkedMsg; + $: isVideoNoteOnly = effectiveAttaches?.some(x => x._type === "VIDEO" && x.videoType === 1) && (!lines || lines.length === 0 || (lines.length === 1 && !lines[0]?.trim())) && (!effectiveAttaches || effectiveAttaches.length === 1) && !linkedMsg; @@ -111,6 +142,7 @@ class:is-deleted={msg.deleted} class:is-sticker={isStickerOnly} class:is-video-note={isVideoNoteOnly} + class:is-channel={chat.type === "CHANNEL"} class:inactive={/* todo optimize */ dropoutActiveAt && dropoutActiveAt?.msg?.id !== msg.id} > @@ -266,10 +298,10 @@ {/each} {/if} - {#if msg.attaches?.length} + {#if effectiveAttaches?.length} + import { createEventDispatcher } from "svelte"; + import { fade } from "svelte/transition"; + + export let showScrollDown = false; + export let chatType = ""; + export let hasReply = false; + export let showStickerPanel = false; + export let unreadBadgeCount = 0; + + const dispatch = createEventDispatcher(); + + function handleClick() { + dispatch("click"); + } + + +{#if showScrollDown} +
+ + {#if unreadBadgeCount > 0} +
{ if (e.key === 'Enter') handleClick(); }}> + {unreadBadgeCount > 99 ? "99+" : unreadBadgeCount} +
+ {/if} +
+{/if} + + diff --git a/src/components/ChatWindow/Settings.svelte b/src/components/ChatWindow/Settings.svelte index 0415f91..9f8d59e 100644 --- a/src/components/ChatWindow/Settings.svelte +++ b/src/components/ChatWindow/Settings.svelte @@ -4,6 +4,7 @@ import { dict } from '$lib/crypto/text-codec'; import API from "$lib/stores/api"; import { isChatMuted } from "$lib/utils/notifications"; + import { autoDownloadEncryptedMedia } from "$lib/stores/e2eSettings.js"; export let chat; export let messages; @@ -83,7 +84,7 @@ class="row-action" on:click={() => switchEnc(chat, chatSettings, messages)} > - { !$chatSettings.keys.current ? "Новая сессия" : "Отключить" } + { !($chatSettings.keys?.current || $chatSettings.session) ? "Новая сессия" : "Отключить" }
@@ -92,15 +93,29 @@ Статус
- { $chatSettings.keys.current ? "Активно" - : $chatSettings.keys.keys?.some(x => x.edp === null) ? "Предложение отправлено" + { ($chatSettings.keys?.current || $chatSettings.session) ? "Активно" + : $chatSettings.pending ? "Предложение отправлено" : "Отключено" }
+ {#if $chatSettings.session?.fingerprint} +
+
+ Ключ сессии +
+
+ {$chatSettings.session.fingerprint} +
+
+ {/if} +
+
Автозагрузка зашифрованных медиа
+ +
Уведомления
+ + + + +
+ + diff --git a/src/components/ChatWindow/input/EditingBanner.svelte b/src/components/ChatWindow/input/EditingBanner.svelte new file mode 100644 index 0000000..df91207 --- /dev/null +++ b/src/components/ChatWindow/input/EditingBanner.svelte @@ -0,0 +1,83 @@ + + +{#if editingMessage} +
+
+ + + +
+
+
Редактирование сообщения
+
{editingMessage.text || 'Вложения'}
+
+ +
+{/if} + + diff --git a/src/components/ChatWindow/input/Input.svelte b/src/components/ChatWindow/input/Input.svelte index 89544d8..5b8b46a 100644 --- a/src/components/ChatWindow/input/Input.svelte +++ b/src/components/ChatWindow/input/Input.svelte @@ -1,13 +1,11 @@ -{#if attaches.length} -
- {#each attaches as attach, i} - {@const attachType = attach.type || attach._type} -
- - - {#if attachType === "PHOTO"} - preview - {:else if attachType === "VIDEO"} - {#if attach.path} - - {:else} -
🎥
Видео
- {/if} - {:else if attachType === "AUDIO"} -
🎵
Голосовое
- {:else} -
-
📄
-
- {attach.name || (attach.path ? attach.path.split("/").pop() : "Файл")} -
-
- {/if} -
- {/each} -
-{/if} + -{#if editingMessage} -
-
- - - -
-
-
Редактирование сообщения
-
{editingMessage.text || 'Вложения'}
-
- -
-{/if} + removeAttach(e.detail.index)} /> + + {#if replyTo} {/if} - -
{#if stickerSuggestions.length > 0} - {#if isFallbackVideo} - - {:else} - - {/if} - -
+ session.flipCamera(videoPreviewEl)} + /> {/if} {#if isReviewingVoice} - {@const voiceReviewBars = Array.from(generateWaveformFromAmplitudes(reviewAmplitudes, 60))} - {@const voiceProgress = reviewAudioDuration > 0 ? Math.min(1, Math.max(0, (reviewAudioCurrentTime - reviewAudioTrimStart) / (reviewAudioTrimEnd - reviewAudioTrimStart))) : 0} -
- - - - -
- -
- - {#each voiceReviewBars as bar, i} - {@const h = Math.max(2, Math.round(bar * 22))} - {@const played = (i + 0.5) / voiceReviewBars.length <= voiceProgress} - - {/each} - -
- - -
-
- {formatSeconds(reviewAudioTrimStart)} - {(reviewAudioTrimEnd - reviewAudioTrimStart).toFixed(1)}s - {formatSeconds(reviewAudioTrimEnd)} -
-
-
+ {#if attachesDropout} + selectFile(e.detail.type)} /> + {/if} - -
- {:else if isReviewingVideoNote} - {@const reviewDur = reviewTrimEnd - reviewTrimStart} - {@const reviewProgress = reviewDur > 0 ? Math.min(1, Math.max(0, (reviewCurrentTime - reviewTrimStart) / reviewDur)) : 0} - {@const ringSize = 200} - {@const ringStroke = 4} - {@const ringRadius = (ringSize - ringStroke) / 2} - {@const ringCirc = 2 * Math.PI * ringRadius} -
- - - - -
{ if (e.key === ' ' || e.key === 'Enter') toggleReviewPlay(); }}> -