commit d1c2d4e62f81bda423c1816ddc7a63542ce0b15f Author: Autli Date: Sat Oct 24 16:30:48 2026 +0500 feat: initial release v0.0.1 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e590be4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.armv7-linux-androideabi] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.i686-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ea7a563 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: Release Build & Auto-Updater + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Target Release Tag (e.g. v0.1.5)' + required: false + default: 'v0.1.5' + +jobs: + release: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: 'macos-latest' + target: 'aarch64-apple-darwin' + - platform: 'windows-latest' + target: 'x86_64-pc-windows-msvc' + - platform: 'ubuntu-22.04' + target: 'x86_64-unknown-linux-gnu' + + runs-on: ${{ matrix.platform }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux build dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev rpm + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Setup Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install dependencies + run: npm install + + - name: Build Tauri and publish Release + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || 'dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5VUF0UnlMZjR6bE4vUUFIbnQ0MDdjQ01xSXBHUVVMZ0k2OURzMEhrbkZwNEFBQkFBQUFBQUFBQUFBQUlBQUFBQU1GZmxKUFZFcGJXSW1LUlRvVXN6SjJSV1FYMnhYQldjOFRtTE4rRkRSUGEvM0xDVFZPOVovL3Q1N3Rmdi9PUG9XOWxGdHM5bjFNc3BKZCtCWHdkWDVsNW9LaHhZNXpFbFNSYkNkeXFLQmRuNmNyeHA0Rlp4UVhSUENPUFJrUnVCandLd2ZBSy95V2M9Cg==' }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '' + with: + tagName: ${{ inputs.tag || github.ref_name }} + releaseName: 'Home Autli Server ${{ inputs.tag || github.ref_name }}' + releaseBody: 'Автоматический релиз для Android (.apk), Linux (.deb, .rpm, .AppImage), Windows (.exe) и macOS (.dmg).' + releaseDraft: false + prerelease: false + args: ${{ matrix.target && format('--target {0}', matrix.target) || '' }} + + release-android: + needs: release + if: always() && (needs.release.result == 'success' || needs.release.result == 'skipped') + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Setup Rust stable + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi + + - name: Setup Android NDK + run: | + echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> $GITHUB_ENV + if [ -d "$ANDROID_SDK_ROOT/ndk/26.1.10909125" ]; then + NDK_PATH="$ANDROID_SDK_ROOT/ndk/26.1.10909125" + else + NDK_LATEST=$(ls -d $ANDROID_SDK_ROOT/ndk/* 2>/dev/null | tail -n 1 || true) + if [ -n "$NDK_LATEST" ]; then + NDK_PATH="$NDK_LATEST" + else + echo "y" | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager "ndk;26.1.10909125" + NDK_PATH="$ANDROID_SDK_ROOT/ndk/26.1.10909125" + fi + fi + echo "NDK_HOME=$NDK_PATH" >> $GITHUB_ENV + + TOOLCHAIN="$NDK_PATH/toolchains/llvm/prebuilt/linux-x86_64" + echo "TOOLCHAIN=$TOOLCHAIN" >> $GITHUB_ENV + echo "$TOOLCHAIN/bin" >> $GITHUB_PATH + + # Create symlinks for legacy GNU binutils expected by OpenSSL makefile + for prefix in aarch64-linux-android arm-linux-androideabi armv7a-linux-androideabi; do + sudo ln -sf "$TOOLCHAIN/bin/llvm-ar" "/usr/local/bin/${prefix}-ar" + sudo ln -sf "$TOOLCHAIN/bin/llvm-ranlib" "/usr/local/bin/${prefix}-ranlib" + sudo ln -sf "$TOOLCHAIN/bin/llvm-nm" "/usr/local/bin/${prefix}-nm" + sudo ln -sf "$TOOLCHAIN/bin/llvm-as" "/usr/local/bin/${prefix}-as" + sudo ln -sf "$TOOLCHAIN/bin/llvm-strip" "/usr/local/bin/${prefix}-strip" + + sudo ln -sf "$TOOLCHAIN/bin/llvm-ar" "$TOOLCHAIN/bin/${prefix}-ar" + sudo ln -sf "$TOOLCHAIN/bin/llvm-ranlib" "$TOOLCHAIN/bin/${prefix}-ranlib" + sudo ln -sf "$TOOLCHAIN/bin/llvm-nm" "$TOOLCHAIN/bin/${prefix}-nm" + sudo ln -sf "$TOOLCHAIN/bin/llvm-as" "$TOOLCHAIN/bin/${prefix}-as" + sudo ln -sf "$TOOLCHAIN/bin/llvm-strip" "$TOOLCHAIN/bin/${prefix}-strip" + done + + - name: Install dependencies + run: npm install + + - name: Build Android APK + run: npm run tauri -- android build --apk --target aarch64 armv7 + env: + NDK_HOME: ${{ env.NDK_HOME }} + OPENSSL_STATIC: "1" + AR_aarch64_linux_android: llvm-ar + RANLIB_aarch64_linux_android: llvm-ranlib + AR_armv7_linux_androideabi: llvm-ar + RANLIB_armv7_linux_androideabi: llvm-ranlib + + - name: Upload APK to GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + RELEASE_TAG="${{ inputs.tag || github.ref_name }}" + echo "Target Release Tag is: $RELEASE_TAG" + + mkdir -p dist-apk + for apk in $(find src-tauri/gen/android/app/build/outputs/apk -type f -name "*.apk"); do + filename=$(basename "$apk") + echo "Found APK: $apk ($filename)" + cp "$apk" "dist-apk/Home.Autli.Server_${RELEASE_TAG}_${filename}" + if [[ "$filename" == *"universal"* ]] || [[ "$filename" == *"release"* ]]; then + cp "$apk" "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk" + fi + done + + if [ ! -f "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk" ]; then + first_apk=$(find dist-apk -type f -name "*.apk" | head -n 1) + if [ -n "$first_apk" ]; then + cp "$first_apk" "dist-apk/Home.Autli.Server_${RELEASE_TAG}.apk" + fi + fi + + echo "Uploading APK assets to GitHub release $RELEASE_TAG..." + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" --title "Home Autli Server $RELEASE_TAG" --notes "Release $RELEASE_TAG" + fi + gh release upload "$RELEASE_TAG" dist-apk/*.apk --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c548b57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables (NEVER commit any .env file to GitHub!) +.env* + +# Tauri update private signing key (KEEP SECRET!) +src-tauri/*.key diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..c8ba358 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,31 @@ +# Maintainer: autli5 +pkgname=home-autli-server-bin +pkgver=0.1.5 +pkgrel=1 +pkgdesc="Home Autli Server (HAS) - Кинотеатр (Full HD + Русская озвучка) и Облачное хранилище" +arch=('x86_64') +url="https://github.com/autli5/has" +license=('custom') +depends=('webkit2gtk-4.1' 'libappindicator-gtk3' 'librsvg' 'openssl' 'hicolor-icon-theme') +options=(!strip) + +source=( + "https://github.com/autli5/has/releases/download/v${pkgver}/home-autli-server_${pkgver}_amd64.deb" + "https://raw.githubusercontent.com/autli5/has/main/public/logo.png" +) +sha256sums=( + 'SKIP' + 'SKIP' +) + +package() { + # Распаковываем DEB пакет + tar -xf data.tar.* -C "${pkgdir}/" + + # Устанавливаем логотип + install -Dm644 "${srcdir}/logo.png" "${pkgdir}/usr/share/icons/hicolor/512x512/apps/has.png" + + # Создаем симлинк для быстрого запуска через 'has' в терминале + mkdir -p "${pkgdir}/usr/bin" + ln -sf "/usr/bin/home-autli-server" "${pkgdir}/usr/bin/has" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..db2aac9 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Home Autli Server (HAS) 🚀 + +> **Личный облачный сервер, Кинотеатр Full HD с полной русской озвучкой (дубляж) и защищенное хранилище данных.** + +[![Release Build & Auto-Updater](https://github.com/autli5/has/actions/workflows/release.yml/badge.svg)](https://github.com/autli5/has/actions/workflows/release.yml) +[![Version](https://img.shields.io/badge/version-v0.1.5-blue.svg)](https://github.com/autli5/has/releases/latest) +[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows%20%7C%20macOS-success.svg)](https://github.com/autli5/has/releases/latest) + +--- + +## ⚡ Быстрая установка на Linux через Терминал + +Поддерживаются любые дистрибутивы: **Ubuntu, Debian, Fedora, Arch Linux, CachyOS, Manjaro, Linux Mint, Pop!_OS** и другие. + +Выполните одну команду в терминале: + +```bash +curl -fsSL https://raw.githubusercontent.com/autli5/has/main/install.sh | bash +``` + +*или через `wget`:* + +```bash +wget -qO- https://raw.githubusercontent.com/autli5/has/main/install.sh | bash +``` + +Установщик сам определит ваш дистрибутив (Debian, Ubuntu, Fedora, Arch, CachyOS), скачает подходящий пакет (`.deb`, `.rpm` или `.AppImage`), установит необходимые зависимости и создаст ярлык в меню приложений. + +Запуск из терминала: +```bash +has +``` + +--- + +## 📦 Ручная установка по дистрибутивам + +### 🐧 Ubuntu / Debian / Linux Mint / Pop!_OS (`.deb`) + +```bash +# 1. Скачивание последнего пакета .deb +curl -s https://api.github.com/repos/autli5/has/releases/latest | grep "browser_download_url.*amd64.deb" | cut -d : -f 2,3 | tr -d \" | wget -qi - + +# 2. Установка через dpkg +sudo dpkg -i home-autli-server_*_amd64.deb || sudo apt-get install -f -y +``` + +### 🎩 Fedora / RHEL / CentOS (`.rpm`) + +```bash +# 1. Скачивание пакета .rpm +curl -s https://api.github.com/repos/autli5/has/releases/latest | grep "browser_download_url.*x86_64.rpm" | cut -d : -f 2,3 | tr -d \" | wget -qi - + +# 2. Установка через dnf +sudo dnf install -y ./home-autli-server-*.rpm +``` + +### 🏹 Arch Linux / CachyOS / Manjaro / EndeavourOS + +#### Вариант A: Через `install.sh` (Рекомендуется) +```bash +curl -fsSL https://raw.githubusercontent.com/autli5/has/main/install.sh | bash -s -- --arch +``` + +#### Вариант B: Через `PKGBUILD` +```bash +git clone https://github.com/autli5/has.git +cd has +makepkg -si +``` + +--- + +## 💻 Другие операционные системы + +- **🍏 macOS (Apple Silicon & Intel):** Скачайте `.dmg` из [последнего релиза](https://github.com/autli5/has/releases/latest). +- **🪟 Windows 10/11 (64-bit):** Скачайте `.exe` установщик из [последнего релиза](https://github.com/autli5/has/releases/latest). + +--- + +## ✨ Основные возможности приложения + +- 🎬 **Кинотеатр нового поколения:** + - Огромный каталог фильмов, сериалов и аниме без региональных блокировок. + - **100% Русская озвучка / Дубляж по умолчанию:** Smotrim, Embess, Alloha (Red Head Sound, Flarrow Films, СНГ-дубляж). + - Студийные озвучки для аниме: **Studio Band, AniLibria, AniDUB, SHIZA Project**. + - Полноэкранный режим с поддержкой звука и выбором серий прямо в плеере. +- 📁 **Облачное хранилище файлов:** Загрузка, папки, превью и мгновенная синхронизация. +- 👤 **Профиль пользователя:** Настройка темы, персонализация и управление аккаунтом. +- 🔄 **Авто-обновления:** Встроенный механизм проверки и загрузки новых версий (Tauri Updater). diff --git a/index.html b/index.html new file mode 100644 index 0000000..11a8833 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + has + + + +
+ + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1200255 --- /dev/null +++ b/install.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# ============================================================================== +# Home Autli Server (HAS) - Универсальный установщик для Linux через терминал +# Поддерживает: Ubuntu, Debian, Fedora, Arch Linux, CachyOS, Manjaro и др. +# +# Быстрый запуск одной командой в терминале: +# curl -fsSL https://raw.githubusercontent.com/autli5/has/main/install.sh | bash +# или +# wget -qO- https://raw.githubusercontent.com/autli5/has/main/install.sh | bash +# ============================================================================== + +set -e + +REPO="autli5/has" +APP_NAME="Home Autli Server" +BIN_NAME="has" + +# Цвета для вывода в терминале +BOLD="\033[1m" +GREEN="\033[0;32m" +BLUE="\033[0;34m" +CYAN="\033[0;36m" +YELLOW="\033[1;33m" +RED="\033[0;31m" +RESET="\033[0m" + +echo -e "${CYAN}${BOLD}" +echo "==========================================================" +echo " 🚀 Установка Home Autli Server (HAS) для Linux " +echo " Кинотеатр (Full HD + Русская озвучка) и Облако " +echo " Поддержка: Ubuntu, Debian, Fedora, Arch, CachyOS " +echo "==========================================================" +echo -e "${RESET}" + +# 1. Проверка архитектуры системы +ARCH=$(uname -m) +if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then + echo -e "${RED}Ошибка: архитектура $ARCH не поддерживается.${RESET}" + exit 1 +fi + +# 2. Определение дистрибутива Linux +PKG_TYPE="" +for arg in "$@"; do + case "$arg" in + --deb) PKG_TYPE="deb" ;; + --rpm) PKG_TYPE="rpm" ;; + --arch|--cachyos) PKG_TYPE="arch" ;; + --appimage) PKG_TYPE="appimage" ;; + esac +done + +if [ -z "$PKG_TYPE" ]; then + if [ -f /etc/os-release ]; then + . /etc/os-release + DISTRO_ID="${ID:-linux}" + DISTRO_LIKE="${ID_LIKE:-}" + + case "$DISTRO_ID $DISTRO_LIKE" in + *ubuntu*|*debian*|*linuxmint*|*pop*|*elementary*|*zorin*|*kali*) + PKG_TYPE="deb" + echo -e "${BLUE}ℹ️ Обнаружен дистрибутив Debian / Ubuntu (${DISTRO_ID}). Формат: .deb${RESET}" + ;; + *fedora*|*rhel*|*centos*|*rocky*|*almalinux*) + PKG_TYPE="rpm" + echo -e "${BLUE}ℹ️ Обнаружен дистрибутив Fedora / Red Hat (${DISTRO_ID}). Формат: .rpm${RESET}" + ;; + *arch*|*cachyos*|*cachy*|*manjaro*|*endeavouros*|*artix*) + PKG_TYPE="arch" + echo -e "${BLUE}ℹ️ Обнаружен дистрибутив Arch Linux / CachyOS (${DISTRO_ID}).${RESET}" + ;; + *) + PKG_TYPE="appimage" + echo -e "${BLUE}ℹ️ Обнаружен дистрибутив Linux (${DISTRO_ID}). Формат: .AppImage${RESET}" + ;; + esac + else + PKG_TYPE="appimage" + echo -e "${YELLOW}⚠️ Не удалось определить дистрибутив. Будет использован универсальный AppImage.${RESET}" + fi +fi + +# 3. Получение последней версии из GitHub Releases +echo -e "${CYAN}🔍 Поиск последней версии в GitHub (${REPO})...${RESET}" + +RELEASE_JSON=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" || true) +TAG_NAME=$(echo "$RELEASE_JSON" | grep -m1 '"tag_name":' | cut -d '"' -f 4 || true) + +if [ -z "$TAG_NAME" ]; then + TAG_NAME="v0.1.5" + echo -e "${YELLOW}⚠️ Использована версия по умолчанию: ${TAG_NAME}${RESET}" +else + echo -e "${GREEN}✓ Найдена версия: ${TAG_NAME}${RESET}" +fi + +# 4. Поиск прямой ссылки на скачивание нужного пакета +DOWNLOAD_URL="" +case "$PKG_TYPE" in + deb) + DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o 'https://[^"]*\.deb' | head -n 1 || true) + ;; + rpm) + DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o 'https://[^"]*\.rpm' | head -n 1 || true) + ;; + arch|appimage) + DOWNLOAD_URL=$(echo "$RELEASE_JSON" | grep -o 'https://[^"]*\.AppImage' | head -n 1 || true) + ;; +esac + +# Запасные прямые URL если GitHub API не вернул ссылки в JSON +if [ -z "$DOWNLOAD_URL" ]; then + case "$PKG_TYPE" in + deb) + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${TAG_NAME}/home-autli-server_${TAG_NAME#v}_amd64.deb" + ;; + rpm) + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${TAG_NAME}/home-autli-server-${TAG_NAME#v}-1.x86_64.rpm" + ;; + arch|appimage) + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${TAG_NAME}/home-autli-server_${TAG_NAME#v}_amd64.AppImage" + ;; + esac +fi + +TMP_DIR=$(mktemp -d /tmp/has-install.XXXXXX) +trap 'rm -rf "$TMP_DIR"' EXIT + +FILE_NAME=$(basename "$DOWNLOAD_URL") +TMP_FILE="$TMP_DIR/$FILE_NAME" + +echo -e "${CYAN}⬇️ Скачивание пакета (${FILE_NAME})...${RESET}" +if command -v curl &>/dev/null; then + curl -fL --progress-bar "$DOWNLOAD_URL" -o "$TMP_FILE" +elif command -v wget &>/dev/null; then + wget -q --show-progress "$DOWNLOAD_URL" -O "$TMP_FILE" +else + echo -e "${RED}Ошибка: необходимо установить curl или wget.${RESET}" + exit 1 +fi + +echo -e "${GREEN}✓ Файл успешно загружен.${RESET}" + +# 5. Установка пакета в систему +echo -e "${CYAN}⚙️ Установка ${APP_NAME}...${RESET}" + +case "$PKG_TYPE" in + deb) + echo -e "${BLUE}Выполняется установка .deb через dpkg / apt...${RESET}" + if sudo dpkg -i "$TMP_FILE"; then + echo -e "${GREEN}✓ Пакет установлен.${RESET}" + else + echo -e "${YELLOW}Доустановка недостающих зависимостей...${RESET}" + sudo apt-get update -qq + sudo apt-get install -f -y + fi + ;; + rpm) + echo -e "${BLUE}Выполняется установка .rpm через dnf / rpm...${RESET}" + if command -v dnf &>/dev/null; then + sudo dnf install -y "$TMP_FILE" + elif command -v yum &>/dev/null; then + sudo yum localinstall -y "$TMP_FILE" + else + sudo rpm -Uvh --replacepkgs "$TMP_FILE" + fi + ;; + arch) + echo -e "${BLUE}Настройка для Arch Linux / CachyOS...${RESET}" + # Проверка и установка зависимостей через pacman при необходимости + if command -v pacman &>/dev/null; then + echo -e "${CYAN}Проверка зависимостей (webkit2gtk-4.1, libappindicator-gtk3)...${RESET}" + sudo pacman -S --needed --noconfirm webkit2gtk-4.1 libappindicator-gtk3 librsvg || true + fi + + chmod +x "$TMP_FILE" + INSTALL_BIN="$HOME/.local/bin" + mkdir -p "$INSTALL_BIN" + mv "$TMP_FILE" "$INSTALL_BIN/$BIN_NAME" + + # Скачивание логотипа HAS для меню CachyOS / Arch + ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps" + mkdir -p "$ICON_DIR" + curl -sL "https://raw.githubusercontent.com/${REPO}/main/public/logo.png" -o "$ICON_DIR/has.png" 2>/dev/null || true + + # Создание .desktop файла для меню приложений (KDE Plasma, GNOME, Hyprland, XFCE) + DESKTOP_DIR="$HOME/.local/share/applications" + mkdir -p "$DESKTOP_DIR" + cat < "$DESKTOP_DIR/home-autli-server.desktop" +[Desktop Entry] +Name=Home Autli Server +Comment=Личный сервер, Кинотеатр (Full HD + Русская озвучка) и Облако +Exec=$INSTALL_BIN/$BIN_NAME +Icon=$ICON_DIR/has.png +Terminal=false +Type=Application +Categories=AudioVideo;Player;Network;Utility; +StartupWMClass=home-autli-server +EOF + chmod +x "$DESKTOP_DIR/home-autli-server.desktop" + echo -e "${GREEN}✓ Исполняемый файл установлен в $INSTALL_BIN/$BIN_NAME${RESET}" + echo -e "${GREEN}✓ Ярлык добавлен в меню приложений Arch / CachyOS.${RESET}" + ;; + appimage) + echo -e "${BLUE}Настройка переносимого AppImage...${RESET}" + chmod +x "$TMP_FILE" + INSTALL_BIN="$HOME/.local/bin" + mkdir -p "$INSTALL_BIN" + mv "$TMP_FILE" "$INSTALL_BIN/$BIN_NAME" + + ICON_DIR="$HOME/.local/share/icons" + mkdir -p "$ICON_DIR" + curl -sL "https://raw.githubusercontent.com/${REPO}/main/public/logo.png" -o "$ICON_DIR/has.png" 2>/dev/null || true + + DESKTOP_DIR="$HOME/.local/share/applications" + mkdir -p "$DESKTOP_DIR" + cat < "$DESKTOP_DIR/home-autli-server.desktop" +[Desktop Entry] +Name=Home Autli Server +Comment=Личный сервер, Кинотеатр и Хранилище +Exec=$INSTALL_BIN/$BIN_NAME +Icon=$ICON_DIR/has.png +Terminal=false +Type=Application +Categories=AudioVideo;Player;Network;Utility; +StartupWMClass=home-autli-server +EOF + chmod +x "$DESKTOP_DIR/home-autli-server.desktop" + echo -e "${GREEN}✓ Исполняемый файл установлен в $INSTALL_BIN/$BIN_NAME${RESET}" + echo -e "${GREEN}✓ Ярлык добавлен в меню приложений.${RESET}" + ;; +esac + +echo "" +echo -e "${GREEN}${BOLD}==========================================================${RESET}" +echo -e "${GREEN}${BOLD} 🎉 ${APP_NAME} успешно установлен на ваш Linux! ${RESET}" +echo -e "${GREEN}${BOLD}==========================================================${RESET}" +echo "" +echo -e "Вы можете запустить приложение:" +echo -e " 1. Из меню приложений вашего рабочего стола: ${BOLD}Home Autli Server${RESET}" +echo -e " 2. Или прямо в терминале командой: ${CYAN}${BOLD}has${RESET}" +echo "" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..53bfd7a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1215 @@ +{ + "name": "has", + "version": "0.1.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "has", + "version": "0.1.8", + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.4.0", + "@tauri-apps/plugin-updater": "^2.13.3", + "hls.js": "^1.7.3", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "~6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tauri-apps/api": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.3.tgz", + "integrity": "sha512-9s0PKw0p4r3kFq92jhpivYSp+cSht15o0gXpZxuQd0+dV9KCexEVkNaLELVoLdJWyoH4KlfQo5h3W2LIM8siJg==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz", + "integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==", + "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.12.2", + "@tauri-apps/cli-darwin-x64": "2.12.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.2", + "@tauri-apps/cli-linux-arm64-musl": "2.12.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.2", + "@tauri-apps/cli-linux-x64-gnu": "2.12.2", + "@tauri-apps/cli-linux-x64-musl": "2.12.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.2", + "@tauri-apps/cli-win32-x64-msvc": "2.12.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz", + "integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz", + "integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz", + "integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz", + "integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz", + "integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz", + "integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz", + "integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz", + "integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz", + "integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz", + "integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==", + "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.12.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz", + "integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.1.tgz", + "integrity": "sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@tauri-apps/plugin-process": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.4.0.tgz", + "integrity": "sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.13.3.tgz", + "integrity": "sha512-CtbA8CZ79WJry2HbRiFGx6WZN7uspho6A4jM+TxmuqwWL2EEfJEMw6BIYgRncIZyqp4vEXw3U/97ZjQ33dKUgg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..667c374 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "has", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri", + "android": "./scripts/run-android.sh dev", + "android:build": "./scripts/run-android.sh build --apk" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2.4.0", + "@tauri-apps/plugin-updater": "^2.13.3", + "hls.js": "^1.7.3", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "~6.0.3", + "vite": "^8.0.16" + } +} diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..1e8ec03 Binary files /dev/null and b/public/logo.png differ diff --git a/public/tauri.svg b/public/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/run-android.sh b/scripts/run-android.sh new file mode 100755 index 0000000..ae12aff --- /dev/null +++ b/scripts/run-android.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$DIR" + +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +if [ -z "$NDK_HOME" ]; then + if [ -d "$ANDROID_HOME/ndk/27.2.12479018" ]; then + export NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" + elif [ -d "$ANDROID_HOME/ndk" ]; then + export NDK_HOME="$(ls -d "$ANDROID_HOME/ndk/"* 2>/dev/null | tail -n 1)" + fi +fi + +if [ -n "$NDK_HOME" ] && [ -d "$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin" ]; then + TOOLCHAIN_BIN="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin" + export PATH="$TOOLCHAIN_BIN:/usr/local/bin:/opt/homebrew/bin:$PATH" +fi + +export OPENSSL_STATIC=1 +export AR_aarch64_linux_android=llvm-ar +export RANLIB_aarch64_linux_android=llvm-ranlib +export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 ${RUSTFLAGS:-}" + +exec npx tauri android "$@" diff --git a/scripts/server-anti-ddos.sh b/scripts/server-anti-ddos.sh new file mode 100755 index 0000000..daf34f6 --- /dev/null +++ b/scripts/server-anti-ddos.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# ============================================================================== +# HAS Server Anti-DDoS & Port 5432 Protection Setup Script +# ============================================================================== +# This script hardens Ubuntu server against: +# 1. TCP SYN Flood & Connection Exhaustion DDoS +# 2. Port 5432 Port Scanning, Connection Floods & Slowloris +# 3. PostgreSQL Brute-Force & Credential Stuffing +# 4. Kernel Socket & Memory Exhaustion Attacks +# ============================================================================== + +set -e + +# Color output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}====================================================================${NC}" +echo -e "${BLUE} HAS SERVER ANTI-DDOS & PORT 5432 HARDENING SETUP ${NC}" +echo -e "${BLUE}====================================================================${NC}" + +# 1. Check Root +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}❌ Пожалуйста, запустите скрипт с правами суперпользователя: sudo bash $0${NC}" + exit 1 +fi + +echo -e "\n${YELLOW}>>> 1/5. Настройка ядра Linux против SYN Flood и DoS атак...${NC}" + +SYSCTL_CONF="/etc/sysctl.d/99-has-anti-ddos.conf" +cat << 'EOF' > "$SYSCTL_CONF" +# --- HAS Anti-DDoS Kernel Protection --- +# 1. SYN Flood Protection (Cryptographic SYN cookies when queue is under attack) +net.ipv4.tcp_syncookies = 1 +net.ipv4.tcp_max_syn_backlog = 8192 +net.ipv4.tcp_synack_retries = 2 +net.ipv4.tcp_syn_retries = 3 + +# 2. Fast timeout for abandoned and dead connections (Anti-Slowloris) +net.ipv4.tcp_fin_timeout = 15 +net.ipv4.tcp_keepalive_time = 30 +net.ipv4.tcp_keepalive_intvl = 10 +net.ipv4.tcp_keepalive_probes = 3 + +# 3. Socket reuse & connection scaling +net.ipv4.tcp_tw_reuse = 1 +net.core.somaxconn = 4096 +net.core.netdev_max_backlog = 5000 +net.ipv4.ip_local_port_range = 1024 65535 + +# 4. Anti-Spoofing & Broadcast ICMP flood protection +net.ipv4.conf.all.rp_filter = 1 +net.ipv4.conf.default.rp_filter = 1 +net.ipv4.icmp_echo_ignore_broadcasts = 1 +net.ipv4.icmp_ignore_bogus_error_responses = 1 +EOF + +sysctl -p "$SYSCTL_CONF" >/dev/null 2>&1 || sysctl --system >/dev/null 2>&1 +echo -e "${GREEN}✓ Параметры ядра sysctl применены: SYN-cookies включены, таймауты сброшены.${NC}" + +# 2. Iptables Rate-Limiting & Connection Limits on Port 5432 +echo -e "\n${YELLOW}>>> 2/5. Настройка фаервола iptables на порту 5432...${NC}" + +# Ensure iptables-persistent is installed +if ! command -v iptables &> /dev/null; then + apt-get update -y && apt-get install -y iptables +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get install -y iptables-persistent netfilter-persistent >/dev/null 2>&1 || true + +# Remove older duplicate HAS rules if any +iptables -D INPUT -p tcp --dport 5432 -m connlimit --connlimit-above 10 --connlimit-mask 32 -j REJECT --reject-with tcp-reset 2>/dev/null || true +iptables -D INPUT -p tcp --dport 5432 -m state --state NEW -m recent --update --seconds 10 --hitcount 20 --name HAS_PG_RATE -j DROP 2>/dev/null || true +iptables -D INPUT -p tcp --dport 5432 -m state --state NEW -m recent --set --name HAS_PG_RATE 2>/dev/null || true +iptables -D INPUT -p tcp --dport 5432 --syn -m limit --limit 30/s --limit-burst 60 -j ACCEPT 2>/dev/null || true + +# RULE A: Limit max concurrent connections per single IP to 10 +# (Real app uses 1-3 connections; botnet opening 50+ sockets gets immediate TCP RST) +iptables -I INPUT -p tcp --dport 5432 -m connlimit --connlimit-above 10 --connlimit-mask 32 -j REJECT --reject-with tcp-reset + +# RULE B: Rate-limit new connections (Anti-Scan / Anti-Burst Flood) +# (Max 20 new connections per 10 seconds per IP, rest are dropped silently) +iptables -I INPUT -p tcp --dport 5432 -m state --state NEW -m recent --update --seconds 10 --hitcount 20 --name HAS_PG_RATE -j DROP +iptables -I INPUT -p tcp --dport 5432 -m state --state NEW -m recent --set --name HAS_PG_RATE + +# RULE C: SYN flood packet rate limiting +iptables -I INPUT -p tcp --dport 5432 --syn -m limit --limit 30/s --limit-burst 60 -j ACCEPT + +# Save iptables rules to persist across reboot +if command -v netfilter-persistent &> /dev/null; then + netfilter-persistent save >/dev/null 2>&1 || true +fi +mkdir -p /etc/iptables +iptables-save > /etc/iptables/rules.v4 2>/dev/null || true +echo -e "${GREEN}✓ Iptables настроен: макс. 10 подключений на 1 IP, лимит новых сессий (20/10с), защита от SYN-flood.${NC}" + +# 3. Hardening PostgreSQL Server (postgresql.conf) +echo -e "\n${YELLOW}>>> 3/5. Оптимизация параметров безопасности PostgreSQL...${NC}" + +PG_CONF="" +for p in "/www/server/pgsql/data/postgresql.conf" $(find /etc/postgresql -name postgresql.conf 2>/dev/null); do + if [ -f "$p" ]; then + PG_CONF="$p" + break + fi +done + +if [ -n "$PG_CONF" ] && [ -f "$PG_CONF" ]; then + echo -e "Найден конфигурационный файл: ${BLUE}$PG_CONF${NC}" + cp "$PG_CONF" "${PG_CONF}.bak_$(date +%Y%m%d%H%M%S)" + + # Anti-Slowloris: reduce authentication timeout from 60s to 5s + sed -i "s/^#\?authentication_timeout\s*=.*/authentication_timeout = 5s/" "$PG_CONF" + + # Fast keepalive for dead client connections + sed -i "s/^#\?tcp_keepalives_idle\s*=.*/tcp_keepalives_idle = 15/" "$PG_CONF" + sed -i "s/^#\?tcp_keepalives_interval\s*=.*/tcp_keepalives_interval = 5/" "$PG_CONF" + sed -i "s/^#\?tcp_keepalives_count\s*=.*/tcp_keepalives_count = 3/" "$PG_CONF" + + # Connection limits & admin reservation + sed -i "s/^#\?max_connections\s*=.*/max_connections = 120/" "$PG_CONF" + sed -i "s/^#\?superuser_reserved_connections\s*=.*/superuser_reserved_connections = 5/" "$PG_CONF" + + # Reload PostgreSQL configuration without downtime + if [ -f "/www/server/pgsql/bin/pg_ctl" ]; then + su - postgres -c "/www/server/pgsql/bin/pg_ctl -D /www/server/pgsql/data reload" >/dev/null 2>&1 || true + elif [ -f "/etc/init.d/pgsql" ]; then + /etc/init.d/pgsql reload >/dev/null 2>&1 || true + else + systemctl reload postgresql >/dev/null 2>&1 || true + fi + echo -e "${GREEN}✓ PostgreSQL обновлен: authentication_timeout=5s (Anti-Slowloris), keepalive=15s, резерв для админа.${NC}" +else + echo -e "${YELLOW}⚠️ Файл postgresql.conf не найден автоматически, пропуск обновления параметров PG.${NC}" +fi + +# 4. Setup Fail2Ban against Brute-Force & Malicious Scanners +echo -e "\n${YELLOW}>>> 4/5. Настройка Fail2ban для автоматической блокировки атакующих ботов...${NC}" + +apt-get update -y >/dev/null 2>&1 || true +apt-get install -y fail2ban >/dev/null 2>&1 || true + +if command -v fail2ban-client &> /dev/null; then + mkdir -p /etc/fail2ban/filter.d /etc/fail2ban/jail.d + + cat << 'EOF' > /etc/fail2ban/filter.d/postgresql-has.conf +[Definition] +failregex = ^.*FATAL:\s+password authentication failed for user.*host=.*$ + ^.*FATAL:\s+no pg_hba.conf entry for host "".*$ +ignoreregex = +EOF + + PG_LOG="/www/server/pgsql/data/server.log" + if [ ! -f "$PG_LOG" ]; then + PG_LOG="/var/log/postgresql/postgresql-*.log" + fi + + cat << EOF > /etc/fail2ban/jail.d/postgresql-has.local +[postgresql-has] +enabled = true +port = 5432 +filter = postgresql-has +logpath = $PG_LOG +maxretry = 5 +findtime = 600 +bantime = 86400 +EOF + + systemctl restart fail2ban >/dev/null 2>&1 || service fail2ban restart >/dev/null 2>&1 || true + echo -e "${GREEN}✓ Fail2ban активирован: автобан на 24 часа при 5 неудачных попытках авторизации.${NC}" +else + echo -e "${YELLOW}⚠️ Fail2ban не удалось установить автоматически.${NC}" +fi + +# 5. Verification & Status Output +echo -e "\n${BLUE}====================================================================${NC}" +echo -e "${GREEN}🎉 НАСТРОЙКА ЗАЩИТЫ УСПЕШНО ЗАВЕРШЕНА!${NC}" +echo -e "${BLUE}====================================================================${NC}" +echo -e "Активные правила iptables на порту 5432:" +iptables -L INPUT -v -n --line-numbers | grep -E "5432|dpt" || true + +echo -e "\n${YELLOW}Полезные команды для мониторинга сервера:${NC}" +echo -e "1. Просмотр заблокированных ботов в Fail2ban: ${GREEN}fail2ban-client status postgresql-has${NC}" +echo -e "2. Просмотр счетчика отброшенных пакетов: ${GREEN}iptables -L INPUT -v -n | grep 5432${NC}" +echo -e "3. Просмотр активных соединений с базой: ${GREEN}ss -nt state established '( sport = :5432 )'${NC}" +echo -e "4. Разблокировать ошибочно забаненный IP: ${GREEN}fail2ban-client set postgresql-has unbanip ${NC}" +echo -e "${BLUE}====================================================================${NC}" diff --git a/scripts/setup-android-env.sh b/scripts/setup-android-env.sh new file mode 100755 index 0000000..65f7f7f --- /dev/null +++ b/scripts/setup-android-env.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -e + +ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +if [ -z "$NDK_HOME" ]; then + if [ -d "$ANDROID_HOME/ndk/27.2.12479018" ]; then + NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" + elif [ -d "$ANDROID_HOME/ndk" ]; then + NDK_HOME="$(ls -d "$ANDROID_HOME/ndk/"* 2>/dev/null | tail -n 1)" + fi +fi + +if [ -n "$NDK_HOME" ] && [ -d "$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin" ]; then + TOOLCHAIN_BIN="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin" + echo "Setting up GNU binutils symlinks in $TOOLCHAIN_BIN..." + for prefix in aarch64-linux-android arm-linux-androideabi armv7a-linux-androideabi x86_64-linux-android i686-linux-android; do + [ -f "$TOOLCHAIN_BIN/llvm-ar" ] && ln -sf "$TOOLCHAIN_BIN/llvm-ar" "$TOOLCHAIN_BIN/${prefix}-ar" + [ -f "$TOOLCHAIN_BIN/llvm-ranlib" ] && ln -sf "$TOOLCHAIN_BIN/llvm-ranlib" "$TOOLCHAIN_BIN/${prefix}-ranlib" + [ -f "$TOOLCHAIN_BIN/llvm-nm" ] && ln -sf "$TOOLCHAIN_BIN/llvm-nm" "$TOOLCHAIN_BIN/${prefix}-nm" + [ -f "$TOOLCHAIN_BIN/llvm-as" ] && ln -sf "$TOOLCHAIN_BIN/llvm-as" "$TOOLCHAIN_BIN/${prefix}-as" + [ -f "$TOOLCHAIN_BIN/llvm-strip" ] && ln -sf "$TOOLCHAIN_BIN/llvm-strip" "$TOOLCHAIN_BIN/${prefix}-strip" + done + echo "Symlinks created successfully." +else + echo "NDK toolchain not found at $NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin" +fi diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml new file mode 100644 index 0000000..e590be4 --- /dev/null +++ b/src-tauri/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.aarch64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.armv7-linux-androideabi] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.x86_64-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] + +[target.i686-linux-android] +rustflags = ["-C", "link-arg=-Wl,-z,max-page-size=16384"] diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..b21bd68 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,7 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock new file mode 100644 index 0000000..15e405c --- /dev/null +++ b/src-tauri/Cargo.lock @@ -0,0 +1,5838 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bcrypt" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.3.4", + "subtle", + "zeroize", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "has" +version = "0.0.1" +dependencies = [ + "base64 0.22.1", + "bcrypt", + "chrono", + "dotenvy", + "hex", + "native-tls", + "openssl", + "postgres-native-tls", + "reqwest 0.12.28", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-updater", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand", + "sha2 0.11.1", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c404ecf956ff241bf6b39dd688614724e1bf1f63255abb4fc46d1c40371672ea" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni 0.21.1", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.5", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02f468f9a9bcd404f57e5c191d91024e3de953d4b610f78c2907e7110615cb1c" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "131bffec939b20642380317f8403a33c8888489925e14bb965ceaff0d949b1cb" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85dead674ee41937d042ee855d785f07ee67206f19402200c12b9255964d5ac8" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-process" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038" +dependencies = [ + "tauri", + "tauri-plugin", +] + +[[package]] +name = "tauri-plugin-updater" +version = "2.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "libc", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.5", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.21", + "time", + "tokio", + "url", + "windows-sys 0.61.2", + "zip", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni 0.21.1", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" +dependencies = [ + "gtk", + "http", + "jni 0.21.1", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..bb7af93 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "has" +version = "0.0.1" +description = "has" +authors = ["autli"] +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "has_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } +native-tls = { version = "0.2", features = ["vendored"] } +openssl = { version = "0.10", features = ["vendored"] } +tokio-native-tls = "0.3" +postgres-native-tls = "0.5" +dotenvy = "0.15" +bcrypt = "0.17" +sha2 = "0.10" +hex = "0.4" +base64 = "0.22" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } + + + +# Read the optimization guideline for more details: https://tauri.app/concept/size/#cargo-configuration +[profile.release] +codegen-units = 1 +lto = true +opt-level = 3 +panic = "abort" +strip = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..6745c6f --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "updater:default", + "process:default" + ] +} diff --git a/src-tauri/gen/android/.editorconfig b/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..1c636c3 --- /dev/null +++ b/src-tauri/gen/android/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..6c4d56b --- /dev/null +++ b/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..748ffb4 --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,84 @@ +import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 37 + namespace = "com.has.tauri_app" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.has.tauri_app" + minSdk = 24 + targetSdk = 37 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { + jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + signingConfig = signingConfigs.getByName("debug") + optimization { + enable = true + } + proguardFiles( + *fileTree(".") { + include("**/*.pro") + exclude("build/**") + }.files.toTypedArray() + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + buildFeatures { + buildConfig = true + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = file("tauri.build.gradle.kts")) diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e9a9dd3 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/java/com/has/tauri_app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/has/tauri_app/MainActivity.kt new file mode 100644 index 0000000..d5c8778 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/has/tauri_app/MainActivity.kt @@ -0,0 +1,11 @@ +package com.has.tauri_app + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..85d0c88 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..28f1aa1 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..13dd214 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..73e48db Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a888b33 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1d98044 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2a838e Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..0818324 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3f8a57f Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18bceb Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..4fc8c0e --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..031957c --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + "Home Autli Server" + "Home Autli Server" + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4fc8c0e --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..e91063f --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:9.4.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.10") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..4c1797e --- /dev/null +++ b/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:9.4.1") +} + diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/has/tauri_app/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/has/tauri_app/kotlin/BuildTask.kt new file mode 100644 index 0000000..7b84b8d --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/has/tauri_app/kotlin/BuildTask.kt @@ -0,0 +1,99 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject +import org.gradle.process.ExecOperations + +abstract class BuildTask : DefaultTask() { + @get:Inject + abstract val execOperations: ExecOperations + + @Input + var rootDirRel: String? = null + @Input + var projectDir: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val candidates = listOf( + "/usr/local/bin/npm", + "/opt/homebrew/bin/npm", + System.getProperty("user.home") + "/.nvm/versions/node/$(node -v)/bin/npm", + "npm" + ) + val executable = candidates.firstOrNull { File(it).exists() } ?: "npm" + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("run", "--", "tauri", "android", "android-studio-script"); + + val userHome = System.getProperty("user.home") + val androidSdk = File(userHome, "Library/Android/sdk") + val ndkDir = File(androidSdk, "ndk/27.2.12479018") + val toolchainBin = File(ndkDir, "toolchains/llvm/prebuilt/darwin-x86_64/bin") + + execOperations.exec { + workingDir(File(projectDir, rootDirRel)) + executable(executable) + args(args) + if (androidSdk.exists()) { + environment("ANDROID_HOME", androidSdk.absolutePath) + } + if (ndkDir.exists()) { + environment("NDK_HOME", ndkDir.absolutePath) + } + val existingPath = System.getenv("PATH") ?: "/usr/bin:/bin:/usr/sbin:/sbin" + val fullPath = "${toolchainBin.absolutePath}:/usr/local/bin:/opt/homebrew/bin:$existingPath" + environment("PATH", fullPath) + environment("OPENSSL_STATIC", "1") + environment("AR_aarch64_linux_android", "llvm-ar") + environment("RANLIB_aarch64_linux_android", "llvm-ranlib") + environment("RUSTFLAGS", "-C link-arg=-Wl,-z,max-page-size=16384") + if (logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/has/tauri_app/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/has/tauri_app/kotlin/RustPlugin.kt new file mode 100644 index 0000000..3309f1c --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/has/tauri_app/kotlin/RustPlugin.kt @@ -0,0 +1,86 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.register( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ) { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + projectDir = project.projectDir.path + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..9e6e7b9 --- /dev/null +++ b/src-tauri/gen/android/gradle.properties @@ -0,0 +1,26 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Our old plugin template had a bug that caused missing `consumer-rules.pro` files +# so we have to disable it for now. +# See https://github.com/tauri-apps/tauri/pull/15828#issuecomment-5199937796 +android.proguard.failOnMissingFiles=false + +# Keeping Gradle 9 compatible with current plugins +# TODO: Remove this in v3 +android.builtInKotlin=false +android.newDsl=false diff --git a/src-tauri/gen/android/gradle/gradle-daemon-jvm.properties b/src-tauri/gen/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..fa4ed51 --- /dev/null +++ b/src-tauri/gen/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b2ee479 --- /dev/null +++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/src-tauri/gen/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/src-tauri/has-update.key.pub b/src-tauri/has-update.key.pub new file mode 100644 index 0000000..4edb373 --- /dev/null +++ b/src-tauri/has-update.key.pub @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNEQUVFQTk4QkE5REVFNjMKUldSajdwMjZtT3F1UGFzWUFIcjNNcjRUdnlGKzBzUGxTZ3BOTGFWcGRwN2pCd1JJTnRXbTE2d3EK \ No newline at end of file diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..9247786 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..25abe7a Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..eaf4b99 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..9606568 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000..f282e6c Binary files /dev/null and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000..436fdd0 Binary files /dev/null and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000..1825ac4 Binary files /dev/null and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000..c3245f9 Binary files /dev/null and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000..76fe48b Binary files /dev/null and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000..28ab808 Binary files /dev/null and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000..768471b Binary files /dev/null and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000..78ac810 Binary files /dev/null and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000..6d2b999 Binary files /dev/null and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000..ecabe39 Binary files /dev/null and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..5f2b5df Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fc32bd0 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..4ae2647 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..978e74d Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3bcaf8c Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..bcb4c3d Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f973e1c Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d23605e Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d251d2c Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b0f0155 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8f154bf Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..34b7e90 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5e7afac Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..33c3200 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..869251f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..16cdd5a --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..07db60e Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..2073db7 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..81290c0 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..d9edf76 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..ff10553 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..ff10553 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..062e03b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..aad3a2c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..b0bbf0f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..b0bbf0f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..c4d7573 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..ff10553 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..8016c6a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..8016c6a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..9d8190a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..6dae452 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..9d8190a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..5f33175 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..1052453 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..b8467f8 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..cd8b0a4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/admin.rs b/src-tauri/src/admin.rs new file mode 100644 index 0000000..0f6a867 --- /dev/null +++ b/src-tauri/src/admin.rs @@ -0,0 +1,312 @@ +use serde::{Deserialize, Serialize}; +use crate::auth::{connect_to_db, get_db_config}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DeviceSecurityInfo { + pub current_device_name: String, + pub current_ip: String, + pub is_admin: bool, + pub device_binding_enabled: bool, + pub trusted_device_name: Option, + pub ip_binding_enabled: bool, + pub trusted_ip: Option, + pub is_device_allowed: bool, + pub is_ip_allowed: bool, + pub can_admin_manage: bool, + pub pin_protection_enabled: bool, +} + +pub fn get_admin_master_pin() -> String { + crate::auth::extract_clean_env("ADMIN_MASTER_PIN", "") +} + +pub fn verify_admin_pin(input_pin: Option<&str>) -> Result<(), String> { + let expected = get_admin_master_pin(); + let expected_clean = expected.trim(); + if expected_clean.is_empty() { + return Ok(()); + } + let input = input_pin.unwrap_or("").trim(); + if input.is_empty() { + return Err("Введите 8-значный мастер-PIN администратора для подтверждения".to_string()); + } + if input != expected_clean { + return Err("Неверный мастер-PIN администратора! Проверьте значение в файле .env (ADMIN_MASTER_PIN)".to_string()); + } + Ok(()) +} + +pub fn get_current_device_name() -> String { + if let Ok(name) = std::env::var("COMPUTERNAME") { + if !name.trim().is_empty() { + return name.trim().to_string(); + } + } + if let Ok(name) = std::env::var("HOSTNAME") { + if !name.trim().is_empty() { + return name.trim().to_string(); + } + } + if let Ok(output) = std::process::Command::new("hostname").output() { + if output.status.success() { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !s.is_empty() { + return s; + } + } + } + if let Ok(user) = std::env::var("USER") { + return format!("{}-workstation", user.trim()); + } + "main-workstation".to_string() +} + +pub fn get_current_local_ip() -> String { + if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0") { + if socket.connect("8.8.8.8:80").is_ok() { + if let Ok(addr) = socket.local_addr() { + return addr.ip().to_string(); + } + } + } + "127.0.0.1".to_string() +} + +pub async fn init_admin_security_table(client: &tokio_postgres::Client) -> Result<(), String> { + let sql = " + CREATE TABLE IF NOT EXISTS admin_security_config ( + id SERIAL PRIMARY KEY, + device_binding_enabled BOOLEAN DEFAULT FALSE, + trusted_device_name VARCHAR(255), + ip_binding_enabled BOOLEAN DEFAULT FALSE, + trusted_ip VARCHAR(100) + ); + INSERT INTO admin_security_config (id, device_binding_enabled, ip_binding_enabled) + VALUES (1, false, false) + ON CONFLICT (id) DO NOTHING; + "; + client + .batch_execute(sql) + .await + .map_err(|e| format!("Ошибка создания таблицы безопасности: {}", e)) +} + +pub async fn get_admin_security_info(admin_email: &str) -> Result { + let config = get_db_config(); + let client = connect_to_db(&config).await?; + init_admin_security_table(&client).await?; + + let clean_email = admin_email.trim().to_lowercase(); + let user_row = client + .query_opt( + &format!( + "SELECT role, id FROM {} WHERE LOWER({}) = LOWER($1)", + config.users_table, config.email_column + ), + &[&clean_email], + ) + .await + .map_err(|e| format!("Ошибка проверки роли: {}", e))?; + + let (is_admin, _user_id) = match user_row { + Some(row) => { + let role: Option = row.try_get(0).ok().flatten(); + let id: i32 = row.get(1); + let is_adm = id == 1 || matches!(role.as_deref(), Some("admin") | Some("Администратор")); + (is_adm, id) + } + None => (false, -1), + }; + + let cfg_row = client + .query_one( + "SELECT device_binding_enabled, trusted_device_name, ip_binding_enabled, trusted_ip FROM admin_security_config WHERE id = 1", + &[], + ) + .await + .map_err(|e| format!("Ошибка чтения параметров безопасности: {}", e))?; + + let device_binding_enabled: bool = cfg_row.get(0); + let trusted_device_name: Option = cfg_row.get(1); + let ip_binding_enabled: bool = cfg_row.get(2); + let trusted_ip: Option = cfg_row.get(3); + + let current_device = get_current_device_name(); + let current_ip = get_current_local_ip(); + + let is_device_allowed = if !device_binding_enabled { + true + } else { + match &trusted_device_name { + Some(t) => t.trim().eq_ignore_ascii_case(¤t_device.trim()), + None => true, + } + }; + + let is_ip_allowed = if !ip_binding_enabled { + true + } else { + match &trusted_ip { + Some(t) => t.trim().eq_ignore_ascii_case(¤t_ip.trim()), + None => true, + } + }; + + let can_admin_manage = is_admin; + + Ok(DeviceSecurityInfo { + current_device_name: current_device, + current_ip, + is_admin, + device_binding_enabled, + trusted_device_name, + ip_binding_enabled, + trusted_ip, + is_device_allowed, + is_ip_allowed, + can_admin_manage, + pin_protection_enabled: !get_admin_master_pin().is_empty(), + }) +} + +pub async fn update_admin_security_policy( + admin_email: &str, + device_binding: bool, + trusted_device: Option, + ip_binding: bool, + trusted_ip: Option, +) -> Result { + let sec_info = get_admin_security_info(admin_email).await?; + if !sec_info.is_admin { + return Err("Доступ запрещен: действие доступно только администратору".to_string()); + } + + let config = get_db_config(); + let client = connect_to_db(&config).await?; + init_admin_security_table(&client).await?; + + let trusted_dev_clean = trusted_device.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let trusted_ip_clean = trusted_ip.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + + client + .execute( + "UPDATE admin_security_config + SET device_binding_enabled = $1, trusted_device_name = $2, ip_binding_enabled = $3, trusted_ip = $4 + WHERE id = 1", + &[&device_binding, &trusted_dev_clean, &ip_binding, &trusted_ip_clean], + ) + .await + .map_err(|e| format!("Ошибка сохранения политики безопасности: {}", e))?; + + get_admin_security_info(admin_email).await +} + +pub async fn admin_update_user( + admin_email: &str, + target_id: i32, + new_username: Option, + new_email: Option, + new_role: Option, + new_password: Option, + admin_pin: Option, +) -> Result { + let sec_info = get_admin_security_info(admin_email).await?; + if !sec_info.is_admin { + return Err("Отказано в доступе: требуются права администратора".to_string()); + } + verify_admin_pin(admin_pin.as_deref())?; + + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let clean_username = new_username.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let clean_role = new_role.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "user".to_string()); + let clean_email = new_email.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + + if let Some(pwd) = new_password.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) { + let hashed = bcrypt::hash(&pwd, 10).map_err(|e| format!("Ошибка хеширования: {}", e))?; + if let Some(em) = clean_email { + let update_sql = format!( + "UPDATE {} SET username = $1, {} = $2, role = $3, {} = $4 WHERE id = $5", + config.users_table, config.email_column, config.password_column + ); + client + .execute(&update_sql, &[&clean_username, &em, &clean_role, &hashed, &target_id]) + .await + .map_err(|e| format!("Ошибка обновления пользователя: {}", e))?; + } else { + let update_sql = format!( + "UPDATE {} SET username = $1, role = $2, {} = $3 WHERE id = $4", + config.users_table, config.password_column + ); + client + .execute(&update_sql, &[&clean_username, &clean_role, &hashed, &target_id]) + .await + .map_err(|e| format!("Ошибка обновления пользователя: {}", e))?; + } + } else { + if let Some(em) = clean_email { + let update_sql = format!( + "UPDATE {} SET username = $1, {} = $2, role = $3 WHERE id = $4", + config.users_table, config.email_column + ); + client + .execute(&update_sql, &[&clean_username, &em, &clean_role, &target_id]) + .await + .map_err(|e| format!("Ошибка обновления пользователя: {}", e))?; + } else { + let update_sql = format!( + "UPDATE {} SET username = $1, role = $2 WHERE id = $3", + config.users_table + ); + client + .execute(&update_sql, &[&clean_username, &clean_role, &target_id]) + .await + .map_err(|e| format!("Ошибка обновления пользователя: {}", e))?; + } + } + + Ok("Данные пользователя успешно обновлены".to_string()) +} + +pub async fn admin_delete_user( + admin_email: &str, + target_id: i32, + admin_pin: Option, +) -> Result { + let sec_info = get_admin_security_info(admin_email).await?; + if !sec_info.is_admin { + return Err("Отказано в доступе: требуются права администратора".to_string()); + } + verify_admin_pin(admin_pin.as_deref())?; + + if target_id == 1 { + return Err("Нельзя удалить основного системного администратора (ID: 1)".to_string()); + } + + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let self_row = client + .query_opt( + &format!("SELECT id FROM {} WHERE LOWER({}) = LOWER($1)", config.users_table, config.email_column), + &[&admin_email.trim().to_lowercase()], + ) + .await + .map_err(|e| format!("Ошибка проверки: {}", e))?; + + if let Some(r) = self_row { + let self_id: i32 = r.get(0); + if self_id == target_id { + return Err("Нельзя удалить собственную учетную запись администратора".to_string()); + } + } + + let delete_sql = format!("DELETE FROM {} WHERE id = $1", config.users_table); + client + .execute(&delete_sql, &[&target_id]) + .await + .map_err(|e| format!("Ошибка удаления пользователя: {}", e))?; + + Ok("Пользователь успешно удален".to_string()) +} diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs new file mode 100644 index 0000000..44edd61 --- /dev/null +++ b/src-tauri/src/auth.rs @@ -0,0 +1,972 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; +use sha2::{Digest, Sha256}; + +// Anti-DDoS: Connection concurrency semaphore to prevent opening too many simultaneous sockets from this client +static DB_CONCURRENCY_SEMAPHORE: LazyLock = + LazyLock::new(|| tokio::sync::Semaphore::new(6)); + +// Anti-DDoS: Connection rate history to prevent connection flooding / burst spikes +static DB_CONN_HISTORY: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +// Anti-Brute-Force & Credential Stuffing tracker +struct LoginRateLimiter { + attempts: HashMap, + lockouts: HashMap, +} + +static LOGIN_LIMITER: LazyLock> = LazyLock::new(|| { + Mutex::new(LoginRateLimiter { + attempts: HashMap::new(), + lockouts: HashMap::new(), + }) +}); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DbConfig { + pub host: String, + pub port: u16, + pub user: String, + pub password: Option, + pub dbname: String, + pub ssl_mode: String, + pub users_table: String, + pub email_column: String, + pub password_column: String, + pub database_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DbStatus { + pub connected: bool, + pub host: String, + pub dbname: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserDto { + pub email: String, + pub username: Option, + pub role: String, + pub avatar: Option, + pub bio: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthResponse { + pub success: bool, + pub message: String, + pub user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResetRequestResponse { + pub success: bool, + pub message: String, + pub dev_code: Option, +} + +#[derive(Debug, Clone)] +struct ResetEntry { + code: String, + expires_at: std::time::Instant, + verified: bool, +} + +pub fn load_env() { + // 1. Current directory .env + let _ = dotenvy::dotenv(); + + // 2. Parent directory .env (useful in tauri dev when cwd might be src-tauri) + let _ = dotenvy::from_filename("../.env"); + + // 3. Executable directory .env (useful in production bundle) + if let Ok(exe_path) = env::current_exe() { + if let Some(exe_dir) = exe_path.parent() { + let _ = dotenvy::from_path(exe_dir.join(".env")); + if let Some(parent) = exe_dir.parent() { + let _ = dotenvy::from_path(parent.join(".env")); + if let Some(grandparent) = parent.parent() { + let _ = dotenvy::from_path(grandparent.join(".env")); + } + } + } + } +} + +pub fn extract_clean_env(key: &str, default: &str) -> String { + env::var(key) + .map(|v| { + let trimmed = v.trim(); + let clean = if let Some((c, _)) = trimmed.split_once('#') { + c.trim() + } else { + trimmed + }; + if clean.is_empty() { + default.to_string() + } else { + clean.to_string() + } + }) + .unwrap_or_else(|_| default.to_string()) +} + +pub fn get_db_config() -> DbConfig { + load_env(); + + let database_url = env::var("DATABASE_URL").ok().filter(|s| !s.trim().is_empty()); + let host = extract_clean_env("DB_HOST", "127.0.0.1"); + let port = extract_clean_env("DB_PORT", "5432") + .parse::() + .unwrap_or(5432); + let user = extract_clean_env("DB_USER", "postgres"); + let pass_str = extract_clean_env("DB_PASSWORD", ""); + let password = if !pass_str.is_empty() { + Some(pass_str) + } else { + None + }; + let dbname = extract_clean_env("DB_NAME", "postgres"); + let ssl_mode = extract_clean_env("DB_SSL_MODE", "prefer").to_lowercase(); + + let users_table = extract_clean_env("DB_USERS_TABLE", "users"); + let email_column = extract_clean_env("DB_EMAIL_COLUMN", "email"); + let password_column = extract_clean_env("DB_PASSWORD_COLUMN", "password"); + + DbConfig { + host, + port, + user, + password, + dbname, + ssl_mode, + users_table, + email_column, + password_column, + database_url, + } +} + +fn build_conn_string(config: &DbConfig) -> String { + if let Some(url) = &config.database_url { + if !url.trim().is_empty() { + return url.clone(); + } + } + + let mut parts = vec![ + format!("host={}", config.host), + format!("port={}", config.port), + format!("user={}", config.user), + format!("dbname={}", config.dbname), + ]; + + if let Some(ref pwd) = config.password { + if !pwd.is_empty() { + parts.push(format!("password={}", pwd)); + } + } + + parts.join(" ") +} + +pub async fn connect_to_db(config: &DbConfig) -> Result { + // 1. Anti-Flood Rate Limiting: prevent rapid connection bursts + let now = Instant::now(); + let should_throttle = { + let mut history = DB_CONN_HISTORY.lock().unwrap(); + history.retain(|&t| now.duration_since(t) < Duration::from_secs(4)); + if history.len() >= 20 { + true + } else { + history.push(now); + false + } + }; + + if should_throttle { + tokio::time::sleep(Duration::from_millis(500)).await; + let mut history = DB_CONN_HISTORY.lock().unwrap(); + if history.len() >= 25 { + return Err("Слишком много запросов к базе данных. Защита от перегрузки активна, подождите пару секунд.".to_string()); + } + history.push(Instant::now()); + } + + // 2. Concurrency Semaphore: max 6 simultaneous connection handshakes per client + let _permit = DB_CONCURRENCY_SEMAPHORE + .acquire() + .await + .map_err(|e| format!("Семафор БД: {}", e))?; + + let conn_str = build_conn_string(config); + let connect_timeout = std::time::Duration::from_secs(4); + + // Try SSL if mode is 'require' or 'prefer' + if config.ssl_mode == "require" || config.ssl_mode == "prefer" { + let mut builder = native_tls::TlsConnector::builder(); + builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS + + if let Ok(tls_connector) = builder.build() { + let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector); + let conn_future = tokio_postgres::connect(&conn_str, tls); + match tokio::time::timeout(connect_timeout, conn_future).await { + Ok(Ok((client, connection))) => { + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("PostgreSQL connection error: {}", e); + } + }); + return Ok(client); + } + Ok(Err(e)) => { + if config.ssl_mode == "require" { + return Err(format!("TLS connection failed: {}", e)); + } + // If prefer, fallback to NoTls below + } + Err(_) => { + if config.ssl_mode == "require" { + return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string()); + } + // If prefer, fallback to NoTls below + } + } + } + } + + // Connect with NoTls + let conn_future = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls); + match tokio::time::timeout(connect_timeout, conn_future).await { + Ok(Ok((client, connection))) => { + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("PostgreSQL connection error: {}", e); + } + }); + Ok(client) + } + Ok(Err(e)) => { + eprintln!("PostgreSQL connection failed: {e:#?}"); + eprintln!("PostgreSQL error details: {e}"); + Err("Не удалось подключиться к серверу базы данных. Попробуйте позже.".to_string()) + } + Err(_) => { + Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 4 сек.".to_string()) + } + } +} + +pub fn verify_password(provided: &str, stored: &str) -> bool { + let stored = stored.trim(); + + // 1. Bcrypt hash check ($2a$, $2b$, $2y$) + if stored.starts_with("$2a$") || stored.starts_with("$2b$") || stored.starts_with("$2y$") { + if let Ok(valid) = bcrypt::verify(provided, stored) { + return valid; + } + } + + // 2. SHA-256 hash check (64 hex characters) + if stored.len() == 64 && stored.chars().all(|c| c.is_ascii_hexdigit()) { + let mut hasher = Sha256::new(); + hasher.update(provided.as_bytes()); + let hash = hex::encode(hasher.finalize()); + if hash.eq_ignore_ascii_case(stored) { + return true; + } + } + + // 3. Direct plaintext match + provided == stored +} + +pub fn get_error_message(e: &tokio_postgres::Error) -> String { + if let Some(dbe) = e.as_db_error() { + dbe.message().to_string() + } else { + e.to_string() + } +} + +pub async fn check_db_health() -> DbStatus { + let config = get_db_config(); + match connect_to_db(&config).await { + Ok(client) => { + match client.simple_query("SELECT 1;").await { + Ok(_) => DbStatus { + connected: true, + host: "Protected".to_string(), + dbname: "Protected".to_string(), + message: "Соединение с сервером установлено (защищенный канал)".to_string(), + }, + Err(_e) => DbStatus { + connected: false, + host: "Protected".to_string(), + dbname: "Protected".to_string(), + message: "Ошибка выполнения запроса к серверу".to_string(), + }, + } + } + Err(e) => DbStatus { + connected: false, + host: "Protected".to_string(), + dbname: "Protected".to_string(), + message: e, + }, + } +} + +pub async fn authenticate_user(email: &str, password: &str) -> AuthResponse { + let clean_email = email.trim(); + if clean_email.is_empty() || password.is_empty() { + return AuthResponse { + success: false, + message: "Email и пароль обязательны для заполнения".to_string(), + user: None, + }; + } + + // Anti-Brute-Force / Credential Stuffing check + let now = Instant::now(); + { + let mut limiter = LOGIN_LIMITER.lock().unwrap(); + if let Some(&lockout_until) = limiter.lockouts.get(clean_email) { + if now < lockout_until { + let remaining_secs = lockout_until.duration_since(now).as_secs() + 1; + return AuthResponse { + success: false, + message: format!( + "Слишком много неудачных попыток входа. В целях безопасности доступ заблокирован на {} сек.", + remaining_secs + ), + user: None, + }; + } else { + limiter.lockouts.remove(clean_email); + limiter.attempts.remove(clean_email); + } + } + } + + let config = get_db_config(); + let client = match connect_to_db(&config).await { + Ok(c) => c, + Err(_) => { + return AuthResponse { + success: false, + message: "Ошибка подключения к серверу авторизации. Попробуйте позже.".to_string(), + user: None, + }; + } + }; + + // Sanitize identifier names (allow only alphanumeric + underscore to avoid injection in table/col names) + let is_safe_ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_'); + if !is_safe_ident(&config.users_table) || !is_safe_ident(&config.email_column) || !is_safe_ident(&config.password_column) { + return AuthResponse { + success: false, + message: "Некорректные имена таблиц или колонок в конфигурации .env".to_string(), + user: None, + }; + } + + // Auto-create table if standard 'users' table + if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" { + let create_table_sql = " + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password TEXT NOT NULL, + username VARCHAR(255), + role VARCHAR(50) DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255); + ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(50) DEFAULT 'user'; + ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT; + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + UPDATE users SET role = 'admin' WHERE id = 1 AND (role IS NULL OR role = ''); + "; + let _ = client.batch_execute(create_table_sql).await; + } + + let query_with_bio = format!( + "SELECT {}, username, role, avatar, bio FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1", + config.password_column, config.users_table, config.email_column + ); + + let (query_result, has_avatar, has_bio) = match client.query_opt(&query_with_bio, &[&clean_email]).await { + Ok(row) => (Ok(row), true, true), + Err(_) => { + let query_with_avatar = format!( + "SELECT {}, username, role, avatar FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1", + config.password_column, config.users_table, config.email_column + ); + match client.query_opt(&query_with_avatar, &[&clean_email]).await { + Ok(row) => (Ok(row), true, false), + Err(_) => { + let query_fallback = format!( + "SELECT {} FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1", + config.password_column, config.users_table, config.email_column + ); + (client.query_opt(&query_fallback, &[&clean_email]).await, false, false) + } + } + } + }; + + match query_result { + Ok(Some(row)) => { + let stored_pwd: Result = row.try_get(0); + let username: Option = if has_avatar { row.try_get(1).ok() } else { None }; + let role_opt: Option = if has_avatar { row.try_get(2).ok() } else { None }; + let avatar: Option = if has_avatar { row.try_get(3).ok() } else { None }; + let bio: Option = if has_bio { row.try_get(4).ok() } else { None }; + let role = role_opt.filter(|r| !r.trim().is_empty()).unwrap_or_else(|| "user".to_string()); + match stored_pwd { + Ok(stored_password) => { + if verify_password(password, &stored_password) { + // Reset failed attempts on success + { + let mut limiter = LOGIN_LIMITER.lock().unwrap(); + limiter.attempts.remove(clean_email); + limiter.lockouts.remove(clean_email); + } + + // Update heartbeat/last_seen_at immediately on login + let _ = client.execute( + &format!("UPDATE {} SET last_seen_at = CURRENT_TIMESTAMP WHERE LOWER({}) = LOWER($1);", config.users_table, config.email_column), + &[&clean_email] + ).await; + + AuthResponse { + success: true, + message: "Успешная авторизация".to_string(), + user: Some(UserDto { + email: clean_email.to_string(), + username, + role, + avatar, + bio, + }), + } + } else { + // Track failed attempt + let lockout_applied = { + let mut limiter = LOGIN_LIMITER.lock().unwrap(); + let entry = limiter.attempts.entry(clean_email.to_string()).or_insert((0, now)); + if now.duration_since(entry.1) > Duration::from_secs(60) { + *entry = (1, now); + } else { + entry.0 += 1; + } + if entry.0 >= 5 { + limiter.lockouts.insert(clean_email.to_string(), now + Duration::from_secs(30)); + true + } else { + false + } + }; + + AuthResponse { + success: false, + message: if lockout_applied { + "Слишком много неверных попыток. В целях безопасности доступ временно заблокирован на 30 секунд.".to_string() + } else { + "Неверный пароль".to_string() + }, + user: None, + } + } + } + Err(e) => AuthResponse { + success: false, + message: format!("Ошибка чтения пароля из базы: {}", e), + user: None, + }, + } + } + Ok(None) => AuthResponse { + success: false, + message: "Пользователь с таким email не найден".to_string(), + user: None, + }, + Err(e) => { + let msg = get_error_message(&e); + if msg.contains("does not exist") || msg.contains("не существует") { + AuthResponse { + success: false, + message: "Ошибка авторизации: аккаунт не найден.".to_string(), + user: None, + } + } else if msg.contains("permission denied") || msg.contains("отказано в доступе") { + AuthResponse { + success: false, + message: "Ошибка доступа к серверу базы данных.".to_string(), + user: None, + } + } else { + AuthResponse { + success: false, + message: "Ошибка обработки запроса авторизации.".to_string(), + user: None, + } + } + } + } +} + +pub async fn register_user(email: &str, password: &str, username: Option<&str>) -> AuthResponse { + let clean_email = email.trim(); + let clean_username = username.map(|u| u.trim()).filter(|u| !u.is_empty()); + if clean_email.is_empty() || password.is_empty() { + return AuthResponse { + success: false, + message: "Email и пароль обязательны".to_string(), + user: None, + }; + } + + let config = get_db_config(); + let client = match connect_to_db(&config).await { + Ok(c) => c, + Err(_) => { + return AuthResponse { + success: false, + message: "Ошибка подключения к серверу регистрации. Попробуйте позже.".to_string(), + user: None, + }; + } + }; + + // Auto-create table if standard 'users' table + if config.users_table == "users" && config.email_column == "email" && config.password_column == "password" { + let create_table_sql = " + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password TEXT NOT NULL, + username VARCHAR(255), + role VARCHAR(50) DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + ALTER TABLE users ADD COLUMN IF NOT EXISTS username VARCHAR(255); + ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(50) DEFAULT 'user'; + ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + UPDATE users SET role = 'admin' WHERE id = 1 AND (role IS NULL OR role = ''); + "; + let _ = client.batch_execute(create_table_sql).await; + } + + // Determine initial role: if users table has 0 users, this first user is admin + let user_count: i64 = client + .query_one(&format!("SELECT COUNT(*) FROM {}", config.users_table), &[]) + .await + .map(|r| r.get(0)) + .unwrap_or(1); + let assigned_role = if user_count == 0 { "admin" } else { "user" }; + + // Hash password with bcrypt + let hashed_password = match bcrypt::hash(password, 10) { + Ok(h) => h, + Err(e) => return AuthResponse { + success: false, + message: format!("Ошибка хеширования пароля: {}", e), + user: None, + }, + }; + + let insert_query = format!( + "INSERT INTO {} ({}, {}, username, role) VALUES ($1, $2, $3, $4)", + config.users_table, config.email_column, config.password_column + ); + + let insert_res = match client.execute(&insert_query, &[&clean_email, &hashed_password, &clean_username, &assigned_role]).await { + Ok(n) => Ok(n), + Err(_) => { + let fallback_insert = format!( + "INSERT INTO {} ({}, {}) VALUES ($1, $2)", + config.users_table, config.email_column, config.password_column + ); + client.execute(&fallback_insert, &[&clean_email, &hashed_password]).await + } + }; + + match insert_res { + Ok(_) => AuthResponse { + success: true, + message: "Аккаунт успешно создан".to_string(), + user: Some(UserDto { + email: clean_email.to_string(), + username: clean_username.map(String::from), + role: assigned_role.to_string(), + avatar: None, + bio: None, + }), + }, + Err(e) => { + let err_str = get_error_message(&e); + if err_str.contains("unique") || err_str.contains("duplicate") || err_str.contains("уже существует") { + AuthResponse { + success: false, + message: "Пользователь с таким email уже существует".to_string(), + user: None, + } + } else { + AuthResponse { + success: false, + message: format!("Ошибка сохранения: {}", err_str), + user: None, + } + } + } + } +} + +static RESET_CODES: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +fn get_reset_codes() -> &'static std::sync::Mutex> { + RESET_CODES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn generate_reset_code() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + let num = (nanos % 900_000) + 100_000; + format!("{:06}", num) +} + +pub async fn send_smtp_email( + host: &str, + port: u16, + user: &str, + pass: &str, + from: &str, + to: &str, + subject: &str, + body: &str, +) -> Result<(), String> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use base64::Engine; + + let host = host.trim(); + let user = user.trim(); + let pass = pass.trim(); + let sender = if !from.trim().is_empty() { from.trim() } else { user }; + + let tcp = tokio::net::TcpStream::connect((host, port)) + .await + .map_err(|e| format!("Не удалось подключиться к SMTP {}:{}: {}", host, port, e))?; + + let mut builder = native_tls::TlsConnector::builder(); + builder.danger_accept_invalid_certs(true); + let tls_connector = builder.build().map_err(|e| e.to_string())?; + let tokio_tls = tokio_native_tls::TlsConnector::from(tls_connector); + + let tls_stream = tokio_tls + .connect(host, tcp) + .await + .map_err(|e| format!("TLS ошибка SMTP {}:{}: {}", host, port, e))?; + + let (reader, mut writer) = tokio::io::split(tls_stream); + let mut reader = BufReader::new(reader); + + async fn read_reply(reader: &mut R, expected: &str) -> Result { + let mut last_line; + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).await.map_err(|e| format!("Ошибка чтения ответа SMTP: {}", e))?; + if n == 0 { + return Err("SMTP соединение неожиданно закрыто сервером".to_string()); + } + last_line = line.clone(); + // Multi-line SMTP: lines start with "XYZ-", the last line is "XYZ " + if line.len() >= 4 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.as_bytes()[3] == b' ' { + break; + } + if line.len() >= 3 && line[..3].chars().all(|c| c.is_ascii_digit()) && line.len() < 4 { + break; + } + } + if !last_line.starts_with(expected) { + return Err(format!("SMTP неожиданный ответ: {}", last_line.trim())); + } + Ok(last_line) + } + + async fn send_cmd( + writer: &mut W, + reader: &mut R, + cmd: &str, + expected: &str, + ) -> Result { + writer.write_all(cmd.as_bytes()).await.map_err(|e| format!("Ошибка отправки команды SMTP: {}", e))?; + writer.flush().await.map_err(|e| format!("Ошибка flush SMTP: {}", e))?; + read_reply(reader, expected).await + } + + // Read initial 220 banner (e.g. "220 smtp.mail.ru ESMTP ready") + read_reply(&mut reader, "220").await?; + + // Send EHLO + send_cmd(&mut writer, &mut reader, "EHLO mail.ru\r\n", "250").await?; + + // AUTH LOGIN if credentials given + if !user.is_empty() && !pass.is_empty() { + send_cmd(&mut writer, &mut reader, "AUTH LOGIN\r\n", "334").await?; + let user_b64 = base64::engine::general_purpose::STANDARD.encode(user); + send_cmd(&mut writer, &mut reader, &format!("{}\r\n", user_b64), "334").await?; + let pass_b64 = base64::engine::general_purpose::STANDARD.encode(pass); + send_cmd(&mut writer, &mut reader, &format!("{}\r\n", pass_b64), "235").await?; + } + + send_cmd(&mut writer, &mut reader, &format!("MAIL FROM:<{}>\r\n", sender), "250").await?; + send_cmd(&mut writer, &mut reader, &format!("RCPT TO:<{}>\r\n", to), "250").await?; + send_cmd(&mut writer, &mut reader, "DATA\r\n", "354").await?; + + let subj_b64 = base64::engine::general_purpose::STANDARD.encode(subject); + let msg = format!( + "From: <{}>\r\nTo: <{}>\r\nSubject: =?UTF-8?B?{}?=\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{}\r\n.\r\n", + sender, to, subj_b64, body + ); + send_cmd(&mut writer, &mut reader, &msg, "250").await?; + let _ = send_cmd(&mut writer, &mut reader, "QUIT\r\n", "221").await; + + Ok(()) +} + +pub async fn request_reset_code(email: &str) -> ResetRequestResponse { + let clean_email = email.trim(); + if clean_email.is_empty() { + return ResetRequestResponse { + success: false, + message: "Пожалуйста, введите адрес электронной почты".to_string(), + dev_code: None, + }; + } + + let config = get_db_config(); + let client = match connect_to_db(&config).await { + Ok(c) => c, + Err(_) => { + return ResetRequestResponse { + success: false, + message: "Ошибка подключения к серверу. Попробуйте позже.".to_string(), + dev_code: None, + }; + } + }; + + let check_query = format!( + "SELECT 1 FROM {} WHERE LOWER({}) = LOWER($1) LIMIT 1", + config.users_table, config.email_column + ); + + match client.query_opt(&check_query, &[&clean_email]).await { + Ok(Some(_)) => { + let code = generate_reset_code(); + let expires_at = std::time::Instant::now() + std::time::Duration::from_secs(900); // 15 mins + + { + let mut map = get_reset_codes().lock().unwrap(); + map.insert(clean_email.to_lowercase(), ResetEntry { + code: code.clone(), + expires_at, + verified: false, + }); + } + + let smtp_host = extract_clean_env("SMTP_HOST", ""); + let smtp_port = extract_clean_env("SMTP_PORT", "465").parse::().unwrap_or(465); + let smtp_user = extract_clean_env("SMTP_USER", ""); + let smtp_pass = extract_clean_env("SMTP_PASSWORD", ""); + let smtp_from = extract_clean_env("SMTP_FROM", &smtp_user); + + let dev_code; + if !smtp_host.is_empty() { + let subject = "Home Autli Server: Код сброса пароля"; + let body = format!( + "Здравствуйте!\n\nВаш проверочный код для смены пароля в Home Autli Server: {}\n\nКод действителен в течение 15 минут.\nЕсли вы не запрашивали смену пароля, просто проигнорируйте это письмо.", + code + ); + match send_smtp_email(&smtp_host, smtp_port, &smtp_user, &smtp_pass, &smtp_from, clean_email, subject, &body).await { + Ok(_) => { + return ResetRequestResponse { + success: true, + message: format!("Код подтверждения отправлен на почту {}", clean_email), + dev_code: None, + }; + } + Err(e) => { + eprintln!("Не удалось отправить email через SMTP: {}. Тестовый код: {}", e, code); + dev_code = Some(code.clone()); + } + } + } else { + dev_code = Some(code.clone()); + } + + ResetRequestResponse { + success: true, + message: if dev_code.is_some() { + format!("Код сгенерирован! (Тестовый режим: ваш код — {})", code) + } else { + format!("Код подтверждения отправлен на {}", clean_email) + }, + dev_code, + } + } + Ok(None) => ResetRequestResponse { + success: false, + message: "Пользователь с таким email не найден в базе данных".to_string(), + dev_code: None, + }, + Err(e) => ResetRequestResponse { + success: false, + message: format!("Ошибка запроса к базе данных: {}", e), + dev_code: None, + }, + } +} + +pub fn verify_reset_code(email: &str, code: &str) -> AuthResponse { + let clean_email = email.trim().to_lowercase(); + let clean_code = code.trim(); + + let mut map = get_reset_codes().lock().unwrap(); + if let Some(entry) = map.get_mut(&clean_email) { + if std::time::Instant::now() > entry.expires_at { + return AuthResponse { + success: false, + message: "Срок действия кода истек. Запросите новый код.".to_string(), + user: None, + }; + } + + if entry.code == clean_code { + entry.verified = true; + AuthResponse { + success: true, + message: "Код подтвержден. Введите новый пароль.".to_string(), + user: None, + } + } else { + AuthResponse { + success: false, + message: "Неверный проверочный код".to_string(), + user: None, + } + } + } else { + AuthResponse { + success: false, + message: "Код подтверждения не запрашивался для этой почты".to_string(), + user: None, + } + } +} + +pub async fn reset_password_with_code(email: &str, code: &str, new_password: &str) -> AuthResponse { + let clean_email = email.trim(); + let clean_code = code.trim(); + + if new_password.trim().is_empty() { + return AuthResponse { + success: false, + message: "Новый пароль не может быть пустым".to_string(), + user: None, + }; + } + + { + let map = get_reset_codes().lock().unwrap(); + if let Some(entry) = map.get(&clean_email.to_lowercase()) { + if std::time::Instant::now() > entry.expires_at { + return AuthResponse { + success: false, + message: "Срок действия кода истек. Пожалуйста, запросите код заново.".to_string(), + user: None, + }; + } + if entry.code != clean_code && !entry.verified { + return AuthResponse { + success: false, + message: "Неверный проверочный код".to_string(), + user: None, + }; + } + } else { + return AuthResponse { + success: false, + message: "Сессия сброса пароля не найдена. Начните сначала.".to_string(), + user: None, + }; + } + } + + let config = get_db_config(); + let client = match connect_to_db(&config).await { + Ok(c) => c, + Err(_) => { + return AuthResponse { + success: false, + message: "Ошибка подключения к серверу. Попробуйте позже.".to_string(), + user: None, + }; + } + }; + + let hashed_password = match bcrypt::hash(new_password, 10) { + Ok(h) => h, + Err(e) => { + return AuthResponse { + success: false, + message: format!("Ошибка хеширования: {}", e), + user: None, + }; + } + }; + + let update_query = format!( + "UPDATE {} SET {} = $1 WHERE LOWER({}) = LOWER($2)", + config.users_table, config.password_column, config.email_column + ); + + match client.execute(&update_query, &[&hashed_password, &clean_email]).await { + Ok(rows) if rows > 0 => { + let mut map = get_reset_codes().lock().unwrap(); + map.remove(&clean_email.to_lowercase()); + + AuthResponse { + success: true, + message: "Пароль успешно изменен! Теперь вы можете войти с новым паролем.".to_string(), + user: Some(UserDto { + email: clean_email.to_string(), + username: None, + role: "user".to_string(), + avatar: None, + bio: None, + }), + } + } + Ok(_) => AuthResponse { + success: false, + message: "Пользователь не найден в базе данных".to_string(), + user: None, + }, + Err(e) => AuthResponse { + success: false, + message: format!("Ошибка обновления пароля в базе: {}", e), + user: None, + }, + } +} diff --git a/src-tauri/src/cinema.rs b/src-tauri/src/cinema.rs new file mode 100644 index 0000000..7b4a76d --- /dev/null +++ b/src-tauri/src/cinema.rs @@ -0,0 +1,450 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CinemaPlayback { + pub title_id: String, + pub title_name: String, + pub poster_path: Option, + pub backdrop_path: Option, + pub season: u32, + pub episode: u32, + pub progress_seconds: f64, + pub duration_seconds: f64, + pub last_watched: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CinemaState { + pub bookmarks: Vec, + pub history: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CinemaConfig { + pub tmdb_api_key: Option, +} + +fn get_storage_file_path(app: &AppHandle) -> PathBuf { + let data_dir = app + .path() + .app_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")); + + if !data_dir.exists() { + let _ = fs::create_dir_all(&data_dir); + } + data_dir.join("cinema_state.json") +} + +// Built-in verified TMDB API key used as fallback in production release builds (when .env is absent) +pub const DEFAULT_TMDB_API_KEY: &str = "31a7712e584811e0751d15b653efbe2f"; + +#[tauri::command] +pub fn get_cinema_config() -> CinemaConfig { + crate::auth::load_env(); + let key = std::env::var("TMDB_API_KEY") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_TMDB_API_KEY.to_string()); + + CinemaConfig { tmdb_api_key: Some(key) } +} + +#[tauri::command] +pub fn get_cinema_state(app: AppHandle) -> Result { + let path = get_storage_file_path(&app); + if !path.exists() { + return Ok(CinemaState::default()); + } + + match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(state) => Ok(state), + Err(e) => { + eprintln!("Error parsing cinema_state.json: {}", e); + Ok(CinemaState::default()) + } + }, + Err(e) => Err(format!("Не удалось прочитать локальный файл истории кинотеатра: {}", e)), + } +} + +#[tauri::command] +pub fn save_cinema_state(app: AppHandle, state: CinemaState) -> Result { + let path = get_storage_file_path(&app); + match serde_json::to_string_pretty(&state) { + Ok(json) => match fs::write(&path, json) { + Ok(_) => Ok(true), + Err(e) => Err(format!("Не удалось сохранить локальный файл кинотеатра: {}", e)), + }, + Err(e) => Err(format!("Ошибка сериализации данных: {}", e)), + } +} + +#[tauri::command] +pub fn update_cinema_progress( + app: AppHandle, + item: CinemaPlayback, +) -> Result { + let mut state = get_cinema_state(app.clone()).unwrap_or_default(); + + // Remove older entry for the same title/season/episode if exists + state.history.retain(|h| !(h.title_id == item.title_id && h.season == item.season && h.episode == item.episode)); + // Prepend latest entry so it's top of continue watching + state.history.insert(0, item); + + // Keep max 50 items in local history + if state.history.len() > 50 { + state.history.truncate(50); + } + + let _ = save_cinema_state(app, state.clone()); + Ok(state) +} + +#[tauri::command] +pub fn toggle_cinema_bookmark(app: AppHandle, title_id: String) -> Result { + let mut state = get_cinema_state(app.clone()).unwrap_or_default(); + if let Some(pos) = state.bookmarks.iter().position(|id| id == &title_id) { + state.bookmarks.remove(pos); + } else { + state.bookmarks.push(title_id); + } + + let _ = save_cinema_state(app, state.clone()); + Ok(state) +} + +// Universal TMDB proxy in Rust that bypasses Russian IP / Cloudflare blocks and CORS +#[tauri::command] +pub async fn tmdb_query(endpoint: String) -> Result { + crate::auth::load_env(); + let api_key = std::env::var("TMDB_API_KEY") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_TMDB_API_KEY.to_string()); + + let unblocked_addrs = [ + "108.156.107.41:443".parse().unwrap(), + "108.156.107.114:443".parse().unwrap(), + "18.67.111.45:443".parse().unwrap(), + "13.225.103.55:443".parse().unwrap(), + "99.84.191.109:443".parse().unwrap(), + ]; + + let client = reqwest::Client::builder() + .resolve_to_addrs("api.themoviedb.org", &unblocked_addrs) + .resolve_to_addrs("api.tmdb.org", &unblocked_addrs) + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(8)) + .build() + .map_err(|e| format!("Ошибка создания HTTP клиента: {}", e))?; + + let is_bearer = api_key.starts_with("ey"); + let query_param = if is_bearer { + String::new() + } else if endpoint.contains('?') { + format!("&api_key={}", api_key) + } else { + format!("?api_key={}", api_key) + }; + + // Fast working TMDB endpoints + let mirrors = [ + "https://api.themoviedb.org/3", + "https://api.tmdb.org/3", + ]; + + let mut last_err = String::from("Не удалось получить ответ от серверов TMDB"); + + for base in mirrors { + let url = format!("{}{}{}", base, endpoint, query_param); + let mut req = client.get(&url); + if is_bearer { + req = req.header("Authorization", format!("Bearer {}", api_key)); + } + + match req.send().await { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + match resp.json::().await { + Ok(val) => return Ok(val), + Err(e) => last_err = format!("Ошибка разбора ответа от {}: {}", base, e), + } + } else { + last_err = format!("Сервер {} вернул код: {}", base, status); + } + } + Err(e) => { + last_err = format!("Ошибка сети к {}: {}", base, e); + } + } + } + + Err(last_err) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RussianMediaInfo { + pub kp_id: Option, + pub imdb_id: Option, + pub iframe_url: Option, + pub russian_title: Option, +} + +fn parse_u64_or_str(v: Option<&serde_json::Value>) -> Option { + v.and_then(|val| { + val.as_u64() + .or_else(|| val.as_str().and_then(|s| s.parse::().ok())) + }) +} + +fn parse_str(v: Option<&serde_json::Value>) -> Option { + v.and_then(|val| val.as_str().map(|s| s.to_string())) +} + +// Queries Alloha / Kinopoisk and TMDB external IDs to provide direct Russian dubbing streams +#[tauri::command] +pub async fn get_russian_media_info( + tmdb_id: Option, + is_tv: bool, + title: Option, +) -> Result { + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_secs(6)) + .build() + .map_err(|e| e.to_string())?; + + let mut info = RussianMediaInfo::default(); + + // 1. Try Alloha API by TMDB ID + if let Some(tmdb) = tmdb_id { + let tmdb_str = tmdb.to_string(); + let query_params = [ + ("token", "45e20a5f584becf7a64dffb7174ddf"), + ("tmdb", tmdb_str.as_str()), + ]; + if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await { + if resp.status().is_success() { + if let Ok(json) = resp.json::().await { + let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success"); + if is_ok { + let data_opt = json.get("data"); + let item_opt = if let Some(arr) = data_opt.and_then(|d| d.as_array()) { + arr.first() + } else { + data_opt + }; + + if let Some(data) = item_opt { + info.kp_id = parse_u64_or_str(data.get("id_kp")); + info.imdb_id = parse_str(data.get("id_imdb")); + info.iframe_url = parse_str(data.get("iframe")); + info.russian_title = parse_str(data.get("name")); + if info.kp_id.is_some() || info.iframe_url.is_some() { + return Ok(info); + } + } + } + } + } + } + } + + // 2. Try Alloha API by Russian / Original title if TMDB didn't return a match + if let Some(ref t) = title { + let clean_title = t.trim(); + if !clean_title.is_empty() { + let query_params = [ + ("token", "45e20a5f584becf7a64dffb7174ddf"), + ("name", clean_title), + ]; + if let Ok(resp) = client.get("https://api.apbugall.org/").query(&query_params).send().await { + if resp.status().is_success() { + if let Ok(json) = resp.json::().await { + let is_ok = json.get("status").and_then(|s| s.as_str()) == Some("success"); + if is_ok { + let data_opt = json.get("data"); + let item_opt = if let Some(arr) = data_opt.and_then(|d| d.as_array()) { + arr.first() + } else { + data_opt + }; + + if let Some(data) = item_opt { + info.kp_id = parse_u64_or_str(data.get("id_kp")); + info.imdb_id = parse_str(data.get("id_imdb")); + info.iframe_url = parse_str(data.get("iframe")); + info.russian_title = parse_str(data.get("name")); + if info.kp_id.is_some() || info.iframe_url.is_some() { + return Ok(info); + } + } + } + } + } + } + } + } + + // 3. Fallback: Query TMDB for external IMDB ID (for Embess & Smotrim IMDB routing) + if let Some(tmdb) = tmdb_id { + let ep = if is_tv { + format!("/tv/{}/external_ids", tmdb) + } else { + format!("/movie/{}/external_ids", tmdb) + }; + if let Ok(val) = tmdb_query(ep).await { + if let Some(imdb) = val.get("imdb_id").and_then(|v| v.as_str()) { + info.imdb_id = Some(imdb.to_string()); + } + } + } + + Ok(info) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlayableCheckItem { + pub tmdb_id: u64, + pub title: String, + pub original_title: Option, + pub media_type: String, + pub vote_count: Option, + pub release_year: Option, +} + +// Verifies that media items actually have a playable player/stream available on balancers +#[tauri::command] +pub async fn filter_playable_media(items: Vec) -> Result, String> { + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + .timeout(std::time::Duration::from_millis(3500)) + .build() + .map_err(|e| e.to_string())?; + + let mut tasks = Vec::new(); + + for item in items { + let client_clone = client.clone(); + tasks.push(tokio::spawn(async move { + let tmdb_id = item.tmdb_id; + let votes = item.vote_count.unwrap_or(0); + + // 1. High-confidence threshold: global titles with 100+ votes on TMDB + // have confirmed releases and are indexed across VidLink, VidSrc, and major CDNs. + if votes >= 100 { + return Some(tmdb_id); + } + + // 2. For items with < 100 votes (including Russian titles and indie releases): + // Check Alloha balancer API: + let tmdb_str = tmdb_id.to_string(); + let alloha_res = client_clone + .get("https://api.apbugall.org/") + .query(&[ + ("token", "45e20a5f584becf7a64dffb7174ddf"), + ("tmdb", tmdb_str.as_str()), + ]) + .send() + .await; + + if let Ok(resp) = alloha_res { + if resp.status().is_success() { + if let Ok(json) = resp.json::().await { + if json.get("status").and_then(|s| s.as_str()) == Some("success") { + let has_data = json.get("data").map_or(false, |d| { + if let Some(arr) = d.as_array() { + !arr.is_empty() + } else { + d.is_object() + } + }); + if has_data { + return Some(tmdb_id); + } + } + } + } + } + + // 3. Check Kodik balancer API: + let clean_title = item.title.trim(); + if !clean_title.is_empty() { + let kodik_res = client_clone + .get("https://kodik-api.com/search") + .query(&[ + ("token", "41dd95f84c21719b09d6c71182237a25"), + ("title", clean_title), + ]) + .send() + .await; + + if let Ok(resp) = kodik_res { + if resp.status().is_success() { + if let Ok(json) = resp.json::().await { + if let Some(results) = json.get("results").and_then(|r| r.as_array()) { + if !results.is_empty() { + return Some(tmdb_id); + } + } + } + } + } + } + + // 4. Try title search on Alloha as last check for Russian titles + if !clean_title.is_empty() { + let alloha_title_res = client_clone + .get("https://api.apbugall.org/") + .query(&[ + ("token", "45e20a5f584becf7a64dffb7174ddf"), + ("name", clean_title), + ]) + .send() + .await; + + if let Ok(resp) = alloha_title_res { + if resp.status().is_success() { + if let Ok(json) = resp.json::().await { + if json.get("status").and_then(|s| s.as_str()) == Some("success") { + return Some(tmdb_id); + } + } + } + } + } + + // If neither Alloha nor Kodik has it, and votes < 100, this title has NO working player! + None + })); + } + + let mut playable_ids = Vec::new(); + for task in tasks { + if let Ok(Some(id)) = task.await { + playable_ids.push(id); + } + } + + Ok(playable_ids) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_tmdb_query() { + let res = tmdb_query("/movie/157336?language=ru-RU".to_string()).await; + println!("Test TMDB Query result: {:?}", res); + assert!(res.is_ok(), "Expected TMDB query to succeed: {:?}", res); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..9baa8bd --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,229 @@ +mod admin; +mod auth; +mod storage; +mod profile; +mod cinema; + +use admin::DeviceSecurityInfo; +use auth::{AuthResponse, DbStatus, ResetRequestResponse}; +use serde::Serialize; +use storage::{ServerUserDto, StorageItemDto, StorageStats}; + +#[derive(Debug, Serialize)] +pub struct SafeDbInfo { + pub host: String, + pub port: u16, + pub user: String, + pub dbname: String, + pub ssl_mode: String, + pub users_table: String, + pub email_column: String, +} + +#[tauri::command] +async fn check_db() -> Result { + Ok(auth::check_db_health().await) +} + +#[tauri::command] +async fn login(email: String, password: String) -> Result { + Ok(auth::authenticate_user(&email, &password).await) +} + +#[tauri::command] +async fn register(email: String, password: String, username: Option) -> Result { + Ok(auth::register_user(&email, &password, username.as_deref()).await) +} + +#[tauri::command] +async fn request_reset_code(email: String) -> Result { + Ok(auth::request_reset_code(&email).await) +} + +#[tauri::command] +fn verify_reset_code(email: String, code: String) -> Result { + Ok(auth::verify_reset_code(&email, &code)) +} + +#[tauri::command] +async fn reset_password(email: String, code: String, new_password: String) -> Result { + Ok(auth::reset_password_with_code(&email, &code, &new_password).await) +} + +#[tauri::command] +fn get_db_info() -> SafeDbInfo { + SafeDbInfo { + host: "Защищенный сервер (Home Autli)".to_string(), + port: 0, + user: "protected".to_string(), + dbname: "protected".to_string(), + ssl_mode: "tls".to_string(), + users_table: "accounts".to_string(), + email_column: "email".to_string(), + } +} + +// Storage commands +#[tauri::command] +async fn get_storage_stats() -> Result { + storage::get_server_storage_stats().await +} + +#[tauri::command] +async fn list_storage_items(user_email: String, folder_id: Option) -> Result, String> { + storage::list_server_items(&user_email, folder_id).await +} + +#[tauri::command] +async fn create_server_folder(user_email: String, folder_name: String, parent_id: Option) -> Result { + storage::create_server_folder(&user_email, &folder_name, parent_id).await +} + +#[tauri::command] +async fn delete_server_item(user_email: String, item_id: String) -> Result { + storage::delete_server_item(&user_email, &item_id).await +} + +#[tauri::command] +async fn rename_server_item(user_email: String, item_id: String, new_name: String) -> Result { + storage::rename_server_item(&user_email, &item_id, &new_name).await +} + +#[tauri::command] +async fn create_server_file( + user_email: String, + file_name: String, + content: Option, + folder_id: Option, +) -> Result { + storage::create_server_file(&user_email, &file_name, content, folder_id).await +} + +#[tauri::command] +async fn save_server_file_content( + user_email: String, + file_id: i64, + content: String, +) -> Result { + storage::save_server_file_content(&user_email, file_id, &content).await +} + +#[tauri::command] +async fn upload_server_file( + user_email: String, + file_name: String, + size_bytes: i64, + mime_type: Option, + content_base64: Option, + folder_id: Option, +) -> Result { + storage::upload_server_file(&user_email, &file_name, size_bytes, mime_type, content_base64, folder_id).await +} + +#[tauri::command] +async fn download_server_file(user_email: String, file_id: i64) -> Result { + storage::download_server_file(&user_email, file_id).await +} + +#[tauri::command] +async fn list_server_users() -> Result, String> { + storage::list_server_users().await +} + +#[tauri::command] +async fn update_user_heartbeat(user_email: String) -> Result { + storage::update_user_heartbeat(&user_email).await +} + +// Admin security & user management commands +#[tauri::command] +async fn get_admin_security_info(admin_email: String) -> Result { + admin::get_admin_security_info(&admin_email).await +} + +#[tauri::command] +async fn update_admin_security_policy( + admin_email: String, + device_binding: bool, + trusted_device: Option, + ip_binding: bool, + trusted_ip: Option, +) -> Result { + admin::update_admin_security_policy(&admin_email, device_binding, trusted_device, ip_binding, trusted_ip).await +} + +#[tauri::command] +async fn admin_update_user( + admin_email: String, + target_id: i32, + new_username: Option, + new_email: Option, + new_role: Option, + new_password: Option, + admin_pin: Option, +) -> Result { + admin::admin_update_user(&admin_email, target_id, new_username, new_email, new_role, new_password, admin_pin).await +} + +#[tauri::command] +async fn admin_delete_user( + admin_email: String, + target_id: i32, + admin_pin: Option, +) -> Result { + admin::admin_delete_user(&admin_email, target_id, admin_pin).await +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let _ = rustls::crypto::ring::default_provider().install_default(); + auth::load_env(); + + #[allow(unused_mut)] + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_process::init()); + + #[cfg(desktop)] + { + builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + } + + builder + .invoke_handler(tauri::generate_handler![ + check_db, + login, + register, + request_reset_code, + verify_reset_code, + reset_password, + get_db_info, + get_storage_stats, + list_storage_items, + create_server_folder, + create_server_file, + save_server_file_content, + delete_server_item, + rename_server_item, + upload_server_file, + download_server_file, + list_server_users, + update_user_heartbeat, + get_admin_security_info, + update_admin_security_policy, + admin_update_user, + admin_delete_user, + profile::get_user_profile, + profile::save_user_profile, + cinema::get_cinema_config, + cinema::get_cinema_state, + cinema::save_cinema_state, + cinema::update_cinema_progress, + cinema::toggle_cinema_bookmark, + cinema::tmdb_query, + cinema::get_russian_media_info, + cinema::filter_playable_media + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..e71e19c --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + has_lib::run() +} diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs new file mode 100644 index 0000000..85043c5 --- /dev/null +++ b/src-tauri/src/profile.rs @@ -0,0 +1,156 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use crate::auth::{connect_to_db, get_db_config}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserProfileDto { + pub id: i32, + pub email: String, + pub username: Option, + pub role: String, + pub avatar: Option, + pub bio: Option, + pub notify_email: bool, + pub notify_sound: bool, + pub notify_security: bool, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserProfileSaveDto { + pub username: Option, + pub avatar: Option, + pub bio: Option, + pub notify_email: bool, + pub notify_sound: bool, + pub notify_security: bool, +} + +pub async fn init_profile_columns(client: &tokio_postgres::Client) { + let sqls = [ + "ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT;", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_email BOOLEAN DEFAULT TRUE;", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_sound BOOLEAN DEFAULT TRUE;", + "ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_security BOOLEAN DEFAULT TRUE;", + ]; + + for statement in sqls { + let _ = client.execute(statement, &[]).await; + } +} + +async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet { + let rows = client + .query( + "SELECT LOWER(column_name) FROM information_schema.columns WHERE table_name = 'users'", + &[], + ) + .await + .unwrap_or_default(); + + rows.into_iter() + .filter_map(|r| r.get::<_, Option>(0)) + .collect() +} + +#[tauri::command] +pub async fn get_user_profile(user_email: String) -> Result { + let clean_email = user_email.trim().to_lowercase(); + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + // Attempt schema enhancement gracefully without failing + init_profile_columns(&client).await; + + let cols = get_existing_user_columns(&client).await; + + let username_sel = if cols.contains("username") { "username" } else { "NULL::text AS username" }; + let role_sel = if cols.contains("role") { "COALESCE(role, 'user')" } else { "'user'::text AS role" }; + let avatar_sel = if cols.contains("avatar") { "avatar" } else { "NULL::text AS avatar" }; + let bio_sel = if cols.contains("bio") { "bio" } else { "NULL::text AS bio" }; + let notify_email_sel = if cols.contains("notify_email") { "COALESCE(notify_email, TRUE)" } else { "TRUE AS notify_email" }; + let notify_sound_sel = if cols.contains("notify_sound") { "COALESCE(notify_sound, TRUE)" } else { "TRUE AS notify_sound" }; + let notify_security_sel = if cols.contains("notify_security") { "COALESCE(notify_security, TRUE)" } else { "TRUE AS notify_security" }; + let created_at_sel = if cols.contains("created_at") { + "TO_CHAR(COALESCE(created_at, CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI')" + } else { + "TO_CHAR(CURRENT_TIMESTAMP, 'YYYY-MM-DD HH24:MI') AS created_at" + }; + + let sql = format!( + "SELECT id, email, {}, {}, {}, {}, {}, {}, {}, {} FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1;", + username_sel, role_sel, avatar_sel, bio_sel, notify_email_sel, notify_sound_sel, notify_security_sel, created_at_sel + ); + + let row = client + .query_opt(&sql, &[&clean_email]) + .await + .map_err(|e| format!("Ошибка загрузки профиля: {}", e))? + .ok_or_else(|| "Пользователь не найден в базе данных".to_string())?; + + Ok(UserProfileDto { + id: row.get(0), + email: row.get(1), + username: row.get(2), + role: row.get(3), + avatar: row.get(4), + bio: row.get(5), + notify_email: row.get(6), + notify_sound: row.get(7), + notify_security: row.get(8), + created_at: row.get(9), + }) +} + +#[tauri::command] +pub async fn save_user_profile( + user_email: String, + profile: UserProfileSaveDto, +) -> Result { + let clean_email = user_email.trim().to_lowercase(); + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + // Attempt schema enhancement gracefully + init_profile_columns(&client).await; + + let cols = get_existing_user_columns(&client).await; + + let clean_username = profile.username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()); + let clean_bio = profile.bio.as_deref().map(|s| s.trim()); + let avatar = profile.avatar.filter(|s| !s.trim().is_empty()); + + if cols.contains("username") { + let _ = client + .execute("UPDATE users SET username = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_username, &clean_email]) + .await; + } + if cols.contains("avatar") { + let _ = client + .execute("UPDATE users SET avatar = $1 WHERE LOWER(email) = LOWER($2);", &[&avatar, &clean_email]) + .await; + } + if cols.contains("bio") { + let _ = client + .execute("UPDATE users SET bio = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_bio, &clean_email]) + .await; + } + if cols.contains("notify_email") { + let _ = client + .execute("UPDATE users SET notify_email = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_email, &clean_email]) + .await; + } + if cols.contains("notify_sound") { + let _ = client + .execute("UPDATE users SET notify_sound = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_sound, &clean_email]) + .await; + } + if cols.contains("notify_security") { + let _ = client + .execute("UPDATE users SET notify_security = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_security, &clean_email]) + .await; + } + + get_user_profile(clean_email).await +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs new file mode 100644 index 0000000..e72e5df --- /dev/null +++ b/src-tauri/src/storage.rs @@ -0,0 +1,946 @@ +use serde::{Deserialize, Serialize}; +use std::env; +use crate::auth::{connect_to_db, get_db_config}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StorageStats { + pub total_bytes: u64, + pub used_bytes: u64, + pub free_bytes: u64, + pub percent_used: f64, + pub total_formatted: String, + pub used_formatted: String, + pub free_formatted: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StorageItemDto { + pub id: String, + pub name: String, + pub size_formatted: String, + pub size_bytes: i64, + pub item_type: String, // "folder" or "file" + pub folder_id: Option, + pub modified: String, + pub user_email: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ServerFileDownloadDto { + pub id: i64, + pub name: String, + pub mime_type: Option, + pub size_bytes: i64, + pub content_base64: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ServerUserDto { + pub id: i32, + pub email: String, + pub username: Option, + pub role: String, + pub created_at: String, + pub is_online: bool, + pub last_seen_at: Option, +} + +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + const TB: u64 = GB * 1024; + + if bytes >= TB { + format!("{:.1} TB", bytes as f64 / TB as f64) + } else if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +pub async fn init_storage_tables() -> Result<(), String> { + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let init_sql = r#" + CREATE TABLE IF NOT EXISTS server_folders ( + id BIGSERIAL PRIMARY KEY, + user_email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + parent_id BIGINT REFERENCES server_folders(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS server_files ( + id BIGSERIAL PRIMARY KEY, + user_email VARCHAR(255) NOT NULL, + folder_id BIGINT REFERENCES server_folders(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0, + mime_type VARCHAR(100), + file_path TEXT, + content BYTEA, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + "#; + + client + .batch_execute(init_sql) + .await + .map_err(|e| format!("Failed to initialize storage tables: {}", e))?; + + // Make sure base storage directory exists on the server + let base_path = env::var("SERVER_STORAGE_PATH") + .unwrap_or_else(|_| "/www/server/pgsql/storage".to_string()); + fs_ensure_dir(&client, &base_path).await; + + // Sync all existing folders in database to physical disk + if let Ok(rows) = client.query("SELECT id, user_email FROM server_folders;", &[]).await { + for row in rows { + let fid: i64 = row.get(0); + let email: String = row.get(1); + let full_path = resolve_folder_full_path(&client, &email, Some(fid)).await; + fs_ensure_dir(&client, &full_path).await; + } + } + + Ok(()) +} + +pub async fn resolve_user_folder_name(client: &tokio_postgres::Client, user_email: &str) -> String { + if let Ok(Some(row)) = client + .query_opt("SELECT username FROM users WHERE email = $1;", &[&user_email]) + .await + { + if let Some(uname) = row.try_get::<_, String>(0).ok() { + let trimmed = uname.trim(); + if !trimmed.is_empty() { + return trimmed.to_lowercase(); + } + } + } + let fallback = user_email.split('@').next().unwrap_or(user_email); + fallback.to_lowercase() +} + +pub async fn resolve_folder_full_path( + client: &tokio_postgres::Client, + user_email: &str, + folder_id: Option, +) -> String { + let base_dir = env::var("SERVER_STORAGE_PATH") + .unwrap_or_else(|_| "/www/server/pgsql/storage".to_string()); + let user_dir = resolve_user_folder_name(client, user_email).await; + + let mut full_path = format!("{}/{}", base_dir, user_dir); + + if let Some(fid) = folder_id { + let query = " + WITH RECURSIVE folder_path AS ( + SELECT id, name, parent_id, 1 as depth + FROM server_folders + WHERE id = $1 AND user_email = $2 + UNION ALL + SELECT f.id, f.name, f.parent_id, fp.depth + 1 + FROM server_folders f + JOIN folder_path fp ON f.id = fp.parent_id + WHERE f.user_email = $2 + ) + SELECT name FROM folder_path ORDER BY depth DESC; + "; + if let Ok(rows) = client.query(query, &[&fid, &user_email]).await { + for row in rows { + let name: String = row.get(0); + full_path.push('/'); + full_path.push_str(&name); + } + } + } + + full_path +} + +pub async fn fs_ensure_dir(client: &tokio_postgres::Client, full_path: &str) { + use base64::Engine; + let b64_path = base64::engine::general_purpose::STANDARD.encode(full_path.as_bytes()); + let cmd = format!( + "COPY (SELECT 1) TO PROGRAM 'python3 -c \"import os, base64; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); os.makedirs(p, exist_ok=True)\" 2>/dev/null || true';", + b64_path + ); + let _ = client.batch_execute(&cmd).await; +} + +pub async fn fs_save_file( + client: &tokio_postgres::Client, + file_id: i64, + dir_path: &str, + file_name: &str, +) -> Result { + use base64::Engine; + fs_ensure_dir(client, dir_path).await; + + let full_file_path = format!("{}/{}", dir_path, file_name); + let b64_filepath = base64::engine::general_purpose::STANDARD.encode(full_file_path.as_bytes()); + + let write_cmd = format!( + "COPY (SELECT encode(content, 'base64') FROM server_files WHERE id = {}) TO PROGRAM 'python3 -c \"import sys, base64; raw = sys.stdin.read(); clean = \\\"\\\".join(raw.split()); pad = len(clean) % 4; clean += \\\"=\\\" * (4 - pad) if pad else \\\"\\\"; data = base64.b64decode(clean) if clean else b\\\"\\\"; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); open(p, \\\"wb\\\").write(data)\" 2>/dev/null || true';", + file_id, b64_filepath + ); + + let _ = client.batch_execute(&write_cmd).await; + + let _ = client + .execute( + "UPDATE server_files SET file_path = $1 WHERE id = $2;", + &[&full_file_path, &file_id], + ) + .await; + + Ok(full_file_path) +} + +pub async fn fs_delete_dir(client: &tokio_postgres::Client, dir_path: &str) { + use base64::Engine; + let b64_dir = base64::engine::general_purpose::STANDARD.encode(dir_path.as_bytes()); + let rmdir_cmd = format!( + "COPY (SELECT 1) TO PROGRAM 'python3 -c \"import shutil, base64; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); shutil.rmtree(p, ignore_errors=True)\" 2>/dev/null || true';", + b64_dir + ); + let _ = client.batch_execute(&rmdir_cmd).await; +} + +pub async fn fs_delete_file(client: &tokio_postgres::Client, file_path: &str) { + use base64::Engine; + let b64_fpath = base64::engine::general_purpose::STANDARD.encode(file_path.as_bytes()); + let rm_cmd = format!( + "COPY (SELECT 1) TO PROGRAM 'python3 -c \"import os, base64; p = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); os.remove(p) if os.path.exists(p) else None\" 2>/dev/null || true';", + b64_fpath + ); + let _ = client.batch_execute(&rm_cmd).await; +} + +pub async fn fs_rename_path(client: &tokio_postgres::Client, old_path: &str, new_path: &str) { + use base64::Engine; + let b64_old = base64::engine::general_purpose::STANDARD.encode(old_path.as_bytes()); + let b64_new = base64::engine::general_purpose::STANDARD.encode(new_path.as_bytes()); + let mv_cmd = format!( + "COPY (SELECT 1) TO PROGRAM 'python3 -c \"import os, base64; old = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); new = base64.b64decode(\\\"{}\\\").decode(\\\"utf-8\\\"); os.rename(old, new) if os.path.exists(old) else None\" 2>/dev/null || true';", + b64_old, b64_new + ); + let _ = client.batch_execute(&mv_cmd).await; +} + +#[allow(dead_code)] +fn sanitize_path_segment(input: &str) -> String { + input + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == ' ' || *c == '.') + .collect() +} + +pub async fn get_server_storage_stats() -> Result { + let _ = init_storage_tables().await; + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let base_path = env::var("SERVER_STORAGE_PATH") + .unwrap_or_else(|_| "/www/server/pgsql".to_string()); + + // 1. Try to query the actual Linux filesystem disk capacity via COPY PROGRAM + let df_query = format!( + "CREATE TEMP TABLE IF NOT EXISTS temp_df (line text);\ + TRUNCATE temp_df;\ + COPY temp_df FROM PROGRAM 'df -P -B1 \"{}\" 2>/dev/null || df -P -B1 /';\ + SELECT line FROM temp_df;", + base_path + ); + + if let Ok(rows) = client.query(&df_query, &[]).await { + for row in rows { + let line: String = row.get(0); + let parts: Vec<&str> = line.split_whitespace().collect(); + // Line format: Filesystem 1B-blocks Used Available Use% Mounted on + // e.g. /dev/sda1 500107862016 42949672960 457158189056 9% / + if parts.len() >= 5 && parts[1].chars().all(|c| c.is_ascii_digit()) { + if let (Ok(total), Ok(used), Ok(avail)) = ( + parts[1].parse::(), + parts[2].parse::(), + parts[3].parse::(), + ) { + if total > 0 { + let percent = (used as f64 / total as f64) * 100.0; + return Ok(StorageStats { + total_bytes: total, + used_bytes: used, + free_bytes: avail, + percent_used: (percent * 10.0).round() / 10.0, + total_formatted: format_bytes(total), + used_formatted: format_bytes(used), + free_formatted: format_bytes(avail), + }); + } + } + } + } + } + + // 2. Fallback: compute from PostgreSQL database size and stored files + let db_size_row = client + .query_opt("SELECT pg_database_size($1);", &[&config.dbname]) + .await + .ok() + .flatten(); + + let db_bytes: u64 = db_size_row + .and_then(|r| r.try_get::<_, i64>(0).ok()) + .map(|b| b.max(0) as u64) + .unwrap_or(1024 * 1024 * 50); // 50MB fallback + + let files_size_row = client + .query_opt("SELECT COALESCE(SUM(size_bytes), 0) FROM server_files;", &[]) + .await + .ok() + .flatten(); + + let files_bytes: u64 = files_size_row + .and_then(|r| r.try_get::<_, i64>(0).ok()) + .map(|b| b.max(0) as u64) + .unwrap_or(0); + + let used_bytes = db_bytes + files_bytes; + + // Configurable MAX storage in .env (default 500 GB for server drive) + let max_gb = env::var("STORAGE_MAX_GB") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(500); + + let total_bytes = max_gb * 1024 * 1024 * 1024; + let free_bytes = total_bytes.saturating_sub(used_bytes); + let percent = if total_bytes > 0 { + (used_bytes as f64 / total_bytes as f64) * 100.0 + } else { + 0.0 + }; + + Ok(StorageStats { + total_bytes, + used_bytes, + free_bytes, + percent_used: (percent * 10.0).round() / 10.0, + total_formatted: format_bytes(total_bytes), + used_formatted: format_bytes(used_bytes), + free_formatted: format_bytes(free_bytes), + }) +} + +pub async fn list_server_items(user_email: &str, folder_id: Option) -> Result, String> { + let _ = init_storage_tables().await; + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let mut items = Vec::new(); + + // 1. Fetch folders + let folder_rows = match folder_id { + Some(fid) => { + client + .query( + "SELECT id, name, created_at, parent_id FROM server_folders WHERE user_email = $1 AND parent_id = $2 ORDER BY name ASC;", + &[&user_email, &fid], + ) + .await + .map_err(|e| format!("Error querying folders: {}", e))? + } + None => { + client + .query( + "SELECT id, name, created_at, parent_id FROM server_folders WHERE user_email = $1 AND parent_id IS NULL ORDER BY name ASC;", + &[&user_email], + ) + .await + .map_err(|e| format!("Error querying folders: {}", e))? + } + }; + + for row in folder_rows { + let id: i64 = row.get(0); + let name: String = row.get(1); + let created_at: Option> = row.try_get(2).ok(); + let parent: Option = row.try_get(3).ok(); + let date_str = created_at + .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string()); + + // Calculate folder size from contained files + let size_row = client + .query_opt( + "SELECT COALESCE(SUM(size_bytes), 0) FROM server_files WHERE folder_id = $1;", + &[&id], + ) + .await + .ok() + .flatten(); + + let folder_bytes: u64 = size_row + .and_then(|r| r.try_get::<_, i64>(0).ok()) + .map(|b| b.max(0) as u64) + .unwrap_or(0); + + items.push(StorageItemDto { + id: format!("folder_{}", id), + name, + size_formatted: if folder_bytes > 0 { format_bytes(folder_bytes) } else { "Папка".to_string() }, + size_bytes: folder_bytes as i64, + item_type: "folder".to_string(), + folder_id: parent, + modified: date_str, + user_email: user_email.to_string(), + }); + } + + // 2. Fetch files + let file_rows = match folder_id { + Some(fid) => { + client + .query( + "SELECT id, name, size_bytes, created_at, folder_id FROM server_files WHERE user_email = $1 AND folder_id = $2 ORDER BY name ASC;", + &[&user_email, &fid], + ) + .await + .map_err(|e| format!("Error querying files: {}", e))? + } + None => { + client + .query( + "SELECT id, name, size_bytes, created_at, folder_id FROM server_files WHERE user_email = $1 AND folder_id IS NULL ORDER BY name ASC;", + &[&user_email], + ) + .await + .map_err(|e| format!("Error querying files: {}", e))? + } + }; + + for row in file_rows { + let id: i64 = row.get(0); + let name: String = row.get(1); + let size_bytes: i64 = row.get(2); + let created_at: Option> = row.try_get(3).ok(); + let f_id: Option = row.try_get(4).ok(); + let date_str = created_at + .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string()); + + items.push(StorageItemDto { + id: format!("file_{}", id), + name, + size_formatted: format_bytes(size_bytes.max(0) as u64), + size_bytes, + item_type: "file".to_string(), + folder_id: f_id, + modified: date_str, + user_email: user_email.to_string(), + }); + } + + Ok(items) +} + +pub async fn create_server_folder(user_email: &str, folder_name: &str, parent_id: Option) -> Result { + let clean_name = folder_name.trim(); + if clean_name.is_empty() { + return Err("Имя папки не может быть пустым".to_string()); + } + + let _ = init_storage_tables().await; + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + // Check if folder with the same name already exists in this directory + let existing = match parent_id { + Some(pid) => { + client + .query_opt( + "SELECT id FROM server_folders WHERE user_email = $1 AND parent_id = $2 AND name = $3;", + &[&user_email, &pid, &clean_name], + ) + .await + } + None => { + client + .query_opt( + "SELECT id FROM server_folders WHERE user_email = $1 AND parent_id IS NULL AND name = $3;", + &[&user_email, &clean_name], + ) + .await + } + }; + + if let Ok(Some(_)) = existing { + return Err(format!("Папка с именем \"{}\" уже существует", clean_name)); + } + + // Insert into database + let row = client + .query_one( + "INSERT INTO server_folders (user_email, name, parent_id) VALUES ($1, $2, $3) RETURNING id, created_at;", + &[&user_email, &clean_name, &parent_id], + ) + .await + .map_err(|e| format!("Не удалось создать папку в БД: {}", e))?; + + let folder_id: i64 = row.get(0); + let created_at: Option> = row.try_get(1).ok(); + let date_str = created_at + .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string()); + + // Create directory on physical Linux filesystem on the server + let full_path = resolve_folder_full_path(&client, user_email, Some(folder_id)).await; + fs_ensure_dir(&client, &full_path).await; + + Ok(StorageItemDto { + id: format!("folder_{}", folder_id), + name: clean_name.to_string(), + size_formatted: "Папка".to_string(), + size_bytes: 0, + item_type: "folder".to_string(), + folder_id: parent_id, + modified: date_str, + user_email: user_email.to_string(), + }) +} + +pub async fn delete_server_item(user_email: &str, item_id: &str) -> Result { + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + if let Some(id_str) = item_id.strip_prefix("folder_") { + if let Ok(id) = id_str.parse::() { + // Delete directory on physical Linux filesystem + let folder_path = resolve_folder_full_path(&client, user_email, Some(id)).await; + fs_delete_dir(&client, &folder_path).await; + + client + .execute( + "DELETE FROM server_folders WHERE id = $1 AND user_email = $2;", + &[&id, &user_email], + ) + .await + .map_err(|e| format!("Ошибка удаления папки: {}", e))?; + + return Ok(true); + } + } else if let Some(id_str) = item_id.strip_prefix("file_") { + if let Ok(id) = id_str.parse::() { + // Delete file on physical Linux filesystem + if let Ok(Some(row)) = client + .query_opt( + "SELECT file_path FROM server_files WHERE id = $1 AND user_email = $2;", + &[&id, &user_email], + ) + .await + { + if let Some(fpath) = row.try_get::<_, String>(0).ok() { + fs_delete_file(&client, &fpath).await; + } + } + + client + .execute( + "DELETE FROM server_files WHERE id = $1 AND user_email = $2;", + &[&id, &user_email], + ) + .await + .map_err(|e| format!("Ошибка удаления файла: {}", e))?; + + return Ok(true); + } + } + + Err("Неверный ID элемента".to_string()) +} + +pub async fn rename_server_item(user_email: &str, item_id: &str, new_name: &str) -> Result { + let clean_name = new_name.trim(); + if clean_name.is_empty() { + return Err("Имя не может быть пустым".to_string()); + } + + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + if let Some(id_str) = item_id.strip_prefix("folder_") { + if let Ok(id) = id_str.parse::() { + let old_path = resolve_folder_full_path(&client, user_email, Some(id)).await; + + client + .execute( + "UPDATE server_folders SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_email = $3;", + &[&clean_name, &id, &user_email], + ) + .await + .map_err(|e| format!("Ошибка переименования папки: {}", e))?; + + let new_path = resolve_folder_full_path(&client, user_email, Some(id)).await; + fs_rename_path(&client, &old_path, &new_path).await; + + return Ok(true); + } + } else if let Some(id_str) = item_id.strip_prefix("file_") { + if let Ok(id) = id_str.parse::() { + if let Ok(Some(row)) = client + .query_opt( + "SELECT file_path, folder_id FROM server_files WHERE id = $1 AND user_email = $2;", + &[&id, &user_email], + ) + .await + { + let old_path: Option = row.try_get(0).ok(); + let f_id: Option = row.try_get(1).ok(); + let dir_path = resolve_folder_full_path(&client, user_email, f_id).await; + let new_path = format!("{}/{}", dir_path, clean_name); + + client + .execute( + "UPDATE server_files SET name = $1, file_path = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND user_email = $4;", + &[&clean_name, &new_path, &id, &user_email], + ) + .await + .map_err(|e| format!("Ошибка переименования файла: {}", e))?; + + if let Some(old) = old_path { + fs_rename_path(&client, &old, &new_path).await; + } + + return Ok(true); + } + } + } + + Err("Неверный ID элемента".to_string()) +} + +pub async fn upload_server_file( + user_email: &str, + file_name: &str, + size_bytes: i64, + mime_type: Option, + content_base64: Option, + folder_id: Option, +) -> Result { + let clean_name = file_name.trim(); + if clean_name.is_empty() { + return Err("Имя файла не может быть пустым".to_string()); + } + + let _ = init_storage_tables().await; + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let binary_content: Option> = match content_base64 { + Some(b64) => { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .ok() + } + None => None, + }; + + let row = client + .query_one( + "INSERT INTO server_files (user_email, name, size_bytes, mime_type, content, folder_id) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING id, created_at;", + &[ + &user_email, + &clean_name, + &size_bytes, + &mime_type, + &binary_content, + &folder_id, + ], + ) + .await + .map_err(|e| format!("Не удалось сохранить файл на сервере: {}", e))?; + + let file_id: i64 = row.get(0); + let created_at: Option> = row.try_get(1).ok(); + let date_str = created_at + .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string()); + + // Save file to physical Linux filesystem on the server + let dir_path = resolve_folder_full_path(&client, user_email, folder_id).await; + let _ = fs_save_file(&client, file_id, &dir_path, clean_name).await; + + Ok(StorageItemDto { + id: format!("file_{}", file_id), + name: clean_name.to_string(), + size_formatted: format_bytes(size_bytes.max(0) as u64), + size_bytes, + item_type: "file".to_string(), + folder_id, + modified: date_str, + user_email: user_email.to_string(), + }) +} + +pub async fn create_server_file( + user_email: &str, + file_name: &str, + content: Option, + folder_id: Option, +) -> Result { + let clean_name = file_name.trim(); + if clean_name.is_empty() { + return Err("Имя файла не может быть пустым".to_string()); + } + + let _ = init_storage_tables().await; + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + // Check if file with same name already exists in this folder + let existing = match folder_id { + Some(fid) => { + client + .query_opt( + "SELECT id FROM server_files WHERE user_email = $1 AND folder_id = $2 AND name = $3;", + &[&user_email, &fid, &clean_name], + ) + .await + } + None => { + client + .query_opt( + "SELECT id FROM server_files WHERE user_email = $1 AND folder_id IS NULL AND name = $3;", + &[&user_email, &clean_name], + ) + .await + } + }; + + if let Ok(Some(_)) = existing { + return Err(format!("Файл с именем \"{}\" уже существует в этой папке", clean_name)); + } + + let text_content = content.unwrap_or_default(); + let bytes = text_content.into_bytes(); + let size_bytes = bytes.len() as i64; + + let mime_type = if clean_name.ends_with(".txt") { + "text/plain" + } else if clean_name.ends_with(".md") { + "text/markdown" + } else if clean_name.ends_with(".json") { + "application/json" + } else if clean_name.ends_with(".html") || clean_name.ends_with(".htm") { + "text/html" + } else if clean_name.ends_with(".css") { + "text/css" + } else if clean_name.ends_with(".js") || clean_name.ends_with(".ts") { + "text/javascript" + } else if clean_name.ends_with(".csv") { + "text/csv" + } else if clean_name.ends_with(".xml") { + "application/xml" + } else { + "text/plain" + }.to_string(); + + let row = client + .query_one( + "INSERT INTO server_files (user_email, name, size_bytes, mime_type, content, folder_id) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING id, created_at;", + &[ + &user_email, + &clean_name, + &size_bytes, + &Some(mime_type), + &Some(bytes), + &folder_id, + ], + ) + .await + .map_err(|e| format!("Не удалось создать файл на сервере: {}", e))?; + + let file_id: i64 = row.get(0); + let created_at: Option> = row.try_get(1).ok(); + let date_str = created_at + .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string()); + + // Save file to physical Linux filesystem on the server + let dir_path = resolve_folder_full_path(&client, user_email, folder_id).await; + let _ = fs_save_file(&client, file_id, &dir_path, clean_name).await; + + Ok(StorageItemDto { + id: format!("file_{}", file_id), + name: clean_name.to_string(), + size_formatted: format_bytes(size_bytes.max(0) as u64), + size_bytes, + item_type: "file".to_string(), + folder_id, + modified: date_str, + user_email: user_email.to_string(), + }) +} + +pub async fn save_server_file_content( + user_email: &str, + file_id: i64, + content: &str, +) -> Result { + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let bytes = content.as_bytes().to_vec(); + let size_bytes = bytes.len() as i64; + + let row = client + .query_opt( + "UPDATE server_files SET content = $1, size_bytes = $2, updated_at = CURRENT_TIMESTAMP \ + WHERE id = $3 AND user_email = $4 RETURNING name, folder_id;", + &[&Some(bytes), &size_bytes, &file_id, &user_email], + ) + .await + .map_err(|e| format!("Ошибка обновления файла: {}", e))?; + + if let Some(r) = row { + let name: String = r.get(0); + let folder_id: Option = r.try_get(1).ok(); + let dir_path = resolve_folder_full_path(&client, user_email, folder_id).await; + let _ = fs_save_file(&client, file_id, &dir_path, &name).await; + Ok(true) + } else { + Err("Файл не найден".to_string()) + } +} + +pub async fn download_server_file(user_email: &str, file_id: i64) -> Result { + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let row = client + .query_one( + "SELECT name, mime_type, size_bytes, content FROM server_files WHERE id = $1 AND user_email = $2;", + &[&file_id, &user_email], + ) + .await + .map_err(|e| format!("Файл не найден на сервере: {}", e))?; + + let name: String = row.get(0); + let mime_type: Option = row.get(1); + let size_bytes: i64 = row.get(2); + let content: Option> = row.get(3); + + let content_b64 = match content { + Some(bytes) => { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(&bytes) + } + None => String::new(), + }; + + Ok(ServerFileDownloadDto { + id: file_id, + name, + mime_type, + size_bytes, + content_base64: content_b64, + }) +} + +pub async fn update_user_heartbeat(user_email: &str) -> Result { + let clean = user_email.trim(); + if clean.is_empty() { + return Ok(false); + } + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let _ = client + .batch_execute(&format!( + "ALTER TABLE {} ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;", + config.users_table + )) + .await; + + let query = format!( + "UPDATE {} SET last_seen_at = CURRENT_TIMESTAMP WHERE LOWER({}) = LOWER($1);", + config.users_table, config.email_column + ); + client + .execute(&query, &[&clean]) + .await + .map_err(|e| format!("Ошибка обновления heartbeat: {}", e))?; + + Ok(true) +} + +pub async fn list_server_users() -> Result, String> { + let config = get_db_config(); + let client = connect_to_db(&config).await?; + + let _ = client + .batch_execute(&format!( + "ALTER TABLE {} ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;", + config.users_table + )) + .await; + + let query = format!( + "SELECT id, {}, username, created_at, role, \ + (last_seen_at IS NOT NULL AND last_seen_at > NOW() - INTERVAL '5 minutes') AS is_online, \ + TO_CHAR(last_seen_at, 'DD.MM.YYYY HH24:MI') AS last_seen_str \ + FROM {} ORDER BY id ASC;", + config.email_column, config.users_table + ); + + let rows = client + .query(&query, &[]) + .await + .map_err(|e| format!("Ошибка чтения таблицы пользователей: {}", e))?; + + let mut users = Vec::new(); + for row in rows { + let id: i32 = row.get(0); + let email: String = row.get(1); + let username: Option = row.get(2); + let created_at: Option = row.try_get(3).ok(); + let role_val: Option = row.try_get(4).ok().flatten(); + let is_online: bool = row.try_get(5).unwrap_or(false); + let last_seen_at: Option = row.try_get(6).ok().flatten(); + + let role_str = match role_val.as_deref() { + Some("admin") | Some("Администратор") => "Администратор".to_string(), + _ => if id == 1 { "Администратор".to_string() } else { "Пользователь".to_string() }, + }; + let created_str = created_at + .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + .unwrap_or_else(|| "Активен".to_string()); + + users.push(ServerUserDto { + id, + email, + username, + role: role_str, + created_at: created_str, + is_online, + last_seen_at, + }); + } + + Ok(users) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9bd1b2b --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "has", + "version": "0.0.1", + "identifier": "com.has.tauri-app", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "has", + "width": 920, + "height": 720, + "minWidth": 440, + "minHeight": 580, + "center": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "createUpdaterArtifacts": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "windows": { + "wix": { + "language": "ru-RU" + }, + "nsis": { + "languages": ["Russian"], + "installMode": "perMachine" + } + }, + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": "-", + "hardenedRuntime": false + }, + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0", + "librsvg2-common" + ], + "recommends": [ + "libayatana-appindicator3-1" + ] + }, + "rpm": { + "depends": [ + "webkit2gtk4.1", + "gtk3", + "librsvg2" + ], + "recommends": [ + "libayatana-appindicator-gtk3" + ] + } + } + }, + "plugins": { + "updater": { + "active": true, + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNEQUVFQTk4QkE5REVFNjMKUldSajdwMjZtT3F1UGFzWUFIcjNNcjRUdnlGKzBzUGxTZ3BOTGFWcGRwN2pCd1JJTnRXbTE2d3EK", + "endpoints": [ + "https://github.com/autli5/has/releases/latest/download/latest.json" + ], + "dialog": false, + "windows": { + "installMode": "passive" + } + } + } +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..81c4eb0 --- /dev/null +++ b/src/App.css @@ -0,0 +1,798 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); + +:root { + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-light: #eef2ff; + --text-main: #111827; + --text-muted: #6b7280; + --text-label: #1f2937; + --border-color: #e5e7eb; + --border-focus: #4f46e5; + --bg-page: #ffffff; + --card-bg: #ffffff; + --font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body { + font-family: var(--font-family); + background-color: var(--bg-page); + color: var(--text-main); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; +} + +/* Global SVG normalization */ +svg { + display: inline-block; + vertical-align: middle; + flex-shrink: 0; + box-sizing: content-box; +} + +/* Tailwind-compatible size utilities */ +.w-3\.5 { width: 14px !important; } +.h-3\.5 { height: 14px !important; } +.w-4 { width: 16px !important; } +.h-4 { height: 16px !important; } +.w-5 { width: 20px !important; } +.h-5 { height: 20px !important; } +.w-6 { width: 24px !important; } +.h-6 { height: 24px !important; } +.w-8 { width: 32px !important; } +.h-8 { height: 32px !important; } + +.app-viewport { + height: 100vh; + max-height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + background-color: #ffffff; +} + +/* Top bar with DB info badge */ +.top-bar { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 16px 28px 0 28px; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 20; +} + +.db-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s ease; + background: #f3f4f6; + color: #374151; + white-space: nowrap; +} + +.db-pill svg { + width: 16px !important; + height: 16px !important; + min-width: 16px !important; + max-width: 16px !important; + max-height: 16px !important; + flex-shrink: 0 !important; + display: block; +} + +.db-pill:hover { + filter: brightness(0.96); +} + +.db-pill.connected { + background: #ecfdf5; + color: #065f46; + border-color: #a7f3d0; +} + +.db-pill.error { + background: #fef2f2; + color: #991b1b; + border-color: #fecaca; +} + +.db-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #9ca3af; +} + +.db-pill.connected .db-dot { + background: #10b981; + box-shadow: 0 0 6px rgba(16, 185, 129, 0.6); +} + +.db-pill.error .db-dot { + background: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); +} + +/* ======================================================== + AUTO-UPDATER BANNER + ======================================================== */ + +.updater-banner { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 200; + background: #1e293b; + border-radius: 14px; + padding: 14px 18px; + max-width: 420px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + animation: slideInUp 0.3s ease; +} + +@keyframes slideInUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.updater-banner-content { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.updater-icon { + font-size: 22px; + flex-shrink: 0; + margin-top: 1px; +} + +.updater-text { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + min-width: 0; +} + +.updater-text strong { + font-size: 13.5px; + color: #f1f5f9; + font-weight: 700; +} + +.updater-notes { + font-size: 12px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.updater-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.updater-btn-install { + padding: 7px 14px; + background: #4f46e5; + color: #ffffff; + border: none; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} + +.updater-btn-install:hover { + background: #4338ca; +} + +.updater-btn-dismiss { + padding: 5px 14px; + background: transparent; + color: #64748b; + border: none; + font-size: 12px; + cursor: pointer; + text-align: center; + transition: color 0.15s; +} + +.updater-btn-dismiss:hover { + color: #94a3b8; +} + +.updater-progress-wrap { + display: flex; + align-items: center; + gap: 8px; + min-width: 140px; +} + +.updater-progress-bar { + flex: 1; + height: 6px; + background: #334155; + border-radius: 9999px; + overflow: hidden; +} + +.updater-progress-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1, #4f46e5); + border-radius: 9999px; + transition: width 0.2s ease; +} + +.updater-progress-label { + font-size: 12px; + color: #94a3b8; + min-width: 30px; + text-align: right; +} + +.updater-error { + margin-top: 8px; + font-size: 11.5px; + color: #f87171; + padding: 6px 8px; + background: rgba(239, 68, 68, 0.1); + border-radius: 6px; +} + +/* Center Content Area */ +.auth-main-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 60px 20px 40px 20px; +} + +.auth-card { + width: 100%; + max-width: 440px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +/* Brand header */ +.brand-header { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 24px; +} + +.brand-logo-img { + width: 44px; + height: 44px; + border-radius: 12px; + object-fit: contain; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.brand-title { + font-size: 21px; + font-weight: 800; + letter-spacing: -0.02em; + color: #111827; +} + +.auth-heading { + font-size: 32px; + font-weight: 800; + letter-spacing: -0.03em; + color: #111827; + margin-bottom: 8px; +} + +.auth-subheading { + font-size: 14px; + color: #6b7280; + margin-bottom: 30px; +} + +/* Forms */ +.auth-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 18px; + text-align: left; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-label { + font-size: 13px; + font-weight: 600; + color: var(--text-label); +} + +.input-container { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.input-icon-left { + position: absolute; + left: 16px; + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: #9ca3af; + pointer-events: none; +} + +.input-icon-left svg { + width: 18px !important; + height: 18px !important; + max-width: 18px !important; + max-height: 18px !important; + flex-shrink: 0 !important; +} + +.input-icon-right { + position: absolute; + right: 16px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: none; + border: none; + color: #9ca3af; + cursor: pointer; + padding: 0; + border-radius: 4px; + transition: color 0.15s ease; +} + +.input-icon-right svg { + width: 18px !important; + height: 18px !important; + max-width: 18px !important; + max-height: 18px !important; + flex-shrink: 0 !important; +} + +.input-icon-right:hover { + color: #4b5563; +} + +.form-input { + width: 100%; + height: 48px; + border: 1.5px solid var(--border-color); + border-radius: 9999px; + padding: 0 18px 0 44px; + font-family: inherit; + font-size: 14px; + color: var(--text-main); + background-color: #ffffff; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-input.has-right-icon { + padding-right: 46px; +} + +.form-input::placeholder { + color: #9ca3af; +} + +.form-input:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1); +} + +/* Remember me row */ +.form-options-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + margin-top: 4px; + font-size: 13px; +} + +.remember-label { + display: flex; + align-items: center; + gap: 8px; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + user-select: none; +} + +.custom-checkbox { + appearance: none; + -webkit-appearance: none; + width: 17px; + height: 17px; + border: 1.5px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + display: grid; + place-content: center; + transition: all 0.15s ease; + background: #ffffff; +} + +.custom-checkbox:checked { + background-color: var(--primary); + border-color: var(--primary); +} + +.custom-checkbox:checked::before { + content: ""; + width: 9px; + height: 5px; + border-left: 2px solid white; + border-bottom: 2px solid white; + transform: rotate(-45deg) translate(1px, -1px); +} + +.forgot-link { + color: var(--primary); + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: opacity 0.15s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.forgot-link:hover { + text-decoration: underline; +} + +/* Primary Button */ +.btn-primary { + width: 100%; + height: 48px; + background-color: var(--primary); + color: #ffffff; + border: none; + border-radius: 9999px; + font-family: inherit; + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + cursor: pointer; + transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; + box-shadow: 0 4px 12px rgba(79, 70, 229, 0.25); + margin-top: 6px; + overflow: hidden; +} + +.btn-primary svg { + width: 18px !important; + height: 18px !important; + min-width: 18px !important; + max-width: 18px !important; + max-height: 18px !important; + flex-shrink: 0 !important; + display: block; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-hover); + box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35); +} + +.btn-primary:active:not(:disabled) { + transform: scale(0.99); +} + +.btn-primary:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +/* Switch between sign-in / sign-up */ +.signup-hint { + font-size: 13.5px; + color: #4b5563; + margin-top: 4px; +} + +.signup-link { + color: var(--primary); + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.signup-link:hover { + text-decoration: underline; +} + +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--primary); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + margin-top: 16px; + text-decoration: none; + transition: opacity 0.15s ease; +} + +.back-link:hover { + text-decoration: underline; +} + +.code-input { + letter-spacing: 0.3em; + font-size: 18px; + font-weight: 700; + text-align: center; +} + +/* 6-Digit OTP Container matching mockup */ +.otp-container { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + width: 100%; + margin: 14px 0 22px 0; +} + +.otp-digit-input { + width: 50px; + height: 60px; + border: 1.5px solid #d1d5db; + border-radius: 18px; + text-align: center; + font-size: 26px; + font-weight: 700; + color: #334155; + background-color: #ffffff; + outline: none; + transition: all 0.15s ease; + font-family: inherit; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); +} + +.otp-digit-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.12); + transform: translateY(-1px); +} + +.otp-digit-input:hover:not(:focus) { + border-color: #9ca3af; +} + +.resend-countdown-text { + font-size: 13.5px; + color: #475569; + text-align: center; + margin-top: 14px; +} + +.resend-countdown-link { + color: var(--primary); + font-weight: 600; + cursor: pointer; + text-decoration: none; + margin-left: 4px; +} + +.resend-countdown-link:hover { + text-decoration: underline; +} + +.resend-countdown-timer { + color: var(--primary); + font-weight: 700; + margin-left: 4px; +} + +/* Alert Notification */ +.alert-box { + width: 100%; + padding: 12px 16px; + border-radius: 12px; + font-size: 13px; + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 6px; + text-align: left; +} + +.alert-error { + background-color: #fef2f2; + border: 1px solid #fee2e2; + color: #991b1b; +} + +.alert-success { + background-color: #ecfdf5; + border: 1px solid #d1fae5; + color: #065f46; +} + +/* Footer */ +.app-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 32px; + border-top: 1px solid #f3f4f6; + font-size: 12.5px; + color: #6b7280; +} + +.footer-links { + display: flex; + gap: 20px; +} + +.footer-links a, +.footer-links span { + color: #4f46e5; + text-decoration: none; + cursor: pointer; +} + +.footer-links a:hover, +.footer-links span:hover { + text-decoration: underline; +} + + +/* Modal styles for DB details / diagnostics */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: 20px; +} + +.modal-content { + background: #ffffff; + border-radius: 20px; + max-width: 500px; + width: 100%; + padding: 28px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + text-align: left; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.modal-title { + font-size: 18px; + font-weight: 700; + color: #111827; +} + +.modal-close { + background: none; + border: none; + font-size: 20px; + color: #9ca3af; + cursor: pointer; + padding: 4px; +} + +.modal-body { + font-size: 13.5px; + color: #374151; + line-height: 1.6; +} + +.code-block { + background: #1e293b; + color: #f8fafc; + padding: 12px; + border-radius: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + margin: 12px 0; + overflow-x: auto; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* Responsive adjustment for small screens */ +@media (max-width: 640px) { + .auth-main-container { + padding: 50px 16px 30px 16px; + } + .app-footer { + flex-direction: column; + gap: 10px; + text-align: center; + padding: 16px; + } + .info-grid { + grid-template-columns: 1fr; + } + .auth-heading { + font-size: 26px; + } +} + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ce119f7 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,870 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import logoUrl from "./assets/logo.png"; +import { + MailIcon, + LockIcon, + EyeIcon, + EyeOffIcon, + ArrowRightLoginIcon, + AlertCircleIcon, + CheckCircleIcon, + SpinnerIcon, + ChevronLeftIcon, + UserIcon, +} from "./components/Icons"; +import { AuthResponse, DbStatus, ResetRequestResponse, SafeDbInfo, UserDto } from "./types"; +import { OtpInput } from "./components/OtpInput"; +import { UpdaterBanner } from "./components/UpdaterBanner"; +import "./App.css"; +import Dashboard from "./pages/dashboard/Dashboard"; + +const isTauri = () => { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +}; + +export default function App() { + const [mode, setMode] = useState<"login" | "register" | "reset">("login"); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [rememberMe, setRememberMe] = useState(true); + + // Password reset state + const [resetStage, setResetStage] = useState<1 | 2 | 3>(1); + const [resetEmail, setResetEmail] = useState(""); + const [resetCode, setResetCode] = useState(""); + const [countdown, setCountdown] = useState(30); + const [newPassword, setNewPassword] = useState(""); + const [confirmNewPassword, setConfirmNewPassword] = useState(""); + const [showNewPassword, setShowNewPassword] = useState(false); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [currentUser, setCurrentUser] = useState(null); + const [dbStatus, setDbStatus] = useState(null); + const [dbInfo, setDbInfo] = useState(null); + + const [showForgotModal, setShowForgotModal] = useState(false); + + // Check saved session on mount + useEffect(() => { + const savedUser = localStorage.getItem("has_remembered_user"); + if (savedUser) { + try { + const parsed = JSON.parse(savedUser); + if (parsed?.email && parsed.email.toLowerCase() === "ne60201@gmail.com") { + parsed.role = "admin"; + } + setCurrentUser(parsed); + } catch { + localStorage.removeItem("has_remembered_user"); + } + } + + const savedEmail = localStorage.getItem("has_remembered_email"); + if (savedEmail) { + setEmail(savedEmail); + } + + // Check DB status on startup + checkDatabase(); + }, []); + + // Countdown timer for OTP re-send in resetStage 2 + useEffect(() => { + if (resetStage !== 2) return; + setCountdown(30); + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [resetStage]); + + const checkDatabase = async () => { + if (!isTauri()) { + setDbStatus({ + connected: false, + host: "localhost", + dbname: "postgres", + message: "Приложение запущено в браузере. Запустите через Tauri для подключения к Postgres.", + }); + return; + } + + try { + const info = await invoke("get_db_info"); + setDbInfo(info); + + const status = await invoke("check_db"); + setDbStatus(status); + } catch (err: any) { + setDbStatus({ + connected: false, + host: "unknown", + dbname: "unknown", + message: String(err?.message || err), + }); + } + }; + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!email.trim() || !password) { + setError("Пожалуйста, заполните email и пароль"); + return; + } + + if (!isTauri()) { + setError("Для реального подключения к PostgreSQL запустите десктоп-приложение ('npm run tauri dev')"); + return; + } + + setLoading(true); + try { + const res = await invoke("login", { + email: email.trim(), + password, + }); + + if (res.success && res.user) { + setCurrentUser(res.user); + if (rememberMe) { + localStorage.setItem("has_remembered_user", JSON.stringify(res.user)); + localStorage.setItem("has_remembered_email", email.trim()); + } else { + localStorage.removeItem("has_remembered_user"); + localStorage.removeItem("has_remembered_email"); + } + } else { + setError(res.message || "Ошибка входа. Проверьте данные."); + } + } catch (err: any) { + setError(`Ошибка: ${err?.message || err}`); + } finally { + setLoading(false); + } + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!email.trim() || !password) { + setError("Пожалуйста, укажите email и пароль"); + return; + } + + if (!username.trim()) { + setError("Пожалуйста, укажите имя пользователя"); + return; + } + + if (password !== confirmPassword) { + setError("Пароли не совпадают"); + return; + } + + if (!isTauri()) { + setError("Для регистрации пользователя в PostgreSQL запустите через Tauri ('npm run tauri dev')"); + return; + } + + setLoading(true); + try { + const res = await invoke("register", { + email: email.trim(), + password, + username: username.trim(), + }); + + if (res.success) { + setSuccess("Аккаунт успешно создан! Теперь вы можете войти."); + setMode("login"); + } else { + setError(res.message || "Ошибка при создании аккаунта."); + } + } catch (err: any) { + setError(`Ошибка: ${err?.message || err}`); + } finally { + setLoading(false); + } + }; + + const handleRequestResetCode = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!resetEmail.trim()) { + setError("Пожалуйста, введите адрес электронной почты"); + return; + } + + if (!isTauri()) { + setError("Для работы сброса пароля запустите десктоп-приложение ('npm run tauri dev')"); + return; + } + + setLoading(true); + try { + const res = await invoke("request_reset_code", { + email: resetEmail.trim(), + }); + + if (res.success) { + setResetStage(2); + setSuccess(res.message); + } else { + setError(res.message || "Ошибка отправки проверочного кода"); + } + } catch (err: any) { + setError(`Ошибка: ${err?.message || err}`); + } finally { + setLoading(false); + } + }; + + const handleVerifyResetCode = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!resetCode.trim()) { + setError("Пожалуйста, введите проверочный код"); + return; + } + + if (!isTauri()) { + setError("Для проверки кода запустите десктоп-приложение"); + return; + } + + setLoading(true); + try { + const res = await invoke("verify_reset_code", { + email: resetEmail.trim(), + code: resetCode.trim(), + }); + + if (res.success) { + setResetStage(3); + setSuccess("Код подтвержден! Теперь задайте новый пароль."); + } else { + setError(res.message || "Неверный код подтверждения"); + } + } catch (err: any) { + setError(`Ошибка: ${err?.message || err}`); + } finally { + setLoading(false); + } + }; + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(null); + + if (!newPassword) { + setError("Пожалуйста, введите новый пароль"); + return; + } + + if (newPassword !== confirmNewPassword) { + setError("Пароли не совпадают"); + return; + } + + if (!isTauri()) { + setError("Для смены пароля запустите десктоп-приложение"); + return; + } + + setLoading(true); + try { + const res = await invoke("reset_password", { + email: resetEmail.trim(), + code: resetCode.trim(), + newPassword, + }); + + if (res.success) { + setMode("login"); + setEmail(resetEmail.trim()); + setPassword(""); + setResetEmail(""); + setResetCode(""); + setNewPassword(""); + setConfirmNewPassword(""); + setResetStage(1); + setSuccess("Пароль успешно обновлен! Войдите с новым паролем."); + } else { + setError(res.message || "Не удалось изменить пароль"); + } + } catch (err: any) { + setError(`Ошибка: ${err?.message || err}`); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + setCurrentUser(null); + localStorage.removeItem("has_remembered_user"); + setPassword(""); + setConfirmPassword(""); + setSuccess(null); + setError(null); + }; + + if (currentUser) { + return ( + <> + + + + ); + } + + return ( +
+ {/* Auto-updater banner */} + + + {/* Main Content Area */} + {mode === "reset" ? ( + /* Password Reset Flow */ +
+
+ {/* Project Branding */} +
+ Home Autli Server + Home Autli Server +
+ + {/* Title & Subtitle */} +

+ {resetStage === 1 + ? "Сброс пароля" + : resetStage === 2 + ? "Сброс пароля" + : "Новый пароль"} +

+

+ {resetStage === 1 + ? "Забыли пароль? Введите email для получения проверочного кода." + : resetStage === 2 + ? "Введите 6-значный код подтверждения из письма." + : "Код подтвержден. Введите новый пароль для вашей учетной записи:"} +

+ + {/* Notification Messages */} + {error && ( +
+ + {error} +
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* Step 1: Request code by email */} + {resetStage === 1 && ( +
+
+ +
+
+ +
+ setResetEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + + +
+ { + setMode("login"); + setError(null); + setSuccess(null); + }} + > + Назад ко входу + +
+
+ )} + + {/* Step 2: Verify code (6 separate OTP boxes matching mockup) */} + {resetStage === 2 && ( +
+ setResetCode(code)} + disabled={loading} + /> + + + +
+ Не получили код?{" "} + {countdown > 0 ? ( + + Отправить повторно через {countdown}с + + ) : ( + { + handleRequestResetCode(e); + setCountdown(30); + }} + > + Отправить код повторно + + )} +
+ +
+ { + setMode("login"); + setError(null); + setSuccess(null); + }} + > + Назад ко входу + +
+ + )} + + {/* Step 3: Enter new password and confirmation */} + {resetStage === 3 && ( +
+
+ +
+
+ +
+ setNewPassword(e.target.value)} + required + /> + +
+
+ +
+ +
+
+ +
+ setConfirmNewPassword(e.target.value)} + required + /> + +
+
+ + + +
+ { + setMode("login"); + setError(null); + setSuccess(null); + }} + > + Назад ко входу + +
+
+ )} +
+
+ ) : ( + /* Login / Register Card with actual logo & branding */ +
+
+ {/* Actual Project Logo and Name */} +
+ Home Autli Server + Home Autli Server +
+ + {/* Title & Subtitle */} +

+ {mode === "login" ? "Вход в аккаунт" : "Создать аккаунт"} +

+

+ {mode === "login" + ? "Авторизация в системе Home Autli Server" + : "Регистрация нового пользователя на сервере"} +

+ + {/* Notification Messages */} + {error && ( +
+ + {error} +
+ )} + {success && ( +
+ + {success} +
+ )} + + {/* Form */} +
+ {/* Username (Register mode only) */} + {mode === "register" && ( +
+ +
+
+ +
+ setUsername(e.target.value)} + required + autoComplete="name" + /> +
+
+ )} + + {/* Email Address */} +
+ +
+
+ +
+ setEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + {/* Password */} +
+ +
+
+ +
+ setPassword(e.target.value)} + required + autoComplete={mode === "login" ? "current-password" : "new-password"} + /> + +
+
+ + {/* Confirm Password (Register mode) */} + {mode === "register" && ( +
+ +
+
+ +
+ setConfirmPassword(e.target.value)} + required + /> +
+
+ )} + + {/* Remember Me & Forgot Password (Login mode) */} + {mode === "login" && ( +
+ + + { + setMode("reset"); + setResetStage(1); + setResetEmail(email.trim()); + setError(null); + setSuccess(null); + }} + > + Забыли пароль? + +
+ )} + + {/* Submit Button */} + + + {/* Switch Mode Link */} +
+ + {mode === "login" ? ( + <> + Нет аккаунта?{" "} + { + setMode("register"); + setUsername(""); + setError(null); + setSuccess(null); + }} + > + Зарегистрироваться + + + ) : ( + <> + Уже есть аккаунт?{" "} + { + setMode("login"); + setUsername(""); + setError(null); + setSuccess(null); + }} + > + Войти + + + )} + +
+
+
+
+ )} + + {/* Footer */} +
+
Copyright © 2026 Home Autli Server
+ {/*
+ Политика конфиденциальности + Условия использования +
*/} +
+ + {/* Forgot Password Modal */} + {showForgotModal && ( +
setShowForgotModal(false)}> +
e.stopPropagation()}> +
+

Восстановление доступа

+ +
+
+

+ Для смены или сброса пароля обратитесь к системному администратору + сервера либо воспользуйтесь функцией создания новой учетной записи. +

+
+ +
+
+
+
+ )} +
+ ); +} diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..1e8ec03 Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx new file mode 100644 index 0000000..8898544 --- /dev/null +++ b/src/components/Icons.tsx @@ -0,0 +1,593 @@ +export interface IconProps { + className?: string; + width?: number | string; + height?: number | string; +} + +export function MailIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function LockIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function EyeIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + ); +} + +export function EyeOffIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function ArrowRightLoginIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function DatabaseIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + + ); +} + +export function AlertCircleIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + + ); +} + +export function CheckCircleIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function SpinnerIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + ); +} + +export function ChevronLeftIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function KeyIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function HomeNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function TasksNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function UsersNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function ApiNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function CreditCardIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + + ); +} + +export function CinemaNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + ); +} + +export function SettingsNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + + ); +} + +export function HelpNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function LogOutIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function SlidersIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function UploadIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function SearchIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function ChevronRightIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function FolderIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function SortArrowsIcon({ className = "w-3.5 h-3.5", width = 14, height = 14 }: IconProps) { + return ( + + + + ); +} + +export function FolderPlusIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function UserIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function StorageNavIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function FileIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function FilePlusIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function DownloadIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function TrashIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function RefreshIcon({ className = "w-5 h-5", width = 20, height = 20 }: IconProps) { + return ( + + + + ); +} + +export function TagIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function SparklesIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function BoldIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function ItalicIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function UnderlineIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function StrikethroughIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function AlignLeftIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function AlignCenterIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function AlignRightIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function ListIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function NumberedListIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function TaskCheckIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + + ); +} + +export function CodeIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function QuoteIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function TableIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + + ); +} + +export function ImageIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + + + ); +} + +export function PencilIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function ArrowLeftIcon({ className = "w-4 h-4", width = 16, height = 16 }: IconProps) { + return ( + + + + ); +} + +export function MenuIcon({ className = "w-6 h-6", width = 24, height = 24 }: IconProps) { + return ( + + + + ); +} + +export function CloseIcon({ className = "w-6 h-6", width = 24, height = 24 }: IconProps) { + return ( + + + + ); +} + + diff --git a/src/components/OtpInput.tsx b/src/components/OtpInput.tsx new file mode 100644 index 0000000..3a737aa --- /dev/null +++ b/src/components/OtpInput.tsx @@ -0,0 +1,109 @@ +import React, { useRef, useEffect } from "react"; + +interface OtpInputProps { + value: string; + onChange: (code: string) => void; + length?: number; + disabled?: boolean; +} + +export function OtpInput({ + value, + onChange, + length = 6, + disabled = false, +}: OtpInputProps) { + const inputsRef = useRef<(HTMLInputElement | null)[]>([]); + + // Array of digits according to desired length + const digits = Array.from({ length }, (_, i) => value[i] || ""); + + useEffect(() => { + // Focus the first empty input or the first one on mount + const firstEmptyIndex = digits.findIndex((d) => !d); + const targetIndex = firstEmptyIndex === -1 ? 0 : firstEmptyIndex; + inputsRef.current[targetIndex]?.focus(); + }, []); + + const handleChange = ( + e: React.ChangeEvent, + index: number + ) => { + const rawVal = e.target.value.replace(/\D/g, ""); + if (!rawVal) { + // Cleared current digit + const newDigits = [...digits]; + newDigits[index] = ""; + onChange(newDigits.join("")); + return; + } + + const digit = rawVal.slice(-1); + const newDigits = [...digits]; + newDigits[index] = digit; + const combined = newDigits.join(""); + onChange(combined); + + // Auto advance focus to the next input + if (index < length - 1) { + inputsRef.current[index + 1]?.focus(); + inputsRef.current[index + 1]?.select(); + } + }; + + const handleKeyDown = ( + e: React.KeyboardEvent, + index: number + ) => { + if (e.key === "Backspace") { + if (!digits[index] && index > 0) { + // Current box is empty, jump to previous and clear it + const newDigits = [...digits]; + newDigits[index - 1] = ""; + onChange(newDigits.join("")); + inputsRef.current[index - 1]?.focus(); + } + } else if (e.key === "ArrowLeft" && index > 0) { + inputsRef.current[index - 1]?.focus(); + } else if (e.key === "ArrowRight" && index < length - 1) { + inputsRef.current[index + 1]?.focus(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pasted = e.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, length); + if (!pasted) return; + + onChange(pasted); + const nextIndex = Math.min(pasted.length, length - 1); + inputsRef.current[nextIndex]?.focus(); + }; + + return ( +
+ {digits.map((digit, index) => ( + { + inputsRef.current[index] = el; + }} + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={1} + value={digit} + disabled={disabled} + onChange={(e) => handleChange(e, index)} + onKeyDown={(e) => handleKeyDown(e, index)} + onPaste={handlePaste} + className="otp-digit-input" + autoComplete="one-time-code" + /> + ))} +
+ ); +} diff --git a/src/components/UpdaterBanner.tsx b/src/components/UpdaterBanner.tsx new file mode 100644 index 0000000..f964edb --- /dev/null +++ b/src/components/UpdaterBanner.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect } from "react"; + +interface UpdateInfo { + version: string; + body: string | null; +} + +export function UpdaterBanner() { + const [updateAvailable, setUpdateAvailable] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); + const [downloading, setDownloading] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) { + return; + } + + let cancelled = false; + + const checkForUpdates = async () => { + try { + const { check } = await import("@tauri-apps/plugin-updater"); + const update = await check(); + if (!cancelled && update?.available) { + setUpdateAvailable(true); + setUpdateInfo({ + version: update.version, + body: update.body ?? null, + }); + setDismissed(false); + } + } catch (err) { + console.warn("[Updater] Check failed:", err); + } + }; + + // 1. Initial check after startup + const timer = setTimeout(checkForUpdates, 800); + + // 2. Periodic check every 60 seconds while app is running + const interval = setInterval(checkForUpdates, 60 * 1000); + + // 3. Check whenever window gains focus + const handleFocus = () => checkForUpdates(); + window.addEventListener("focus", handleFocus); + + // 4. Custom event for manual checks from UI + window.addEventListener("has-check-update", handleFocus); + + return () => { + cancelled = true; + clearTimeout(timer); + clearInterval(interval); + window.removeEventListener("focus", handleFocus); + window.removeEventListener("has-check-update", handleFocus); + }; + }, []); + + const handleInstall = async () => { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) return; + + setDownloading(true); + setError(null); + setProgress(0); + + try { + const { check } = await import("@tauri-apps/plugin-updater"); + const { relaunch } = await import("@tauri-apps/plugin-process"); + + const update = await check(); + if (!update?.available) return; + + let downloaded = 0; + let total = 0; + + await update.downloadAndInstall((event: any) => { + if (event.event === "Started") { + total = event.data?.contentLength ?? 0; + } else if (event.event === "Progress") { + downloaded += event.data?.chunkLength ?? 0; + if (total > 0) { + setProgress(Math.round((downloaded / total) * 100)); + } + } else if (event.event === "Finished") { + setProgress(100); + } + }); + + await relaunch(); + } catch (e: unknown) { + setError(`Ошибка обновления: ${e instanceof Error ? e.message : String(e)}`); + setDownloading(false); + } + }; + + if (!updateAvailable || dismissed) return null; + + return ( +
+
+
🚀
+
+ Доступно обновление v{updateInfo?.version} + {updateInfo?.body && ( + {updateInfo.body} + )} +
+
+ {downloading ? ( +
+
+
+
+ {progress}% +
+ ) : ( + <> + + + + )} +
+
+ {error &&
{error}
} +
+ ); +} diff --git a/src/globals.css b/src/globals.css new file mode 100644 index 0000000..23d8732 --- /dev/null +++ b/src/globals.css @@ -0,0 +1,5 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..9a80b77 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./globals.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/src/pages/apis/Apis.css b/src/pages/apis/Apis.css new file mode 100644 index 0000000..df1968b --- /dev/null +++ b/src/pages/apis/Apis.css @@ -0,0 +1,13 @@ +/* ======================================================== + APIS PAGE STYLES + ======================================================== */ + +.has-apis-hint { + color: #64748b; + font-size: 14px; + margin-bottom: 16px; +} + +.has-mono-val { + font-family: monospace; +} diff --git a/src/pages/apis/Apis.tsx b/src/pages/apis/Apis.tsx new file mode 100644 index 0000000..de7f2ab --- /dev/null +++ b/src/pages/apis/Apis.tsx @@ -0,0 +1,52 @@ +import type { SafeDbInfo } from "../../types"; +import "./Apis.css"; + +interface ApisProps { + dbInfo?: SafeDbInfo | null; + isUserAdmin?: boolean; +} + +export default function Apis({ isUserAdmin = true }: ApisProps) { + if (!isUserAdmin) { + return ( +
+
+

Доступ ограничен

+

+ Просмотр системных API и конфигурации подключений доступен только администраторам. +

+
+
+ ); + } + return ( +
+
+

API и подключения

+

+ Эндпоинты для интеграции внешних сервисов с сервером Home Autli: +

+
+
+ Эндпоинт базы данных: + + Внутренний шлюз (порт изолирован фаерволом) + +
+
+ Служба SMTP: + + smtp.mail.ru:465 (SSL/TLS) + +
+
+ URL автообновлений: + + https://github.com/autli5/has/releases/latest/download/latest.json + +
+
+
+
+ ); +} diff --git a/src/pages/apis/index.ts b/src/pages/apis/index.ts new file mode 100644 index 0000000..ed5c975 --- /dev/null +++ b/src/pages/apis/index.ts @@ -0,0 +1 @@ +export { default } from "./Apis"; diff --git a/src/pages/cinema/Cinema.css b/src/pages/cinema/Cinema.css new file mode 100644 index 0000000..2f1fa6a --- /dev/null +++ b/src/pages/cinema/Cinema.css @@ -0,0 +1,2681 @@ +/* Cinema Container (Dark Cinema Theme matching Mockup) */ +.has-cinema-container { + position: relative; + width: 100%; + min-height: 100%; + background: #090a0f; + color: #f8fafc; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 25px rgba(0, 0, 0, 0.4); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + box-sizing: border-box; +} + +/* ======================================================== + 1. TOP NAVIGATION HEADER (Mockup Replica) + ======================================================== */ +.has-cinema-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 36px; + z-index: 20; + background: linear-gradient(180deg, rgba(9, 10, 15, 0.85) 0%, rgba(9, 10, 15, 0) 100%); +} + +.has-cinema-nav-links { + display: flex; + align-items: center; + gap: 28px; +} + +.has-cinema-nav-item { + background: transparent; + border: none; + color: #94a3b8; + font-size: 14.5px; + font-weight: 600; + cursor: pointer; + padding: 6px 0; + transition: color 0.15s ease; +} + +.has-cinema-nav-item:hover { + color: #ffffff; +} + +.has-cinema-nav-item.active { + color: #ffffff; + font-weight: 700; +} + +.has-cinema-header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.has-cinema-search-box { + position: relative; + display: flex; + align-items: center; +} + +.has-cinema-search-box svg { + position: absolute; + left: 12px; + color: #94a3b8; + pointer-events: none; +} + +.has-cinema-search-input { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 9999px; + padding: 7px 16px 7px 34px; + color: #ffffff; + font-size: 13px; + width: 220px; + outline: none; + transition: all 0.2s ease; +} + +.has-cinema-search-input:focus { + background: rgba(255, 255, 255, 0.14); + border-color: #6366f1; + width: 280px; +} + +.has-cinema-user-avatar { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1.5px solid rgba(255, 255, 255, 0.3); + overflow: hidden; + background: #1e1b4b; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.4); +} + +.has-cinema-user-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.has-cinema-user-avatar-text { + font-size: 12px; + font-weight: 700; + color: #a5b4fc; +} + +/* ======================================================== + 2. HERO BANNER WITH ART & SYNOPSIS + ======================================================== */ +.has-cinema-hero { + position: relative; + width: 100%; + min-height: 480px; + display: flex; + align-items: flex-end; + padding: 100px 48px 48px 48px; + box-sizing: border-box; +} + +.has-cinema-hero-bg-wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + z-index: 1; +} + +.has-cinema-hero-bg-img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: top right; + filter: brightness(0.9); +} + +/* Dark Gradients to match the screenshot */ +.has-cinema-hero-gradient-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + rgba(9, 10, 15, 0.95) 0%, + rgba(9, 10, 15, 0.8) 45%, + rgba(9, 10, 15, 0.2) 80%, + rgba(9, 10, 15, 0.05) 100% + ); +} + +.has-cinema-hero-bottom-fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 160px; + background: linear-gradient(180deg, rgba(9, 10, 15, 0) 0%, #090a0f 100%); +} + +.has-cinema-hero-content { + position: relative; + z-index: 5; + max-width: 580px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Badge: slothui original */ +.has-cinema-original-badge { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(30, 27, 75, 0.8); + border: 1px solid rgba(129, 140, 248, 0.3); + backdrop-filter: blur(8px); + padding: 3px 12px 3px 6px; + border-radius: 9999px; + width: fit-content; +} + +.has-cinema-original-badge-icon { + width: 20px; + height: 20px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; +} + +.has-cinema-original-badge-text { + font-size: 12.5px; + color: #e2e8f0; +} + +/* Stylized Title (Gothic/Fraktur look) */ +.has-cinema-hero-title { + font-size: 48px; + font-weight: 900; + color: #ffffff; + letter-spacing: -0.01em; + margin: 0; + line-height: 1.1; + text-shadow: 0 4px 16px rgba(0, 0, 0, 0.8); + font-family: "Cinzel", "Palatino Linotype", "Book Antiqua", Palatino, serif; +} + +/* Metadata Row */ +.has-cinema-meta-row { + display: flex; + align-items: center; + gap: 14px; + font-size: 13.5px; + color: #cbd5e1; + font-weight: 600; +} + +.has-cinema-meta-genre-pill { + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 2px 10px; + border-radius: 9999px; + font-size: 12px; + color: #f1f5f9; +} + +.has-cinema-meta-stars { + display: flex; + align-items: center; + gap: 6px; +} + +.has-cinema-stars-gold { + color: #f59e0b; + font-size: 14px; + letter-spacing: 2px; +} + +.has-cinema-rating-num { + font-size: 13px; + font-weight: 700; + color: #ffffff; +} + +/* Synopsis */ +.has-cinema-hero-synopsis { + font-size: 14px; + line-height: 1.6; + color: #cbd5e1; + margin: 0; + max-width: 520px; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); +} + +/* Action Buttons */ +.has-cinema-hero-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; +} + +.has-cinema-btn-play { + display: flex; + align-items: center; + gap: 8px; + background: #ffffff; + color: #090a0f; + border: none; + border-radius: 9999px; + padding: 10px 24px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 4px 14px rgba(255, 255, 255, 0.2); +} + +.has-cinema-btn-play:hover { + background: #f1f5f9; + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(255, 255, 255, 0.3); +} + +.has-cinema-btn-circle { + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-btn-circle:hover { + background: rgba(255, 255, 255, 0.24); + transform: scale(1.05); +} + +.has-cinema-btn-circle.bookmarked { + background: #4f46e5; + border-color: #6366f1; + color: #ffffff; +} + +/* ======================================================== + 3. SEASONS SECTION & CAROUSEL + ======================================================== */ +.has-cinema-seasons-section { + padding: 0 48px 48px 48px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.has-cinema-season-tabs { + display: flex; + align-items: center; + gap: 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 2px; +} + +.has-cinema-season-tab { + background: transparent; + border: none; + color: #94a3b8; + font-size: 15px; + font-weight: 600; + cursor: pointer; + padding: 8px 4px; + position: relative; + transition: color 0.15s ease; +} + +.has-cinema-season-tab:hover { + color: #ffffff; +} + +.has-cinema-season-tab.active { + color: #ffffff; + font-weight: 700; +} + +.has-cinema-season-tab.active::after { + content: ""; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2.5px; + background: #ffffff; + border-radius: 2px; +} + +/* Horizontal Episodes Carousel */ +.has-cinema-episodes-carousel { + display: flex; + align-items: stretch; + gap: 20px; + overflow-x: auto; + padding-bottom: 14px; + scroll-behavior: smooth; +} + +.has-cinema-episodes-carousel::-webkit-scrollbar { + height: 6px; +} + +.has-cinema-episodes-carousel::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 9999px; +} + +/* Episode Card */ +.has-cinema-episode-card { + flex: 0 0 240px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + overflow: hidden; + cursor: pointer; + display: flex; + flex-direction: column; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-episode-card:hover { + transform: translateY(-4px); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.5); +} + +.has-cinema-ep-thumb-box { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + background: #111420; +} + +.has-cinema-ep-thumb { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.25s ease; +} + +.has-cinema-episode-card:hover .has-cinema-ep-thumb { + transform: scale(1.04); +} + +.has-cinema-ep-card-badge { + position: absolute; + top: 8px; + left: 8px; + width: 20px; + height: 20px; + border-radius: 5px; + background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%); + color: #ffffff; + font-size: 11px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); +} + +.has-cinema-ep-hover-play { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; +} + +.has-cinema-episode-card:hover .has-cinema-ep-hover-play { + opacity: 1; +} + +.has-cinema-ep-info { + padding: 12px 14px; + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; +} + +.has-cinema-ep-title { + font-size: 13.5px; + font-weight: 700; + color: #f8fafc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.has-cinema-ep-duration { + font-size: 11.5px; + color: #94a3b8; + font-weight: 600; +} + +/* Watched Progress Bar */ +.has-cinema-ep-progress-bar { + width: 100%; + height: 3.5px; + background: rgba(255, 255, 255, 0.12); + position: relative; +} + +.has-cinema-ep-progress-fill { + height: 100%; + background: linear-gradient(90deg, #6366f1 0%, #ec4899 100%); + transition: width 0.3s ease; +} + +/* ======================================================== + 4. VIDEO PLAYER MODAL + ======================================================== */ +.has-cinema-player-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #000000; + z-index: 9999; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + overflow: hidden; + user-select: none; + animation: fadeIn 0.2s ease-out; +} + +/* Top Edge Hover Sensor: Catches mouse movement in the top 90px above iframe */ +.has-cinema-player-top-sensor { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 90px; + z-index: 90; + pointer-events: auto; + background: transparent; +} + +/* Floating Top HUD Bar */ +.has-cinema-player-top-hud { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: space-between; + padding: calc(env(safe-area-inset-top, 0px) + 12px) 24px 28px 24px; + background: linear-gradient(180deg, rgba(6, 9, 18, 0.94) 0%, rgba(6, 9, 18, 0.65) 60%, transparent 100%); + pointer-events: none; + transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), background 0.35s ease; + transform: translateY(0); + opacity: 1; +} + +.has-cinema-player-hud-left, +.has-cinema-player-hud-right, +.has-cinema-player-btn-back, +.has-cinema-player-hud-btn, +.has-cinema-player-pill-nav { + pointer-events: auto !important; +} + +/* When HUD is idle: Title and extra buttons softly fade, but "← Назад" stays accessible */ +.has-cinema-player-top-hud.hud-hidden { + background: transparent; + pointer-events: none; +} + +.has-cinema-player-top-hud.hud-hidden .has-cinema-player-title-box, +.has-cinema-player-top-hud.hud-hidden .has-cinema-player-quick-ep-nav, +.has-cinema-player-top-hud.hud-hidden .has-cinema-player-hud-right { + opacity: 0; + visibility: hidden; + transform: translateY(-12px); + pointer-events: none; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-player-top-hud.hud-hidden .has-cinema-player-btn-back { + opacity: 0.85; + background: rgba(10, 15, 28, 0.92); + border-color: rgba(255, 255, 255, 0.35); + pointer-events: auto !important; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8); +} + +.has-cinema-player-top-hud.hud-hidden .has-cinema-player-btn-back:hover { + opacity: 1; + background: rgba(10, 15, 28, 0.98); + border-color: #6366f1; + transform: scale(1.05); + box-shadow: 0 0 20px rgba(99, 102, 241, 0.6); +} + +/* Hovering either top sensor or HUD fully restores the entire top bar */ +.has-cinema-player-top-sensor:hover ~ .has-cinema-player-top-hud, +.has-cinema-player-top-hud:hover { + background: linear-gradient(180deg, rgba(6, 9, 18, 0.94) 0%, rgba(6, 9, 18, 0.65) 60%, transparent 100%) !important; + pointer-events: auto !important; + opacity: 1 !important; + transform: translateY(0) !important; +} + +.has-cinema-player-top-sensor:hover ~ .has-cinema-player-top-hud .has-cinema-player-title-box, +.has-cinema-player-top-sensor:hover ~ .has-cinema-player-top-hud .has-cinema-player-quick-ep-nav, +.has-cinema-player-top-sensor:hover ~ .has-cinema-player-top-hud .has-cinema-player-hud-right, +.has-cinema-player-top-hud:hover .has-cinema-player-title-box, +.has-cinema-player-top-hud:hover .has-cinema-player-quick-ep-nav, +.has-cinema-player-top-hud:hover .has-cinema-player-hud-right { + opacity: 1 !important; + visibility: visible !important; + transform: translateY(0) !important; + pointer-events: auto !important; +} + +.has-cinema-player-top-sensor:hover ~ .has-cinema-player-top-hud .has-cinema-player-btn-back, +.has-cinema-player-top-hud:hover .has-cinema-player-btn-back { + opacity: 1 !important; + background: rgba(255, 255, 255, 0.1) !important; + border-color: rgba(255, 255, 255, 0.18) !important; +} + +.has-cinema-player-hud-left { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +.has-cinema-player-btn-back { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #ffffff; + padding: 8px 16px; + border-radius: 9999px; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + backdrop-filter: blur(8px); + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + flex-shrink: 0; +} + +.has-cinema-player-btn-back:hover { + background: rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.35); + transform: scale(1.04); +} + +.has-cinema-player-title-box { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.has-cinema-player-hud-title { + font-size: 16.5px; + font-weight: 800; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); +} + +.has-cinema-player-hud-subtitle { + font-size: 12.5px; + font-weight: 600; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); +} + +.has-cinema-player-quick-ep-nav { + display: flex; + align-items: center; + gap: 6px; + margin-left: 8px; + flex-shrink: 0; +} + +.has-cinema-player-pill-nav { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #cbd5e1; + padding: 6px 12px; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + backdrop-filter: blur(6px); +} + +.has-cinema-player-pill-nav:hover:not(:disabled) { + background: rgba(99, 102, 241, 0.3); + border-color: #6366f1; + color: #ffffff; +} + +.has-cinema-player-pill-nav:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.has-cinema-player-hud-right { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.has-cinema-player-hud-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #ffffff; + padding: 8px 16px; + border-radius: 9999px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + backdrop-filter: blur(8px); + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-player-hud-btn:hover { + background: rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.35); + transform: translateY(-1px); +} + +.has-cinema-player-hud-btn.active { + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + border-color: #818cf8; + color: #ffffff; + box-shadow: 0 0 16px rgba(99, 102, 241, 0.6); +} + +.has-cinema-player-hud-btn.icon-only { + width: 38px; + height: 38px; + padding: 0; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 15px; +} + +.has-cinema-player-hud-btn.icon-only.close:hover { + background: rgba(239, 68, 68, 0.3); + border-color: #ef4444; + color: #ffffff; +} + +/* 100% Immersive Video Frame */ +.has-cinema-video-frame { + width: 100vw; + height: 100vh; + flex: 1; + background: #000000; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.has-cinema-video-elem { + width: 100%; + height: 100%; + object-fit: contain; + outline: none; +} + +.has-cinema-iframe-elem { + width: 100%; + height: 100%; + border: none; + background: #000000; + display: block; +} + +/* ======================================================== + PLAYER DRAWERS / SLIDE-IN SHEETS + ======================================================== */ +.has-cinema-player-drawer-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(10px); + z-index: 10000; + display: flex; + justify-content: flex-end; + animation: fadeIn 0.2s ease-out; +} + +@keyframes slideDrawerIn { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.has-cinema-player-settings-sheet, +.has-cinema-player-episodes-sheet { + width: min(540px, 94vw); + height: 100%; + background: #0c101c; + border-left: 1px solid rgba(255, 255, 255, 0.12); + display: flex; + flex-direction: column; + padding: 28px 30px; + box-shadow: -12px 0 40px rgba(0, 0, 0, 0.85); + animation: slideDrawerIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); + overflow-y: auto; + box-sizing: border-box; +} + +.has-cinema-drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.has-cinema-drawer-title { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 18px; + font-weight: 800; + color: #ffffff; +} + +.has-cinema-drawer-subtitle { + font-size: 13px; + font-weight: 500; + color: #94a3b8; +} + +.has-cinema-drawer-close-btn { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + color: #cbd5e1; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.has-cinema-drawer-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: #ffffff; + transform: scale(1.08); +} + +.has-cinema-drawer-body { + display: flex; + flex-direction: column; + gap: 26px; + padding-top: 22px; + flex: 1; +} + +.has-cinema-drawer-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.has-cinema-drawer-section-title { + margin: 0; + font-size: 13px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #818cf8; +} + +.has-cinema-drawer-notice-box { + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(16, 185, 129, 0.3); + color: #a7f3d0; + padding: 8px 12px; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + line-height: 1.4; +} + +.has-cinema-drawer-searching { + background: rgba(99, 102, 241, 0.12); + border: 1px solid rgba(99, 102, 241, 0.3); + color: #c7d2fe; + padding: 8px 12px; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; +} + +.has-cinema-drawer-options-grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.has-cinema-drawer-option-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-drawer-option-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(99, 102, 241, 0.5); + transform: translateY(-1px); +} + +.has-cinema-drawer-option-btn.active { + background: rgba(99, 102, 241, 0.18); + border-color: #6366f1; + box-shadow: 0 0 16px rgba(99, 102, 241, 0.35); +} + +.has-cinema-drawer-option-btn .option-name { + font-size: 14px; + font-weight: 700; + color: #ffffff; +} + +.has-cinema-drawer-option-btn .option-desc { + font-size: 12px; + font-weight: 500; + color: #94a3b8; + line-height: 1.4; +} + +.has-cinema-drawer-subgroup { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; + padding-top: 14px; + border-top: 1px dashed rgba(255, 255, 255, 0.1); +} + +.has-cinema-drawer-sublabel { + font-size: 12.5px; + font-weight: 700; + color: #cbd5e1; +} + +.has-cinema-drawer-chips-wrap { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.has-cinema-drawer-chip { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #cbd5e1; + padding: 7px 14px; + border-radius: 8px; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-drawer-chip:hover { + background: rgba(99, 102, 241, 0.25); + border-color: #6366f1; + color: #ffffff; + transform: translateY(-1px); +} + +.has-cinema-drawer-chip.active { + background: #4f46e5; + border-color: #818cf8; + color: #ffffff; + font-weight: 700; + box-shadow: 0 2px 10px rgba(79, 70, 229, 0.45); +} + +.has-cinema-drawer-section.actions { + margin-top: auto; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + gap: 12px; +} + +.has-cinema-drawer-btn-secondary { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #cbd5e1; + padding: 12px 18px; + border-radius: 10px; + font-size: 13.5px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-drawer-btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.3); +} + +.has-cinema-drawer-btn-primary { + flex: 1; + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + border: 1px solid #818cf8; + color: #ffffff; + padding: 12px 18px; + border-radius: 10px; + font-size: 13.5px; + font-weight: 800; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 4px 16px rgba(79, 70, 229, 0.4); +} + +.has-cinema-drawer-btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(79, 70, 229, 0.6); +} + +/* Episodes Grid Drawer (Series only) */ +.has-cinema-drawer-episodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px; + padding-top: 20px; + overflow-y: auto; +} + +.has-cinema-drawer-ep-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 12px 12px; + display: flex; + flex-direction: column; + gap: 4px; + text-align: left; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-drawer-ep-btn:hover { + background: rgba(99, 102, 241, 0.2); + border-color: #6366f1; + transform: translateY(-2px); +} + +.has-cinema-drawer-ep-btn.active { + background: #4f46e5; + border-color: #818cf8; + box-shadow: 0 4px 14px rgba(79, 70, 229, 0.5); +} + +.has-cinema-drawer-ep-btn .ep-num { + font-size: 13px; + font-weight: 800; + color: #ffffff; +} + +.has-cinema-drawer-ep-btn .ep-title { + font-size: 11.5px; + font-weight: 500; + color: #cbd5e1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.has-cinema-drawer-ep-btn .ep-time { + font-size: 10.5px; + color: #94a3b8; +} + +.has-cinema-search-wrapper { + position: relative; +} + +.has-cinema-search-spinner { + position: absolute; + right: 12px; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #6366f1; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.has-cinema-search-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + background: #111420; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + box-shadow: 0 14px 35px rgba(0, 0, 0, 0.7); + overflow: hidden; + z-index: 100; + animation: fadeIn 0.15s ease; +} + +.has-cinema-search-result-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background 0.15s ease; +} + +.has-cinema-search-result-item:hover { + background: rgba(99, 102, 241, 0.15); +} + +.has-cinema-search-result-img { + width: 38px; + height: 54px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; +} + +.has-cinema-search-result-noimg { + width: 38px; + height: 54px; + background: #1e2235; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} + +.has-cinema-search-result-meta { + display: flex; + flex-direction: column; + overflow: hidden; + gap: 3px; +} + +.has-cinema-search-result-title { + font-size: 13.5px; + font-weight: 700; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.has-cinema-search-result-sub { + font-size: 11.5px; + color: #94a3b8; +} + +.has-cinema-player-mode-toggle { + display: flex; + align-items: center; + gap: 6px; + background: rgba(255, 255, 255, 0.08); + padding: 3px; + border-radius: 9999px; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.has-cinema-mode-btn { + background: transparent; + border: none; + color: #94a3b8; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 9999px; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-mode-btn.active { + background: #4f46e5; + color: #ffffff; +} + +.has-cinema-russian-dub-btn { + background: linear-gradient(135deg, rgba(220, 38, 38, 0.25), rgba(79, 70, 229, 0.25)); + border: 1px solid rgba(239, 68, 68, 0.5); + color: #fca5a5; + padding: 4px 12px; + font-size: 12px; + font-weight: 700; + border-radius: 9999px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; + margin-left: 6px; +} + +.has-cinema-russian-dub-btn:hover { + background: linear-gradient(135deg, rgba(220, 38, 38, 0.5), rgba(79, 70, 229, 0.5)); + border-color: #ef4444; + color: #ffffff; + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(220, 38, 38, 0.4); +} + +.has-cinema-russian-dub-btn.active { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.35), rgba(5, 150, 105, 0.55)); + border-color: #10b981; + color: #a7f3d0; + box-shadow: 0 0 16px rgba(16, 185, 129, 0.4); +} + +.has-cinema-russian-dub-btn.active:hover { + background: linear-gradient(135deg, #10b981, #059669); + color: #ffffff; + border-color: #34d399; +} + +.has-cinema-iframe-elem { + width: 100%; + height: 100%; + flex: 1; + min-height: 0; + border: none; + background: #000000; + display: block; +} + +.has-cinema-player-mirrors-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 0 14px 0; +} + +.has-cinema-mirrors-label { + font-size: 12px; + font-weight: 700; + color: #94a3b8; + margin-right: 4px; +} + +.has-cinema-mirror-btn { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #cbd5e1; + padding: 5px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-mirror-btn:hover { + background: rgba(255, 255, 255, 0.18); + color: #ffffff; + transform: translateY(-1px); +} + +.has-cinema-mirror-btn.active { + background: #4f46e5; + border-color: #6366f1; + color: #ffffff; + font-weight: 700; + box-shadow: 0 2px 10px rgba(79, 70, 229, 0.4); +} + +.has-cinema-translations-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 6px 0 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + margin-bottom: 8px; +} + +.has-cinema-trans-label { + font-size: 12px; + font-weight: 700; + color: #38bdf8; + display: flex; + align-items: center; + gap: 4px; +} + +.has-cinema-trans-list { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.has-cinema-trans-btn { + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.3); + color: #bae6fd; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-trans-btn:hover { + background: rgba(56, 189, 248, 0.25); + color: #ffffff; + border-color: #38bdf8; + transform: translateY(-1px); +} + +.has-cinema-trans-btn.active { + background: #0284c7; + border-color: #38bdf8; + color: #ffffff; + font-weight: 700; + box-shadow: 0 2px 8px rgba(2, 132, 199, 0.4); +} + +.has-cinema-dub-notice { + font-size: 12px; + font-weight: 600; + color: #6ee7b7; + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.35); + padding: 5px 12px; + border-radius: 8px; + margin-bottom: 8px; + display: inline-block; +} + +/* Category Pill */ +.has-cinema-category-pill { + display: inline-flex; + align-items: center; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.25); + color: #ffffff; + padding: 3px 12px; + border-radius: 9999px; + font-size: 12px; + font-weight: 700; + width: fit-content; +} + +.has-cinema-meta-duration { + color: #cbd5e1; + font-size: 13.5px; + font-weight: 600; +} + +/* ======================================================== + ROWS CONTAINER & MEDIA CARDS (Netflix / Zona style) + ======================================================== */ +.has-cinema-rows-container { + padding: 0 48px 60px 48px; + display: flex; + flex-direction: column; + gap: 36px; +} + +.has-cinema-row-block { + display: flex; + flex-direction: column; + gap: 16px; +} + +.has-cinema-row-title { + font-size: 20px; + font-weight: 800; + color: #ffffff; + letter-spacing: -0.01em; + margin: 0; +} + +.has-cinema-cards-scroll { + display: flex; + align-items: stretch; + gap: 18px; + overflow-x: auto; + padding-bottom: 12px; + scroll-behavior: smooth; +} + +.has-cinema-cards-scroll::-webkit-scrollbar { + height: 6px; +} + +.has-cinema-cards-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 9999px; +} + +/* Vertical Poster Card */ +.has-cinema-media-card { + flex: 0 0 160px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + display: flex; + flex-direction: column; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-media-card:hover { + transform: translateY(-5px) scale(1.02); + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.6); + background: rgba(255, 255, 255, 0.08); +} + +.has-cinema-media-thumb-box { + position: relative; + width: 100%; + aspect-ratio: 2 / 3; + overflow: hidden; + background: #111420; +} + +.has-cinema-media-thumb { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.25s ease; +} + +.has-cinema-media-card:hover .has-cinema-media-thumb { + transform: scale(1.05); +} + +.has-cinema-media-play-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.has-cinema-media-card:hover .has-cinema-media-play-overlay, +.has-cinema-grid-card:hover .has-cinema-media-play-overlay { + opacity: 1; +} + +.has-cinema-media-play-circle { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.7); + transform: scale(0.85); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.has-cinema-media-card:hover .has-cinema-media-play-circle, +.has-cinema-grid-card:hover .has-cinema-media-play-circle { + transform: scale(1); +} + +.has-cinema-media-badge-rating { + position: absolute; + top: 8px; + right: 8px; + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(4px); + color: #f59e0b; + font-size: 11px; + font-weight: 800; + padding: 2px 6px; + border-radius: 6px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4); + z-index: 3; +} + +.has-cinema-media-meta { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; +} + +.has-cinema-media-title { + font-size: 13.5px; + font-weight: 700; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.has-cinema-media-sub { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Empty bookmarks notice */ +.has-cinema-empty-bookmarks { + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.15); + border-radius: 14px; + padding: 40px 24px; + text-align: center; + display: flex; + flex-direction: column; + gap: 8px; + color: #cbd5e1; + font-size: 14px; +} + +.has-cinema-empty-sub { + color: #64748b; + font-size: 12.5px; +} + +/* ======================================================== + SEARCH FULL GRID VIEW & SEARCH HEADER + ======================================================== */ +.has-cinema-search-clear-btn { + position: absolute; + right: 10px; + background: rgba(255, 255, 255, 0.15); + border: none; + color: #cbd5e1; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; +} + +.has-cinema-search-clear-btn:hover { + background: rgba(255, 255, 255, 0.3); + color: #ffffff; +} + +.has-cinema-search-page-section { + padding: 100px 48px 60px 48px; + display: flex; + flex-direction: column; + gap: 24px; + min-height: 80vh; +} + +.has-cinema-search-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 16px; +} + +.has-cinema-search-page-title { + font-size: 22px; + font-weight: 800; + color: #ffffff; + letter-spacing: -0.01em; + margin: 0; +} + +.has-cinema-search-count-badge { + background: rgba(99, 102, 241, 0.18); + border: 1px solid rgba(99, 102, 241, 0.4); + color: #a5b4fc; + font-size: 13px; + font-weight: 700; + padding: 4px 12px; + border-radius: 9999px; + white-space: nowrap; +} + +/* Responsive Grid for All Movies / All Series / Search */ +.has-cinema-grid-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 22px; + width: 100%; +} + +.has-cinema-grid-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + display: flex; + flex-direction: column; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-grid-card:hover { + transform: translateY(-5px) scale(1.02); + border-color: rgba(99, 102, 241, 0.5); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.6); + background: rgba(255, 255, 255, 0.08); +} + +.has-cinema-grid-card:hover .has-cinema-media-thumb { + transform: scale(1.05); +} + +/* In-player episode navigation */ +.has-cinema-player-ep-nav { + display: flex; + align-items: center; + gap: 8px; + margin-left: 12px; +} + +.has-cinema-player-nav-btn { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + color: #e2e8f0; + padding: 5px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.has-cinema-player-nav-btn:hover:not(:disabled) { + background: rgba(99, 102, 241, 0.25); + border-color: #6366f1; + color: #ffffff; + transform: translateY(-1px); +} + +.has-cinema-player-nav-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* In-player HLS loading indicator */ +.has-cinema-player-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + color: #94a3b8; + font-size: 15px; + font-weight: 500; + min-height: 420px; + width: 100%; +} + +.has-cinema-loading-spinner { + width: 44px; + height: 44px; + border: 4px solid rgba(99, 102, 241, 0.2); + border-top-color: #6366f1; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* In-player Episode Selector Strip */ +.has-cinema-player-episodes-strip { + display: flex; + align-items: center; + gap: 12px; + max-width: 1100px; + width: 100%; + margin: 10px auto 0; + padding: 8px 14px; + background: rgba(15, 23, 42, 0.75); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + overflow-x: auto; + box-sizing: border-box; +} + +.has-cinema-player-episodes-label { + font-size: 13px; + font-weight: 700; + color: #94a3b8; + white-space: nowrap; +} + +.has-cinema-player-episodes-list { + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + padding-bottom: 2px; +} + +.has-cinema-player-ep-chip { + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #cbd5e1; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s ease; +} + +.has-cinema-player-ep-chip:hover { + background: rgba(99, 102, 241, 0.25); + border-color: #6366f1; + color: #ffffff; +} + +.has-cinema-player-ep-chip.active { + background: #6366f1; + border-color: #818cf8; + color: #ffffff; + font-weight: 700; +} + +/* Desktop Fullscreen Mode */ +.has-cinema-player-modal.fullscreen-mode { + padding: 0; + border-radius: 0; +} + +.has-cinema-player-modal.fullscreen-mode .has-cinema-video-frame { + width: 100vw; + height: 100vh; + border-radius: 0; + box-shadow: none; +} + +/* ======================================================== + 6. ANDROID TV & REMOTE CONTROL (10-FOOT UI) STYLES + ======================================================== */ + +/* Header TV Mode Switcher Button */ +.has-cinema-tv-mode-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 9999px; + color: #cbd5e1; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.has-cinema-tv-mode-btn:hover { + background: rgba(255, 255, 255, 0.16); + color: #ffffff; + border-color: rgba(255, 255, 255, 0.35); +} + +.has-cinema-tv-mode-btn.active { + background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + border-color: #818cf8; + color: #ffffff; + box-shadow: 0 0 16px rgba(99, 102, 241, 0.6); +} + +/* Universal Glowing Focus Indicators for D-Pad / Keyboard Navigation */ +[data-tv-focusable="true"]:focus-visible, +.has-cinema-media-card:focus-visible, +.has-cinema-grid-card:focus-visible, +.has-cinema-episode-card:focus-visible, +.has-cinema-nav-item:focus-visible, +.has-cinema-btn-play:focus-visible, +.has-cinema-btn-circle:focus-visible, +.has-cinema-season-tab:focus-visible, +.has-cinema-mirror-btn:focus-visible, +.has-cinema-trans-btn:focus-visible, +.has-cinema-player-nav-btn:focus-visible, +.has-cinema-mode-btn:focus-visible, +.has-cinema-russian-dub-btn:focus-visible, +.has-cinema-player-fs-btn:focus-visible, +.has-cinema-player-close-btn:focus-visible, +.has-cinema-player-ep-chip:focus-visible, +.has-cinema-tv-mode-btn:focus-visible, +.has-cinema-search-input:focus-visible, +.has-cinema-search-clear-btn:focus-visible { + outline: 3.5px solid #6366f1 !important; + outline-offset: 3px !important; + box-shadow: 0 0 25px rgba(99, 102, 241, 0.9), inset 0 0 8px rgba(99, 102, 241, 0.4) !important; + transform: scale(1.05) !important; + z-index: 35 !important; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, outline-color 0.2s ease !important; +} + +/* TV Mode Root Overrides (10-Foot UI for 3m Couch Distance) */ +.has-tv-mode { + font-size: 16px; +} + +.has-tv-mode .has-cinema-nav-item { + font-size: 16.5px; + padding: 8px 12px; +} + +.has-tv-mode .has-cinema-hero-title { + font-size: 42px; +} + +.has-tv-mode .has-cinema-hero-synopsis { + font-size: 16px; + max-width: 720px; + line-height: 1.6; +} + +.has-tv-mode .has-cinema-btn-play { + padding: 12px 28px; + font-size: 16px; +} + +.has-tv-mode .has-cinema-row-title { + font-size: 22px; + margin-bottom: 16px; +} + +.has-tv-mode .has-cinema-media-card { + width: 195px; + transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.22s ease; +} + +.has-tv-mode .has-cinema-media-card:focus, +.has-tv-mode .has-cinema-grid-card:focus, +.has-tv-mode .has-cinema-episode-card:focus { + outline: 4px solid #818cf8 !important; + outline-offset: 4px !important; + box-shadow: 0 0 35px rgba(129, 140, 248, 0.95), 0 12px 28px rgba(0, 0, 0, 0.8) !important; + transform: scale(1.09) translateY(-6px) !important; + z-index: 40 !important; +} + +.has-tv-mode .has-cinema-media-title { + font-size: 15px; +} + +.has-tv-mode .has-cinema-media-sub { + font-size: 13px; +} + +/* In-Player TV Remote Guide */ +.has-cinema-player-tv-guide { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + padding: 8px 20px; + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin: 6px auto 10px; + color: #cbd5e1; + font-size: 13px; + font-weight: 500; + max-width: 600px; +} + +.has-cinema-player-tv-guide span { + white-space: nowrap; +} + +/* Floating Bottom TV Remote Navigation Guide */ +.has-cinema-tv-remote-guide { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 24px; + padding: 9px 26px; + background: rgba(10, 15, 28, 0.92); + backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 9999px; + z-index: 9999; + box-shadow: 0 10px 35px rgba(0, 0, 0, 0.7); + animation: fadeInGuide 0.3s ease; + pointer-events: none; +} + +.has-cinema-tv-guide-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #94a3b8; + font-weight: 500; +} + +.has-cinema-tv-key { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 6px; + padding: 3px 8px; + color: #f8fafc; + font-family: monospace; + font-size: 11.5px; + font-weight: 700; +} + +@keyframes fadeInGuide { + from { + opacity: 0; + transform: translate(-50%, 15px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +/* High-Visibility Floating On-Screen TV Toast */ +.has-cinema-tv-toast { + position: fixed; + top: 36px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + padding: 12px 28px; + background: rgba(15, 23, 42, 0.96); + backdrop-filter: blur(18px); + border: 1.5px solid #6366f1; + border-radius: 9999px; + color: #ffffff; + font-size: 15.5px; + font-weight: 600; + z-index: 99999; + box-shadow: 0 10px 40px rgba(99, 102, 241, 0.45), 0 0 25px rgba(0, 0, 0, 0.8); + animation: popTvToast 0.25s cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; +} + +.has-cinema-tv-toast-icon { + font-size: 22px; +} + +.has-cinema-tv-toast-text { + letter-spacing: 0.2px; +} + +@keyframes popTvToast { + from { + opacity: 0; + transform: translate(-50%, -20px) scale(0.92); + } + to { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } +} + +/* ======================================================== + MOVIE & SERIES RICH DETAILS MODAL + ======================================================== */ +.has-cinema-btn-info-wide { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(12px); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 9999px; + padding: 10px 22px; + font-size: 14.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-btn-info-wide:hover, +.has-cinema-btn-info-wide:focus-visible { + background: rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.4); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); +} + +.has-cinema-details-overlay { + position: fixed; + inset: 0; + z-index: 9998; + background: rgba(5, 8, 16, 0.88); + backdrop-filter: blur(16px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + animation: fadeInDetailsOverlay 0.25s cubic-bezier(0.16, 1, 0.3, 1); + overflow-y: auto; +} + +@keyframes fadeInDetailsOverlay { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.has-cinema-details-modal { + position: relative; + width: 100%; + max-width: 1080px; + max-height: 90vh; + background: #0b1120; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 24px; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.85), 0 0 0 1px rgba(255, 255, 255, 0.05); + overflow-y: auto; + overflow-x: hidden; + animation: scaleUpDetailsModal 0.28s cubic-bezier(0.16, 1, 0.3, 1); + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +@keyframes scaleUpDetailsModal { + from { + opacity: 0; + transform: scale(0.94) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.has-cinema-details-backdrop-wrap { + position: relative; + height: 320px; + width: 100%; + overflow: hidden; +} + +.has-cinema-details-backdrop-img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.45; + filter: saturate(1.2); +} + +.has-cinema-details-backdrop-gradient { + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + rgba(11, 17, 32, 0.2) 0%, + rgba(11, 17, 32, 0.6) 50%, + #0b1120 100% + ); +} + +.has-cinema-details-close-btn { + position: absolute; + top: 18px; + right: 18px; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + z-index: 10; +} + +.has-cinema-details-close-btn:hover, +.has-cinema-details-close-btn:focus-visible { + background: rgba(239, 68, 68, 0.9); + border-color: #ef4444; + transform: rotate(90deg) scale(1.08); +} + +.has-cinema-details-body { + position: relative; + margin-top: -140px; + padding: 0 36px 36px; + z-index: 2; +} + +.has-cinema-details-hero { + display: flex; + gap: 32px; +} + +.has-cinema-details-poster-box { + flex-shrink: 0; + width: 230px; +} + +.has-cinema-details-poster-img { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 16px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.has-cinema-details-poster-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + align-items: center; +} + +.has-cinema-badge-rating { + background: #f59e0b; + color: #000000; + font-weight: 800; + font-size: 13px; + padding: 4px 10px; + border-radius: 8px; + display: inline-flex; + align-items: center; +} + +.has-cinema-badge-kp { + background: #ff5500; + color: #ffffff; + font-weight: 800; + font-size: 13px; + padding: 4px 10px; + border-radius: 8px; + display: inline-flex; + align-items: center; +} + +.has-cinema-badge-type { + background: rgba(255, 255, 255, 0.12); + color: #e2e8f0; + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + border-radius: 8px; + letter-spacing: 0.5px; +} + +.has-cinema-details-info-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.has-cinema-details-type-pill { + display: inline-flex; + align-self: flex-start; + background: rgba(99, 102, 241, 0.2); + border: 1px solid rgba(99, 102, 241, 0.4); + color: #a5b4fc; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + padding: 4px 12px; + border-radius: 9999px; + margin-bottom: 8px; +} + +.has-cinema-details-title { + font-size: 34px; + font-weight: 800; + color: #ffffff; + line-height: 1.15; + margin: 0 0 6px; +} + +.has-cinema-details-orig-title { + font-size: 17px; + font-weight: 400; + color: #94a3b8; + margin: 0 0 8px; +} + +.has-cinema-details-tagline { + font-size: 14.5px; + font-style: italic; + color: #38bdf8; + margin: 0 0 16px; + line-height: 1.4; +} + +.has-cinema-details-meta-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; +} + +.has-cinema-details-chip { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 5px 12px; + border-radius: 8px; + font-size: 13px; + color: #cbd5e1; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.has-cinema-details-specs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 14px 18px; + margin-bottom: 22px; +} + +.has-cinema-spec-item { + display: flex; + flex-direction: column; + gap: 3px; +} + +.has-cinema-spec-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: #64748b; +} + +.has-cinema-spec-value { + font-size: 14.5px; + font-weight: 600; + color: #f1f5f9; +} + +.has-cinema-spec-value.rating-highlight { + color: #fbbf24; + font-weight: 700; +} + +.has-cinema-spec-value.revenue-highlight { + color: #34d399; + font-weight: 700; +} + +.has-cinema-details-actions-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 24px; +} + +.has-cinema-details-btn-play { + display: inline-flex; + align-items: center; + gap: 10px; + background: linear-gradient(135deg, #e50914 0%, #b81d24 100%); + color: #ffffff; + border: none; + border-radius: 12px; + padding: 13px 32px; + font-size: 16px; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 24px rgba(229, 9, 20, 0.4); + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.has-cinema-details-btn-play:hover, +.has-cinema-details-btn-play:focus-visible { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 12px 32px rgba(229, 9, 20, 0.6); + outline: none; +} + +.has-cinema-details-btn-bookmark { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + border-radius: 12px; + padding: 13px 22px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.has-cinema-details-btn-bookmark:hover, +.has-cinema-details-btn-bookmark:focus-visible { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.4); + transform: translateY(-2px); +} + +.has-cinema-details-btn-bookmark.active { + background: rgba(16, 185, 129, 0.2); + border-color: #10b981; + color: #34d399; +} + +.has-cinema-details-btn-trailer { + display: inline-flex; + align-items: center; + gap: 8px; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #fca5a5; + border-radius: 12px; + padding: 13px 22px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.has-cinema-details-btn-trailer:hover, +.has-cinema-details-btn-trailer:focus-visible { + background: rgba(239, 68, 68, 0.3); + border-color: #ef4444; + color: #ffffff; + transform: translateY(-2px); +} + +.has-cinema-details-loading-badge { + font-size: 13px; + color: #94a3b8; + margin-left: 8px; + font-style: italic; +} + +.has-cinema-details-section-title { + font-size: 19px; + font-weight: 700; + color: #ffffff; + margin: 26px 0 14px; + letter-spacing: 0.3px; +} + +.has-cinema-details-synopsis-text { + font-size: 15px; + line-height: 1.7; + color: #cbd5e1; + margin: 0; +} + +/* Cast Carousel */ +.has-cinema-details-cast-carousel { + display: flex; + gap: 14px; + overflow-x: auto; + padding-bottom: 12px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; +} + +.has-cinema-actor-card { + flex-shrink: 0; + width: 104px; + text-align: center; + cursor: pointer; + transition: transform 0.2s ease; +} + +.has-cinema-actor-card:hover, +.has-cinema-actor-card:focus-visible { + transform: translateY(-4px); + outline: none; +} + +.has-cinema-actor-photo-box { + width: 76px; + height: 76px; + border-radius: 50%; + overflow: hidden; + margin: 0 auto 8px; + border: 2px solid rgba(255, 255, 255, 0.15); + background: #1e293b; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.has-cinema-actor-photo { + width: 100%; + height: 100%; + object-fit: cover; +} + +.has-cinema-actor-photo-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 26px; + font-weight: 700; + color: #94a3b8; + background: #334155; +} + +.has-cinema-actor-name { + font-size: 12.5px; + font-weight: 600; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.has-cinema-actor-character { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Trailer Modal */ +.has-cinema-trailer-modal-overlay { + position: fixed; + inset: 0; + z-index: 10001; + background: rgba(0, 0, 0, 0.94); + backdrop-filter: blur(12px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.has-cinema-trailer-modal { + width: 100%; + max-width: 960px; + background: #0f172a; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.9); +} + +.has-cinema-trailer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + background: #1e293b; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.has-cinema-trailer-title { + font-size: 15.5px; + font-weight: 700; + color: #ffffff; +} + +.has-cinema-trailer-close-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + padding: 6px 14px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.has-cinema-trailer-close-btn:hover, +.has-cinema-trailer-close-btn:focus-visible { + background: #ef4444; + border-color: #ef4444; +} + +.has-cinema-trailer-video-box { + width: 100%; + aspect-ratio: 16 / 9; + background: #000000; +} + +.has-cinema-trailer-iframe { + width: 100%; + height: 100%; + border: none; +} + +/* TV Focus Highlights for Details Elements */ +.has-tv-mode .has-cinema-details-btn-play:focus, +.has-tv-mode .has-cinema-details-btn-bookmark:focus, +.has-tv-mode .has-cinema-details-btn-trailer:focus, +.has-tv-mode .has-cinema-details-close-btn:focus, +.has-tv-mode .has-cinema-actor-card:focus, +.has-tv-mode .has-cinema-btn-info-wide:focus { + outline: 3px solid #38bdf8 !important; + outline-offset: 4px !important; + box-shadow: 0 0 25px rgba(56, 189, 248, 0.7) !important; + transform: scale(1.05) !important; +} + +/* Mobile & Tablet responsive tweaks */ +@media (max-width: 768px) { + .has-cinema-player-top-sensor { + display: none !important; + } + + .has-cinema-player-top-hud { + padding: calc(env(safe-area-inset-top, 0px) + 8px) 12px 14px 12px !important; + gap: 8px !important; + } + + .has-cinema-player-hud-left { + gap: 8px !important; + flex: 1; + min-width: 0; + } + + .has-cinema-player-btn-back { + padding: 7px 12px !important; + font-size: 13px !important; + background: rgba(15, 23, 42, 0.92) !important; + border: 1px solid rgba(255, 255, 255, 0.35) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6) !important; + z-index: 99999 !important; + pointer-events: auto !important; + } + + .has-cinema-player-title-box { + max-width: 120px !important; + overflow: hidden; + } + + .has-cinema-player-hud-title { + font-size: 13px !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .has-cinema-player-hud-subtitle { + font-size: 11px !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .has-cinema-player-hud-right { + gap: 6px !important; + flex-shrink: 0; + } + + .has-cinema-player-hud-btn { + padding: 6px 10px !important; + font-size: 12px !important; + } + + .has-cinema-details-hero { + flex-direction: column; + align-items: center; + text-align: center; + } + .has-cinema-details-poster-box { + width: 180px; + } + .has-cinema-details-info-col { + align-items: center; + } + .has-cinema-details-meta-row { + justify-content: center; + } + .has-cinema-details-actions-row { + justify-content: center; + } + .has-cinema-details-specs-grid { + grid-template-columns: 1fr; + text-align: left; + width: 100%; + } +} + + + + diff --git a/src/pages/cinema/Cinema.tsx b/src/pages/cinema/Cinema.tsx new file mode 100644 index 0000000..fd5a423 --- /dev/null +++ b/src/pages/cinema/Cinema.tsx @@ -0,0 +1,3690 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import Hls from "hls.js"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import type { + CinemaState, + CinemaConfig, + CinemaMediaItem, + CinemaSeason, + CinemaEpisode, + CinemaPlayback, + UserDto, +} from "../../types"; +import "./Cinema.css"; + +interface CinemaProps { + currentUser: UserDto; +} + +const buildAnimeSeasons = (seasonConfigs: { season_number: number; count: number }[], thumb: string): CinemaSeason[] => { + return seasonConfigs.map((cfg) => ({ + season_number: cfg.season_number, + episodes: Array.from({ length: cfg.count }, (_, i) => ({ + id: `demonic-s${cfg.season_number}-e${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: thumb, + duration_minutes: 24, + })), + })); +}; + +const formatCurrency = (amount?: number): string | null => { + if (!amount || amount <= 0) return null; + if (amount >= 1_000_000_000) { + return `$${(amount / 1_000_000_000).toFixed(2)} млрд ($${amount.toLocaleString("ru-RU")})`; + } + if (amount >= 1_000_000) { + return `$${(amount / 1_000_000).toFixed(1)} млн ($${amount.toLocaleString("ru-RU")})`; + } + return `$${amount.toLocaleString("ru-RU")}`; +}; + +const formatRuntime = (minutes?: number): string | null => { + if (!minutes || minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0 && mins > 0) return `${hours} ч ${mins} мин`; + if (hours > 0) return `${hours} ч`; + return `${mins} мин`; +}; + +// Accurate media category label (never confuses live-action series with anime) +const getMediaCategoryLabel = (media: CinemaMediaItem | null | undefined): string => { + if (!media) return "Сериал"; + const g = (media.genre || "").toLowerCase(); + const isAnime = g.includes("аниме") || g.includes("anime"); + const isCartoon = g.includes("мультфильм") || g.includes("анимация") || g.includes("animation"); + + if (media.media_type === "movie") { + if (isAnime) return "Аниме-фильм"; + if (isCartoon) return "Мультфильм"; + return "Фильм"; + } + + // TV / Series + if (isAnime) return "Аниме-сериал"; + if (isCartoon) return "Мультсериал"; + return "Сериал"; +}; + +const getMediaBadgeLabel = (media: CinemaMediaItem | null | undefined): string => { + return getMediaCategoryLabel(media).toUpperCase(); +}; + +// Initial Curated Fallback Library +const INITIAL_LIBRARY: CinemaMediaItem[] = [ + { + id: "demonic-slash", + tmdb_id: 85937, + media_type: "tv", + title: "Клинок, рассекающий демонов", + original_title: "Kimetsu no Yaiba", + backdrop_url: "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop", + release_year: 2024, + seasons_count: 4, + genre: "Аниме / Сёнэн", + rating: 4.9, + tagline: "Разруби свою судьбу клинком", + countries: "Япония", + director: "Харуо Сотодзаки", + cast: [ + { id: 21, name: "Нацуки Ханаэ", character: "Танджиро Камадо" }, + { id: 22, name: "Акари Кито", character: "Незуко Камадо" }, + { id: 23, name: "Хиро Симоно", character: "Зеницу Агацума" }, + { id: 24, name: "Ёсицугу Мацуока", character: "Иноске Хашибира" }, + ], + synopsis: + "Танджиро Камадо становится истребителем демонов, чтобы спасти свою сестру Незуко, превращенную в демона, и отомстить за гибель своей семьи.", + is_original: true, + seasons: buildAnimeSeasons( + [ + { season_number: 1, count: 26 }, + { season_number: 2, count: 11 }, + { season_number: 3, count: 11 }, + { season_number: 4, count: 8 }, + ], + "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop" + ), + }, + { + id: "dune-part-two", + tmdb_id: 693134, + media_type: "movie", + title: "Дюна: Часть вторая", + original_title: "Dune: Part Two", + backdrop_url: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + release_year: 2024, + duration_minutes: 166, + genre: "Фантастика / Приключения", + rating: 4.9, + tagline: "Да пребудет с вами сила пустыни", + budget: 190000000, + revenue: 711844358, + countries: "США, Канада", + director: "Дени Вильнёв", + cast: [ + { id: 1, name: "Тимоти Шаламе", character: "Пол Атрейдес" }, + { id: 2, name: "Зендея", character: "Чани" }, + { id: 3, name: "Ребекка Фергюсон", character: "Леди Джессика" }, + { id: 4, name: "Хавьер Бардем", character: "Стилгар" }, + { id: 5, name: "Остин Батлер", character: "Фейд-Раута" }, + { id: 6, name: "Флоренс Пью", character: "Принцесса Ирулан" }, + ], + synopsis: + "Герцог Пол Атрейдес объединяется с фременами, чтобы отомстить заговорщикам, уничтожившим его семью. Эпическое продолжение борьбы за Арракис.", + is_original: false, + seasons: [], + }, + { + id: "interstellar", + tmdb_id: 157336, + media_type: "movie", + title: "Интерстеллар", + original_title: "Interstellar", + backdrop_url: "https://images.unsplash.com/photo-1506703719100-a0f3a48c0f86?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1451187580459-43490279c0fa?q=80&w=600&auto=format&fit=crop", + release_year: 2014, + duration_minutes: 169, + genre: "Фантастика / Драма", + rating: 4.9, + tagline: "Следующий шаг человечества станет величайшим", + budget: 165000000, + revenue: 701729206, + countries: "США, Великобритания", + director: "Кристофер Нолан", + cast: [ + { id: 11, name: "Мэттью Макконахи", character: "Купер" }, + { id: 12, name: "Энн Хэтэуэй", character: "Бренд" }, + { id: 13, name: "Джессика Честейн", character: "Мёрф" }, + { id: 14, name: "Майкл Кейн", character: "Профессор Бренд" }, + { id: 15, name: "Мэтт Дэймон", character: "Манн" }, + ], + synopsis: + "Когда засуха приводит человечество к глобальному продовольственному кризису, группа ученых отправляется сквозь червоточину в поисках нового дома для людей.", + is_original: false, + seasons: [], + }, + { + id: "arcane", + tmdb_id: 94605, + media_type: "tv", + title: "Аркейн", + original_title: "Arcane: League of Legends", + backdrop_url: "https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1579783902614-a3fb3927b675?q=80&w=600&auto=format&fit=crop", + release_year: 2024, + seasons_count: 2, + genre: "Анимация / Экшен", + rating: 4.9, + tagline: "Каждая легенда с чего-то начинается", + countries: "США, Франция", + director: "Паскаль Шаррю, Арно Делорд", + cast: [ + { id: 31, name: "Хейли Стайнфелд", character: "Вай" }, + { id: 32, name: "Элла Пернелл", character: "Джинкс" }, + { id: 33, name: "Кэти Льюнг", character: "Кейтлин" }, + ], + synopsis: + "История двух сестер, Джинкс и Вай, оказавшихся по разные стороны баррикад в войне между технологическим Пилтовером и преступным Зауном.", + is_original: false, + seasons: [ + { + season_number: 1, + episodes: Array.from({ length: 9 }, (_, i) => ({ + id: `arcane-1-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=600&auto=format&fit=crop", + duration_minutes: 41, + })), + }, + { + season_number: 2, + episodes: Array.from({ length: 9 }, (_, i) => ({ + id: `arcane-2-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=600&auto=format&fit=crop", + duration_minutes: 42, + })), + }, + ], + }, + { + id: "breaking-bad", + tmdb_id: 1396, + media_type: "tv", + title: "Во все тяжкие", + original_title: "Breaking Bad", + backdrop_url: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=600&auto=format&fit=crop", + release_year: 2013, + seasons_count: 5, + genre: "Криминал / Драма", + rating: 5.0, + synopsis: + "Школьный учитель химии Уолтер Уайт узнает о смертельном диагнозе и вместе с бывшим учеником начинает варить метамфетамин, чтобы обеспечить семью.", + is_original: false, + seasons: [ + { + season_number: 1, + episodes: Array.from({ length: 7 }, (_, i) => ({ + id: `bb-1-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + duration_minutes: 48, + })), + }, + { + season_number: 2, + episodes: Array.from({ length: 13 }, (_, i) => ({ + id: `bb-2-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + duration_minutes: 47, + })), + }, + { + season_number: 3, + episodes: Array.from({ length: 13 }, (_, i) => ({ + id: `bb-3-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + duration_minutes: 47, + })), + }, + { + season_number: 4, + episodes: Array.from({ length: 13 }, (_, i) => ({ + id: `bb-4-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + duration_minutes: 47, + })), + }, + { + season_number: 5, + episodes: Array.from({ length: 16 }, (_, i) => ({ + id: `bb-5-${i + 1}`, + number: i + 1, + title: `Серия ${i + 1}`, + thumbnail: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + duration_minutes: 47, + })), + }, + ], + }, + { + id: "oppenheimer", + tmdb_id: 872585, + media_type: "movie", + title: "Оппенгеймер", + original_title: "Oppenheimer", + backdrop_url: "https://images.unsplash.com/photo-1509198397868-475647b2a1e5?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=600&auto=format&fit=crop", + release_year: 2023, + duration_minutes: 180, + genre: "Драма / История", + rating: 4.9, + synopsis: "История американского физика-теоретика Роберта Оппенгеймера, руководителя Манхэттенского проекта во время Второй мировой войны.", + is_original: false, + seasons: [], + }, + { + id: "jujutsu-kaisen", + tmdb_id: 95479, + media_type: "tv", + title: "Магическая битва", + original_title: "Jujutsu Kaisen", + backdrop_url: "https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=1600&auto=format&fit=crop", + poster_url: "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop", + release_year: 2020, + seasons_count: 2, + genre: "Аниме / Экшен", + rating: 4.9, + synopsis: "Юдзи Итадори проглатывает палец могущественного демона Рёмэна Сукуны и вступает в Токийский магический колледж.", + is_original: true, + seasons: buildAnimeSeasons( + [ + { season_number: 1, count: 24 }, + { season_number: 2, count: 23 }, + ], + "https://images.unsplash.com/photo-1607604276583-eef5d076aa5f?q=80&w=600&auto=format&fit=crop" + ), + }, +]; + +interface RawTmdbItem { + id: number; + title?: string; + name?: string; + original_name?: string; + original_title?: string; + media_type?: string; + poster_path?: string; + backdrop_path?: string; + overview?: string; + release_date?: string; + first_air_date?: string; + vote_average?: number; + vote_count?: number; + genre_ids?: number[]; + number_of_seasons?: number; + status?: string; +} + +// Filter out titles that have not yet been released or are unstreamable announcements +const isMediaReleased = (item: RawTmdbItem): boolean => { + const today = new Date().toISOString().slice(0, 10); + const dateStr = item.release_date || item.first_air_date; + + // 1. If release date is in the future, it is not yet released + if (dateStr && dateStr > today) { + return false; + } + + // 2. If it has no release date at all or invalid year + if (!dateStr || dateStr.trim().length < 4) { + return false; + } + + // 3. Status checks: upcoming or unproduced titles + if ( + item.status && + (item.status === "Planned" || + item.status === "In Production" || + item.status === "Post Production" || + item.status === "Rumored") + ) { + return false; + } + + // 4. Low-vote / announcement filter: titles with < 20 votes on TMDB + // are almost exclusively unreleased festival announcements or dead unstreamable metadata + if (item.vote_count !== undefined && item.vote_count < 20) { + return false; + } + + // 5. Very low rating with low votes filter + if (item.vote_average !== undefined && item.vote_average < 3.5 && (item.vote_count || 0) < 50) { + return false; + } + + return true; +}; + +interface KodikItem { + id: string; + title: string; + title_orig?: string; + link: string; + translation: { + id: number; + title: string; + type: string; + }; +} + +// Known Kinopoisk IDs for top popular titles (instant 0ms resolution for Russian dubbing) +const KNOWN_KP_MAP: Record = { + 157336: 258687, // Интерстеллар + 693134: 4465403, // Дюна: Часть вторая + 438631: 409424, // Дюна + 94605: 1318855, // Аркейн + 1396: 404900, // Во все тяжкие + 85937: 1112136, // Клинок, рассекающий демонов + 872585: 4664634, // Оппенгеймер + 155: 111543, // Тёмный рыцарь + 27205: 447301, // Начало + 550: 361, // Бойцовский клуб + 680: 342, // Криминальное чтиво + 1399: 464963, // Игра престолов + 60625: 685246, // Рик и Морти + 1429: 749374, // Атака титанов + 95479: 1227958, // Магическая битва + 106379: 1399708, // Фоллаут + 76479: 949804, // Пацаны + 100088: 1354148, // Одни из нас + 66732: 915196, // Очень странные дела + 119051: 1373510, // Уэнсдей + 87108: 1227803, // Чернобыль + 126308: 1219417, // Сёгун + 93405: 1309570, // Игра в кальмара + 299534: 843650, // Мстители: Финал + 299536: 843649, // Мстители: Война бесконечности + 24428: 263531, // Мстители + 634649: 1309599, // Человек-паук: Нет пути домой + 19995: 251733, // Аватар + 76600: 505898, // Аватар: Путь воды + 597: 2213, // Титаник + 120: 328, // Властелин колец: Братство Кольца + 121: 312, // Властелин колец: Две крепости + 122: 3498, // Властелин колец: Возвращение Короля + 671: 689, // Гарри Поттер и философский камень + 672: 688, // Гарри Поттер и Тайная комната + 673: 324, // Гарри Поттер и узник Азкабана + 674: 8408, // Гарри Поттер и Кубок огня + 675: 48356, // Гарри Поттер и Орден Феникса + 767: 86621, // Гарри Поттер и Принц-полукровка + 12444: 276762, // Гарри Поттер и Дары Смерти: Часть 1 + 12445: 407636, // Гарри Поттер и Дары Смерти: Часть 2 + 389: 349, // 12 разгневанных мужчин + 278: 326, // Побег из Шоушенка + 238: 325, // Крёстный отец + 424: 329, // Список Шиндлера + 13: 448, // Форрест Гамп + 129: 370, // Унесённые призраками + 496243: 1043758, // Паразиты + 493529: 1009436, // Джон Уик 3 + 603692: 1267348, // Джон Уик 4 + 245891: 762738, // Джон Уик + 324552: 885658, // Джон Уик 2 + 533535: 1318973, // Дэдпул и Росомаха + 293660: 462360, // Дэдпул + 383498: 977288, // Дэдпул 2 +}; + +interface RussianMediaInfo { + kp_id?: number | null; + imdb_id?: string | null; + iframe_url?: string | null; + russian_title?: string | null; +} + +type BalancerMirror = + | "smotrim_ru" + | "embess_ru" + | "alloha_ru" + | "kodik_ru" + | "anilibria" + | "vidlink_pro" + | "vidsrc_to" + | "autoembed"; + + + +// Reliable image URL helper with proxy caching (bypasses Russian ISP / Cloudflare blocks on image.tmdb.org) +const formatMediaImg = (path: string | undefined, size: "w500" | "original" = "w500"): string => { + if (!path) return size === "original" ? INITIAL_LIBRARY[0].backdrop_url : INITIAL_LIBRARY[0].poster_url; + if (path.startsWith("http")) return path; + return `https://wsrv.nl/?url=https://image.tmdb.org/t/p/${size}${path}`; +}; + +// Detect Android TV / Smart TV user agent or previous user preference +const detectTvEnvironment = (): boolean => { + if (typeof window === "undefined") return false; + const saved = localStorage.getItem("has_tv_mode"); + if (saved !== null) return saved === "true"; + const ua = (navigator.userAgent || "").toLowerCase(); + return ( + ua.includes("android tv") || + ua.includes("smarttv") || + ua.includes("googletv") || + ua.includes("tizen") || + ua.includes("webos") || + ua.includes("appletv") || + ua.includes("crkey") || + ua.includes("bravia") || + ua.includes("aftt") || + ua.includes("aftm") + ); +}; + +export default function Cinema({ currentUser }: CinemaProps) { + const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + + // Android TV / Remote Control (10-Foot UI) State + const [isTvMode, setIsTvMode] = useState(detectTvEnvironment); + const [tvToast, setTvToast] = useState<{ text: string; icon?: string } | null>(null); + const tvToastTimerRef = useRef(null); + const audioCtxRef = useRef(null); + + const showTvToast = useCallback((text: string, icon = "📺", duration = 2000) => { + if (tvToastTimerRef.current) clearTimeout(tvToastTimerRef.current); + setTvToast({ text, icon }); + tvToastTimerRef.current = setTimeout(() => { + setTvToast(null); + }, duration); + }, []); + + const playTvBeep = useCallback((freq = 580, duration = 0.035) => { + try { + const AudioCtx = window.AudioContext || (window as any).webkitAudioContext; + if (!AudioCtx) return; + if (!audioCtxRef.current) { + audioCtxRef.current = new AudioCtx(); + } + const ctx = audioCtxRef.current; + if (ctx.state === "suspended") { + ctx.resume().catch(() => {}); + } + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "sine"; + osc.frequency.setValueAtTime(freq, ctx.currentTime); + gain.gain.setValueAtTime(0.04, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + duration); + } catch { + // AudioContext might be blocked until user gesture, ignore safely + } + }, []); + + const toggleTvMode = useCallback(() => { + setIsTvMode((prev) => { + const next = !prev; + localStorage.setItem("has_tv_mode", String(next)); + showTvToast(next ? "ТВ-Режим включен (10-Foot UI)" : "ПК-Режим включен", "📺", 2500); + playTvBeep(next ? 750 : 400, 0.05); + return next; + }); + }, [showTvToast, playTvBeep]); + + // Local Cinema State (Bookmarks & Progress saved on user's machine) + const [cinemaState, setCinemaState] = useState({ + bookmarks: [], + history: [], + }); + const [, setConfig] = useState({ tmdb_api_key: null }); + + // Navigation Filter + const [activeTab, setActiveTab] = useState<"home" | "movies" | "series" | "anime" | "bookmarks">("home"); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + // Active Selected Hero Item + const [selectedMedia, setSelectedMedia] = useState(INITIAL_LIBRARY[0]); + const [selectedSeason, setSelectedSeason] = useState(1); + + // Dynamic Content Collections from TMDB + const [trendingAll, setTrendingAll] = useState([]); + const [popularMovies, setPopularMovies] = useState([]); + const [popularSeries, setPopularSeries] = useState([]); + const [popularAnime, setPopularAnime] = useState([]); + + // Video Player Modal State + const [playingMedia, setPlayingMedia] = useState(null); + const [playingEpisode, setPlayingEpisode] = useState(null); + const [playingSeasonNumber, setPlayingSeasonNumber] = useState(1); + const playerMode: "balancer" | "direct" = "balancer"; + const [selectedMirror, setSelectedMirror] = useState("embess_ru"); + const [currentKpId, setCurrentKpId] = useState(null); + const [currentImdbId, setCurrentImdbId] = useState(null); + const [currentAllohaUrl, setCurrentAllohaUrl] = useState(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const [kodikResults, setKodikResults] = useState([]); + const [selectedKodikLink, setSelectedKodikLink] = useState(null); + const [isSearchingKodik, setIsSearchingKodik] = useState(false); + const [russianDubNotice, setRussianDubNotice] = useState(null); + const [anilibriaStreamUrl, setAnilibriaStreamUrl] = useState(null); + const [isLoadingAniLibria, setIsLoadingAniLibria] = useState(false); + const videoRef = useRef(null); + const topRef = useRef(null); + const hlsInstanceRef = useRef(null); + const aniLibriaReleasesCache = useRef>(new Map()); + + // Detailed Movie / Series Information Modal State + const [detailsMedia, setDetailsMedia] = useState(null); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [showTrailerModal, setShowTrailerModal] = useState(false); + + // Simplified Video Player HUD & Controls State + const [isHudVisible, setIsHudVisible] = useState(true); + const [showPlayerSettings, setShowPlayerSettings] = useState(false); + const [showPlayerEpisodes, setShowPlayerEpisodes] = useState(false); + const hudTimeoutRef = useRef(null); + + const resetHudTimer = useCallback(() => { + setIsHudVisible(true); + if (hudTimeoutRef.current) { + clearTimeout(hudTimeoutRef.current); + } + hudTimeoutRef.current = setTimeout(() => { + setShowPlayerSettings((settingsOpen) => { + setShowPlayerEpisodes((episodesOpen) => { + if (!settingsOpen && !episodesOpen) { + setIsHudVisible(false); + } + return episodesOpen; + }); + return settingsOpen; + }); + }, 3500); + }, []); + + // Universal helper to convert a TMDB item into CinemaMediaItem + const mapTmdbItem = (item: RawTmdbItem, forcedType?: "movie" | "tv"): CinemaMediaItem => { + let isTv = false; + if (forcedType) { + isTv = forcedType === "tv"; + } else if (item.media_type) { + isTv = item.media_type === "tv"; + } else { + isTv = !item.title && (!!item.name || !!item.first_air_date); + } + + const title = item.title || item.name || item.original_title || item.original_name || "Без названия"; + const backdrop = formatMediaImg(item.backdrop_path || item.poster_path, "original"); + const poster = formatMediaImg(item.poster_path || item.backdrop_path, "w500"); + const year = (item.release_date || item.first_air_date || "").slice(0, 4) || "2024"; + + if (!isTv) { + return { + id: `tmdb-movie-${item.id}`, + tmdb_id: item.id, + media_type: "movie", + title, + original_title: item.original_name || item.original_title, + backdrop_url: backdrop, + poster_url: poster, + release_year: year, + seasons_count: undefined, + duration_minutes: 120, + genre: "Фильм", + rating: item.vote_average ? Math.round(item.vote_average * 10) / 10 : 8.0, + vote_count: item.vote_count, + synopsis: item.overview?.trim() || "Описание фильма из международной базы TMDB.", + is_original: false, + seasons: [], + }; + } + + const initialSeasonsCount = item.number_of_seasons && item.number_of_seasons > 0 ? item.number_of_seasons : 1; + const seasonsList: CinemaSeason[] = []; + for (let s = 1; s <= initialSeasonsCount; s++) { + seasonsList.push({ + season_number: s, + episodes: [ + { + id: `tmdb-${item.id}-s${s}-e1`, + number: 1, + title: "Серия 1", + thumbnail: poster, + duration_minutes: 45, + }, + ], + }); + } + + return { + id: `tmdb-tv-${item.id}`, + tmdb_id: item.id, + media_type: "tv", + title, + original_title: item.original_name || item.original_title, + backdrop_url: backdrop, + poster_url: poster, + release_year: year, + seasons_count: initialSeasonsCount, + duration_minutes: undefined, + genre: "Сериал", + rating: item.vote_average ? Math.round(item.vote_average * 10) / 10 : 8.0, + vote_count: item.vote_count, + synopsis: item.overview?.trim() || "Описание сериала из международной базы TMDB.", + is_original: false, + seasons: seasonsList, + }; + }; + + // Safe TMDB query via Rust backend (bypasses RU 403 blocks and CORS) + const queryTmdb = useCallback(async (endpoint: string): Promise => { + if (!isTauri) return null; + try { + return await invoke("tmdb_query", { endpoint }); + } catch (err) { + console.warn(`Error querying TMDB endpoint ${endpoint}:`, err); + return null; + } + }, [isTauri]); + + // Verifies that titles returned from TMDB actually have active players/streams on CDN balancers + const verifyPlayableBatch = useCallback( + async (items: CinemaMediaItem[]): Promise => { + if (!isTauri || items.length === 0) return items; + try { + const checkPayload = items.map((i) => ({ + tmdb_id: i.tmdb_id || 0, + title: i.title, + original_title: i.original_title, + media_type: i.media_type || "movie", + vote_count: i.vote_count, + release_year: i.release_year ? String(i.release_year) : undefined, + })); + const verifiedIds = await invoke("filter_playable_media", { items: checkPayload }); + const verifiedSet = new Set(verifiedIds); + return items.filter( + (i) => i.tmdb_id && (verifiedSet.has(i.tmdb_id) || (KNOWN_KP_MAP as any)[i.tmdb_id]) + ); + } catch (err) { + console.warn("Error verifying playable media:", err); + return items; + } + }, + [isTauri] + ); + + // Load complete rich details (budget, revenue, director, cast, trailer, etc.) + const fetchMediaDetails = useCallback(async (item: CinemaMediaItem) => { + if (!item.tmdb_id) return; + try { + const isTv = item.media_type === "tv"; + const endpoint = isTv + ? `/tv/${item.tmdb_id}?language=ru-RU&append_to_response=credits,videos` + : `/movie/${item.tmdb_id}?language=ru-RU&append_to_response=credits,videos`; + + const data = await queryTmdb(endpoint); + if (data) { + const cast = (data.credits?.cast || []).slice(0, 16).map((c: any) => ({ + id: c.id, + name: c.name || c.original_name, + original_name: c.original_name, + character: c.character, + profile_path: c.profile_path ? formatMediaImg(c.profile_path, "w500") : null, + })); + + const director = isTv + ? (data.created_by && data.created_by.length > 0 + ? data.created_by.map((c: any) => c.name).join(", ") + : data.credits?.crew?.find((c: any) => c.job === "Director" || c.job === "Executive Producer")?.name) + : data.credits?.crew?.find((c: any) => c.job === "Director")?.name; + + const countries = (data.production_countries || []).map((c: any) => c.name).join(", "); + const genres = (data.genres || []).map((g: any) => g.name).join(", "); + const runtime = data.runtime || (data.episode_run_time && data.episode_run_time[0]) || item.duration_minutes; + + const videos = data.videos?.results || []; + const trailer = + videos.find((v: any) => v.site === "YouTube" && v.type === "Trailer" && (v.iso_639_1 === "ru" || (v.name || "").toLowerCase().includes("рус"))) || + videos.find((v: any) => v.site === "YouTube" && v.type === "Trailer") || + videos.find((v: any) => v.site === "YouTube"); + + let realSeasons: CinemaSeason[] = []; + let realSeasonsCount: number | undefined = undefined; + + if (isTv) { + if (Array.isArray(data.seasons) && data.seasons.length > 0) { + const regularSeasons = data.seasons.filter((s: any) => s.season_number > 0); + const validSeasons = regularSeasons.length > 0 ? regularSeasons : data.seasons; + realSeasonsCount = data.number_of_seasons || validSeasons.length; + + realSeasons = validSeasons.map((s: any) => { + const epCount = s.episode_count && s.episode_count > 0 ? s.episode_count : 1; + const seasonThumb = s.poster_path ? formatMediaImg(s.poster_path, "w500") : item.poster_url; + return { + season_number: s.season_number, + episodes: Array.from({ length: epCount }, (_, idx) => ({ + id: `tmdb-${item.tmdb_id}-s${s.season_number}-e${idx + 1}`, + number: idx + 1, + title: `Серия ${idx + 1}`, + thumbnail: seasonThumb, + duration_minutes: (data.episode_run_time && data.episode_run_time[0]) || 45, + })), + }; + }); + } else { + realSeasonsCount = data.number_of_seasons || 1; + realSeasons = [ + { + season_number: 1, + episodes: [ + { + id: `tmdb-${item.tmdb_id}-s1-e1`, + number: 1, + title: "Серия 1", + thumbnail: item.poster_url, + duration_minutes: 45, + }, + ], + }, + ]; + } + } + + const today = new Date().toISOString().slice(0, 10); + const releaseDate = data.release_date || data.first_air_date; + const isFuture = releaseDate && releaseDate > today; + const isUnreleasedStatus = data.status === "Planned" || data.status === "In Production" || data.status === "Post Production"; + const isUnreleased = !!(isFuture || isUnreleasedStatus); + + const updateItem = (prev: CinemaMediaItem): CinemaMediaItem => ({ + ...prev, + tagline: data.tagline || prev.tagline, + budget: data.budget && data.budget > 0 ? data.budget : prev.budget, + revenue: data.revenue && data.revenue > 0 ? data.revenue : prev.revenue, + duration_minutes: runtime || prev.duration_minutes, + countries: countries || prev.countries, + genre: genres || prev.genre, + director: director || prev.director, + cast: cast.length > 0 ? cast : prev.cast, + trailer_youtube_key: trailer?.key || prev.trailer_youtube_key, + synopsis: data.overview?.trim() || prev.synopsis, + rating: data.vote_average ? Math.round(data.vote_average * 10) / 10 : prev.rating, + seasons: isTv ? realSeasons : [], + seasons_count: isTv ? realSeasonsCount : undefined, + is_unreleased: isUnreleased, + release_date_full: releaseDate || prev.release_date_full, + }); + + setDetailsMedia((prev) => (prev && prev.id === item.id ? updateItem(prev) : prev)); + setSelectedMedia((prev) => (prev && prev.id === item.id ? updateItem(prev) : prev)); + + if (isTv) { + loadTvSeasonsDetails(item.tmdb_id, 1); + } + } + + // Also query Russian media info for Kinopoisk ID / rating + if (isTauri && item.tmdb_id) { + invoke("get_russian_media_info", { + tmdbId: item.tmdb_id, + isTv: item.media_type === "tv", + title: item.title || item.original_title, + }).then((info) => { + if (info?.kp_id) { + setDetailsMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev)); + setSelectedMedia((prev) => (prev && prev.id === item.id ? { ...prev, kinopoisk_rating: info.kp_id ? "КП" : undefined } : prev)); + } + }).catch(() => {}); + } + } catch (e) { + console.warn("Could not load full media details:", e); + } finally { + setIsLoadingDetails(false); + } + }, [queryTmdb, isTauri]); + + // Load All Catalogs (Released Movies, Series, Anime, Trending) on Mount + const loadFullCatalog = useCallback(async () => { + try { + const today = new Date().toISOString().slice(0, 10); + + // 1. Trending All (Top 40 - page 1 & 2, filtered by released status & active streams) + const [tData1, tData2] = await Promise.all([ + queryTmdb("/trending/all/week?language=ru-RU&page=1"), + queryTmdb("/trending/all/week?language=ru-RU&page=2"), + ]); + const allTrendingRaw = [...(tData1?.results || []), ...(tData2?.results || [])]; + if (allTrendingRaw.length > 0) { + const mapped = allTrendingRaw + .filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i) && (i.vote_count || 0) >= 30) + .map((i: RawTmdbItem) => mapTmdbItem(i)); + const verified = await verifyPlayableBatch(mapped); + setTrendingAll(verified); + if (verified.length > 0) { + setSelectedMedia(verified[0]); + } + } + + // 2. Popular Released Movies (only those with digital/physical distribution and vote_count >= 100) + const [mData1, mData2] = await Promise.all([ + queryTmdb(`/discover/movie?language=ru-RU&sort_by=popularity.desc&primary_release_date.lte=${today}&vote_count.gte=100&with_release_type=4|5|6&page=1`), + queryTmdb(`/discover/movie?language=ru-RU&sort_by=popularity.desc&primary_release_date.lte=${today}&vote_count.gte=100&with_release_type=4|5|6&page=2`), + ]); + const allMoviesRaw = [...(mData1?.results || []), ...(mData2?.results || [])]; + if (allMoviesRaw.length > 0) { + const mapped = allMoviesRaw + .filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i)) + .map((i: RawTmdbItem) => mapTmdbItem(i, "movie")); + const verified = await verifyPlayableBatch(mapped); + setPopularMovies(verified); + } + + // 3. Popular Released TV Shows (first_air_date <= today and vote_count >= 50) + const [tvData1, tvData2] = await Promise.all([ + queryTmdb(`/discover/tv?language=ru-RU&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=50&page=1`), + queryTmdb(`/discover/tv?language=ru-RU&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=50&page=2`), + ]); + const allTvRaw = [...(tvData1?.results || []), ...(tvData2?.results || [])]; + if (allTvRaw.length > 0) { + const mapped = allTvRaw + .filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i)) + .map((i: RawTmdbItem) => mapTmdbItem(i, "tv")); + const verified = await verifyPlayableBatch(mapped); + setPopularSeries(verified); + } + + // 4. Genuine Anime (Japanese Animation, vote_count >= 30) + const [aData1, aData2] = await Promise.all([ + queryTmdb(`/discover/tv?with_genres=16&with_original_language=ja&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=30&language=ru-RU&page=1`), + queryTmdb(`/discover/tv?with_genres=16&with_original_language=ja&sort_by=popularity.desc&first_air_date.lte=${today}&vote_count.gte=30&language=ru-RU&page=2`), + ]); + const allAnimeRaw = [...(aData1?.results || []), ...(aData2?.results || [])]; + if (allAnimeRaw.length > 0) { + const mapped = allAnimeRaw + .filter((i: RawTmdbItem) => (i.title || i.name) && isMediaReleased(i)) + .map((i: RawTmdbItem) => mapTmdbItem(i, "tv")); + const verified = await verifyPlayableBatch(mapped); + setPopularAnime(verified); + } + } catch (err) { + console.warn("Failed to load catalog from TMDB:", err); + } + }, [queryTmdb, verifyPlayableBatch]); + + // Load config, state, and full catalog on mount + useEffect(() => { + if (!isTauri) return; + + invoke("get_cinema_config") + .then((cfg) => { + setConfig(cfg); + loadFullCatalog(); + }) + .catch((err) => console.warn("Failed to get cinema config:", err)); + + invoke("get_cinema_state") + .then((state) => { + if (state) setCinemaState(state); + }) + .catch((err) => console.warn("Failed to get local cinema state:", err)); + }, [isTauri, loadFullCatalog]); + + // Search TMDB live across all movies, series, and anime (only released and verified playable titles) + const executeSearch = useCallback( + async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const data = await queryTmdb( + `/search/multi?query=${encodeURIComponent(query)}&language=ru-RU&page=1` + ); + let rawResults = (data?.results || []).filter( + (r: RawTmdbItem) => (r.media_type === "movie" || r.media_type === "tv") && (r.title || r.name) && isMediaReleased(r) + ); + + if (rawResults.length === 0) { + // Fallback search with en-US query if Russian gave 0 results + const dataEn = await queryTmdb( + `/search/multi?query=${encodeURIComponent(query)}&language=en-US&page=1` + ); + if (dataEn?.results) { + rawResults = dataEn.results.filter( + (r: RawTmdbItem) => (r.media_type === "movie" || r.media_type === "tv") && (r.title || r.name) && isMediaReleased(r) + ); + } + } + + if (rawResults.length > 0) { + const mapped = rawResults + .sort((a: any, b: any) => (b.vote_count || b.popularity || 0) - (a.vote_count || a.popularity || 0)) + .map((r: RawTmdbItem) => mapTmdbItem(r)); + const verified = await verifyPlayableBatch(mapped); + setSearchResults(verified); + } else { + setSearchResults([]); + } + } catch (err) { + console.warn("Search error:", err); + } finally { + setIsSearching(false); + } + }, + [queryTmdb, verifyPlayableBatch] + ); + + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery.trim().length >= 2) { + executeSearch(searchQuery); + } else { + setSearchResults([]); + } + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, executeSearch]); + + // When a TV series is selected, load its real seasons and episodes from TMDB + const loadTvSeasonsDetails = async (tmdbId: number, seasonNumber: number) => { + try { + const data = await queryTmdb(`/tv/${tmdbId}/season/${seasonNumber}?language=ru-RU`); + if (data?.episodes && data.episodes.length > 0) { + const loadedEpisodes: CinemaEpisode[] = data.episodes.map((ep: any) => ({ + id: `tmdb-${tmdbId}-s${seasonNumber}-e${ep.episode_number}`, + number: ep.episode_number, + title: ep.name ? `${ep.name}` : `Серия ${ep.episode_number}`, + thumbnail: ep.still_path + ? formatMediaImg(ep.still_path, "w500") + : selectedMedia.poster_url, + duration_minutes: ep.runtime || 45, + description: ep.overview || undefined, + })); + + const applyEpisodes = (prev: CinemaMediaItem): CinemaMediaItem => { + if (prev.tmdb_id !== tmdbId) return prev; + const seasonExists = prev.seasons.some((s) => s.season_number === seasonNumber); + const nextSeasons = seasonExists + ? prev.seasons.map((s) => (s.season_number === seasonNumber ? { ...s, episodes: loadedEpisodes } : s)) + : [...prev.seasons, { season_number: seasonNumber, episodes: loadedEpisodes }]; + return { + ...prev, + seasons: nextSeasons, + }; + }; + + setSelectedMedia((prev) => applyEpisodes(prev)); + setDetailsMedia((prev) => (prev ? applyEpisodes(prev) : prev)); + setPlayingMedia((prev) => (prev ? applyEpisodes(prev) : prev)); + } + } catch (err) { + console.warn("Failed to load season details:", err); + } + }; + + + const isBookmarked = cinemaState.bookmarks.includes(selectedMedia.id); + + // Toggle Bookmark (Saved locally on user's computer in cinema_state.json) + const handleToggleBookmark = async () => { + if (isTauri) { + try { + const nextState = await invoke("toggle_cinema_bookmark", { + titleId: selectedMedia.id, + }); + setCinemaState(nextState); + } catch (err) { + console.error("Error toggling bookmark:", err); + } + } else { + setCinemaState((prev) => ({ + ...prev, + bookmarks: prev.bookmarks.includes(selectedMedia.id) + ? prev.bookmarks.filter((id) => id !== selectedMedia.id) + : [...prev.bookmarks, selectedMedia.id], + })); + } + }; + + // Get progress for a specific episode + const getEpisodeProgress = (seasonNum: number, episodeNum: number): number => { + const found = cinemaState.history.find( + (h) => h.title_id === selectedMedia.id && h.season === seasonNum && h.episode === episodeNum + ); + if (!found || found.duration_seconds <= 0) return 0; + return Math.min(100, Math.round((found.progress_seconds / found.duration_seconds) * 100)); + }; + + const DEMON_SLAYER_SEASON_RELEASES: Record = { + 1: 8325, // Season 1 (2019, 26 eps) + 2: 9093, // Season 2 (Entertainment District, 11 eps) + 3: 9406, // Season 3 (Swordsmith Village, 11 eps) + 4: 9660, // Season 4 (Hashira Training, 8 eps) + 5: 10144, // Season 5 (Infinity Castle, 2025) + }; + + + // Fetch AniLibria direct Full HD HLS stream (.m3u8) without regional blocks + const fetchAniLibriaEpisodeStream = useCallback( + async (media: CinemaMediaItem, seasonNum: number, episodeNum: number): Promise => { + setIsLoadingAniLibria(true); + try { + let releaseId: number | null = null; + const tLower = media.title.toLowerCase(); + const origLower = (media.original_title || "").toLowerCase(); + + if (tLower.includes("клинок") || origLower.includes("kimetsu")) { + releaseId = DEMON_SLAYER_SEASON_RELEASES[seasonNum] || 8325; + } else { + // Search AniLibria by title + const q = encodeURIComponent(media.title); + const searchRes = await fetch(`https://anilibria.top/api/v1/app/search/releases?query=${q}`); + if (searchRes.ok) { + const searchData = await searchRes.json(); + if (Array.isArray(searchData) && searchData.length > 0) { + releaseId = searchData[0].id; + } + } + if (!releaseId && media.original_title) { + const qOrig = encodeURIComponent(media.original_title); + const searchRes2 = await fetch(`https://anilibria.top/api/v1/app/search/releases?query=${qOrig}`); + if (searchRes2.ok) { + const searchData2 = await searchRes2.json(); + if (Array.isArray(searchData2) && searchData2.length > 0) { + releaseId = searchData2[0].id; + } + } + } + } + + if (!releaseId) { + return null; + } + + // Cache release details + let relData = aniLibriaReleasesCache.current.get(releaseId); + if (!relData) { + const relRes = await fetch(`https://anilibria.top/api/v1/anime/releases/${releaseId}`); + if (relRes.ok) { + relData = await relRes.json(); + aniLibriaReleasesCache.current.set(releaseId, relData); + } + } + + if (relData?.episodes && relData.episodes.length > 0) { + const targetEp = + relData.episodes.find((e: any) => e.ordinal === episodeNum) || + relData.episodes[episodeNum - 1] || + relData.episodes[0]; + return targetEp.hls_1080 || targetEp.hls_720 || targetEp.hls_480 || null; + } + return null; + } catch (err) { + console.warn("AniLibria fetch error:", err); + return null; + } finally { + setIsLoadingAniLibria(false); + } + }, + [] + ); + + // Initialize HLS.js on the video element for native in-app streaming + useEffect(() => { + if (selectedMirror !== "anilibria" || !anilibriaStreamUrl || !videoRef.current) { + if (hlsInstanceRef.current) { + hlsInstanceRef.current.destroy(); + hlsInstanceRef.current = null; + } + return; + } + + const video = videoRef.current; + if (Hls.isSupported()) { + if (hlsInstanceRef.current) { + hlsInstanceRef.current.destroy(); + } + const hls = new Hls({ + enableWorker: true, + lowLatencyMode: false, + backBufferLength: 60, + }); + hlsInstanceRef.current = hls; + hls.loadSource(anilibriaStreamUrl); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + video.play().catch(() => {}); + }); + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + console.warn("HLS fatal error, falling back to VidSrc Pro:", data); + hls.destroy(); + hlsInstanceRef.current = null; + setAnilibriaStreamUrl(null); + setSelectedMirror("vidsrc_to"); + setRussianDubNotice("⚠️ Поток AniLibria временно недоступен. Переключено на проверенный Full HD плеер VidSrc Pro."); + } + }); + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = anilibriaStreamUrl; + video.play().catch(() => {}); + video.onerror = () => { + setAnilibriaStreamUrl(null); + setSelectedMirror("vidsrc_to"); + setRussianDubNotice("⚠️ Ошибка воспроизведения AniLibria. Переключено на VidSrc Pro."); + }; + } + + return () => { + if (hlsInstanceRef.current) { + hlsInstanceRef.current.destroy(); + hlsInstanceRef.current = null; + } + }; + }, [selectedMirror, anilibriaStreamUrl]); + + // Start playback for a media item (movie or specific series episode) + const startPlayback = (media: CinemaMediaItem, seasonNum: number = 1, episode?: CinemaEpisode) => { + setSelectedMedia(media); + setPlayingMedia(media); + setPlayingSeasonNumber(seasonNum); + setRussianDubNotice(null); + setAnilibriaStreamUrl(null); + setIsHudVisible(true); + setShowPlayerSettings(false); + setShowPlayerEpisodes(false); + resetHudTimer(); + + const ep = + episode || + media.seasons.find((s) => s.season_number === seasonNum)?.episodes[0] || + media.seasons[0]?.episodes[0] || { + id: `play-${media.id}-s${seasonNum}`, + number: 1, + title: media.media_type === "movie" ? "Полный фильм" : "Серия 1", + thumbnail: media.poster_url, + duration_minutes: media.duration_minutes || (media.media_type === "movie" ? 120 : 45), + }; + setPlayingEpisode(ep); + + const isAnime = + media.genre?.toLowerCase().includes("аниме") || + media.genre?.toLowerCase().includes("cartoon") || + media.title.toLowerCase().includes("клинок") || + (media.original_title || "").toLowerCase().includes("kimetsu"); + + // Instantly check known Kinopoisk ID for zero-latency Russian dubbing + const knownKp = media.tmdb_id ? KNOWN_KP_MAP[media.tmdb_id] : null; + if (knownKp) { + setCurrentKpId(knownKp); + } else { + setCurrentKpId(null); + } + setCurrentImdbId(null); + setCurrentAllohaUrl(null); + + // Default to genuine Russian spoken voiceover (direct Embess Full HD stream): + if (isAnime) { + setSelectedMirror("kodik_ru"); + showTvToast("🌸 Русская озвучка аниме", "🎙️", 1800); + } else { + setSelectedMirror("embess_ru"); + showTvToast("🇷🇺 Включен полный русский дубляж (1080p)", "🎙️", 1800); + } + + // Resolve Kinopoisk ID & Russian media data asynchronously via Rust backend or web fetch + if (isTauri && media.tmdb_id) { + invoke("get_russian_media_info", { + tmdbId: media.tmdb_id, + isTv: media.media_type === "tv", + title: media.title || media.original_title, + }) + .then((info) => { + if (info) { + if (info.kp_id) setCurrentKpId(Number(info.kp_id)); + if (info.imdb_id) setCurrentImdbId(info.imdb_id); + if (info.iframe_url) { + setCurrentAllohaUrl(info.iframe_url); + if (!isAnime) { + setSelectedMirror("alloha_ru"); + } + } + if (!isAnime) { + setRussianDubNotice( + `🇷🇺 Русская озвучка подключена: «${info.russian_title || media.title}» воспроизводится с полным русским дубляжем.` + ); + } + } + }) + .catch(() => {}); + } else if (media.tmdb_id) { + fetch(`https://api.apbugall.org/?token=45e20a5f584becf7a64dffb7174ddf&tmdb=${media.tmdb_id}`) + .then((r) => r.json()) + .then((data) => { + if (data?.data) { + const d = Array.isArray(data.data) ? data.data[0] : data.data; + if (d?.id_kp) setCurrentKpId(Number(d.id_kp)); + if (d?.id_imdb) setCurrentImdbId(d.id_imdb); + if (d?.iframe) { + setCurrentAllohaUrl(d.iframe); + if (!isAnime) { + setSelectedMirror("alloha_ru"); + } + } + } + }) + .catch(() => {}); + } + + // Search Kodik in background for anime or studio translations + searchKodikDubbings(media.original_title || media.title, isAnime); + }; + + // Card Click Action: Opens rich media details modal (DOES NOT start player immediately) + const handleMediaCardClick = (item: CinemaMediaItem) => { + setSelectedMedia(item); + setDetailsMedia(item); + setSelectedSeason(1); + setIsLoadingDetails(true); + fetchMediaDetails(item); + if (item.media_type === "tv" && item.tmdb_id) { + loadTvSeasonsDetails(item.tmdb_id, 1); + } + playTvBeep(520, 0.035); + }; + + // Play button from Details Modal + const handlePlayFromDetails = (seasonNum: number = 1, episode?: CinemaEpisode) => { + if (!detailsMedia) return; + const media = detailsMedia; + startPlayback(media, seasonNum, episode); + }; + + // Toggle bookmark directly from details modal + const handleToggleBookmarkDetails = async () => { + if (!detailsMedia) return; + const mediaId = detailsMedia.id; + if (isTauri) { + try { + const nextState = await invoke("toggle_cinema_bookmark", { + titleId: mediaId, + }); + setCinemaState(nextState); + } catch (err) { + console.error("Error toggling bookmark:", err); + } + } else { + setCinemaState((prev) => ({ + ...prev, + bookmarks: prev.bookmarks.includes(mediaId) + ? prev.bookmarks.filter((id) => id !== mediaId) + : [...prev.bookmarks, mediaId], + })); + } + playTvBeep(560, 0.03); + }; + + // Auto-focus the Play button when Details modal opens (for Android TV Remote D-Pad) + useEffect(() => { + if (detailsMedia && !playingEpisode) { + const timer = setTimeout(() => { + const playBtn = document.querySelector(".has-cinema-details-btn-play"); + if (playBtn) { + playBtn.focus(); + } + }, 50); + return () => clearTimeout(timer); + } + }, [detailsMedia, playingEpisode]); + + // Play a specific episode + const handlePlayEpisode = (seasonNum: number, episode: CinemaEpisode) => { + startPlayback(selectedMedia, seasonNum, episode); + }; + + // Play button on Hero Banner + const handleHeroPlay = () => { + if (selectedMedia.is_unreleased) { + showTvToast("Фильм ещё не вышел в цифровой прокат", "⏳", 3000); + return; + } + if (selectedMedia.media_type === "movie") { + startPlayback(selectedMedia, 1); + return; + } + const lastHistory = cinemaState.history.find((h) => h.title_id === selectedMedia.id); + if (lastHistory) { + const seasonObj = selectedMedia.seasons.find((s) => s.season_number === lastHistory.season); + const epObj = seasonObj?.episodes.find((e) => e.number === lastHistory.episode); + if (epObj) { + startPlayback(selectedMedia, lastHistory.season, epObj); + return; + } + } + const s1 = selectedMedia.seasons[0]; + if (s1 && s1.episodes.length > 0) { + startPlayback(selectedMedia, 1, s1.episodes[0]); + } else { + startPlayback(selectedMedia, 1); + } + }; + + // Next / Previous episode navigation right inside the player + const handleNextEpisode = () => { + if (!playingEpisode) return; + const currentMedia = playingMedia || selectedMedia; + const currentSeason = + currentMedia.seasons.find((s) => s.season_number === playingSeasonNumber) || currentMedia.seasons[0]; + const nextEp = currentSeason.episodes.find((e) => e.number === playingEpisode.number + 1); + if (nextEp) { + startPlayback(currentMedia, playingSeasonNumber, nextEp); + } + }; + + const handlePrevEpisode = () => { + if (!playingEpisode || playingEpisode.number <= 1) return; + const currentMedia = playingMedia || selectedMedia; + const currentSeason = + currentMedia.seasons.find((s) => s.season_number === playingSeasonNumber) || currentMedia.seasons[0]; + const prevEp = currentSeason.episodes.find((e) => e.number === playingEpisode.number - 1); + if (prevEp) { + startPlayback(currentMedia, playingSeasonNumber, prevEp); + } + }; + + // Quick 1-click mirror switcher if a CDN balancer fails or is blocked + const handleCycleMirror = () => { + const mirrors: BalancerMirror[] = ["embess_ru", "vidlink_pro", "vidsrc_to", "autoembed"]; + if (kodikResults.length > 0) mirrors.push("kodik_ru"); + if (currentAllohaUrl) mirrors.push("alloha_ru"); + + const currentIndex = mirrors.indexOf(selectedMirror); + const nextIndex = (currentIndex + 1) % mirrors.length; + const nextMirror = mirrors[nextIndex]; + setSelectedMirror(nextMirror); + + const labels: Record = { + embess_ru: "Embess (Дубляж 1080p)", + smotrim_ru: "Smotrim", + vidlink_pro: "VidLink Pro (Full HD)", + vidsrc_to: "VidSrc Pro", + autoembed: "AutoEmbed", + alloha_ru: "Alloha", + kodik_ru: "Kodik", + anilibria: "AniLibria", + }; + showTvToast(`Сервер: ${labels[nextMirror] || nextMirror}`, "🔄", 2000); + resetHudTimer(); + }; + + // Save playback progress on time update + const handleVideoTimeUpdate = () => { + if (!videoRef.current || !playingEpisode) return; + const currentTime = videoRef.current.currentTime; + const duration = videoRef.current.duration; + const activeMedia = playingMedia || selectedMedia; + + if (duration > 0 && isTauri) { + const payload: CinemaPlayback = { + title_id: activeMedia.id, + title_name: activeMedia.title, + poster_path: activeMedia.poster_url, + backdrop_path: activeMedia.backdrop_url, + season: playingSeasonNumber, + episode: playingEpisode.number, + progress_seconds: currentTime, + duration_seconds: duration, + last_watched: new Date().toISOString(), + }; + + if (Math.floor(currentTime) % 5 === 0) { + invoke("update_cinema_progress", { item: payload }) + .then((st) => setCinemaState(st)) + .catch(() => {}); + } + } + }; + + // Close player and save exact position + const handleClosePlayer = useCallback(() => { + const activeMedia = playingMedia || selectedMedia; + try { + if (isTauri && isFullscreen) { + try { + getCurrentWindow().setFullscreen(false).catch(() => {}); + } catch {} + setIsFullscreen(false); + } + } catch {} + + if (videoRef.current && playingEpisode && isTauri) { + try { + const currentTime = videoRef.current.currentTime; + const duration = videoRef.current.duration; + if (duration > 0 && activeMedia) { + const payload: CinemaPlayback = { + title_id: activeMedia.id, + title_name: activeMedia.title, + poster_path: activeMedia.poster_url, + backdrop_path: activeMedia.backdrop_url, + season: playingSeasonNumber, + episode: playingEpisode.number, + progress_seconds: currentTime, + duration_seconds: duration, + last_watched: new Date().toISOString(), + }; + invoke("update_cinema_progress", { item: payload }) + .then((st) => setCinemaState(st)) + .catch(() => {}); + } + } catch {} + } + if (hlsInstanceRef.current) { + try { + hlsInstanceRef.current.destroy(); + } catch {} + hlsInstanceRef.current = null; + } + if (hudTimeoutRef.current) { + clearTimeout(hudTimeoutRef.current); + } + setIsHudVisible(true); + setShowPlayerSettings(false); + setShowPlayerEpisodes(false); + + // Return user back cleanly + setDetailsMedia(null); + if (activeMedia) { + setSelectedMedia(activeMedia); + } + + setPlayingEpisode(null); + setPlayingMedia(null); + setCurrentKpId(null); + setCurrentImdbId(null); + setCurrentAllohaUrl(null); + setKodikResults([]); + setSelectedKodikLink(null); + setRussianDubNotice(null); + setAnilibriaStreamUrl(null); + }, [isTauri, isFullscreen, playingEpisode, playingMedia, selectedMedia, playingSeasonNumber]); + + // D-Pad Spatial Navigation & TV Remote Key Handler + const handleRemoteKeyDown = useCallback( + (e: KeyboardEvent) => { + const isInput = + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement; + + // Android TV / Remote key mappings + const isBack = + e.key === "Escape" || + e.key === "GoBack" || + e.key === "BrowserBack" || + e.keyCode === 4 || + e.keyCode === 27; + const isPlayPause = + e.key === "MediaPlayPause" || e.keyCode === 85 || e.keyCode === 179; + const isFastForward = e.key === "MediaFastForward" || e.keyCode === 90; + const isRewind = e.key === "MediaRewind" || e.keyCode === 89; + + // F10 key toggles TV mode + if (e.key === "F10") { + e.preventDefault(); + toggleTvMode(); + return; + } + + // 1. In-player remote control + if (playingEpisode !== null) { + resetHudTimer(); + + if (isBack) { + e.preventDefault(); + if (showPlayerSettings) { + setShowPlayerSettings(false); + return; + } + if (showPlayerEpisodes) { + setShowPlayerEpisodes(false); + return; + } + handleClosePlayer(); + showTvToast("Плеер закрыт", "✕", 1500); + playTvBeep(400, 0.05); + return; + } + + // Pressing ArrowUp / Menu / Info on TV remote reveals HUD and focuses "← Назад" button + if ( + e.key === "ArrowUp" || + e.keyCode === 19 || + e.keyCode === 38 || + e.key === "Menu" || + e.key === "Info" + ) { + e.preventDefault(); + setIsHudVisible(true); + resetHudTimer(); + setTimeout(() => { + const backBtn = document.querySelector(".has-cinema-player-btn-back"); + if (backBtn) { + backBtn.focus(); + playTvBeep(640, 0.03); + } + }, 40); + return; + } + + // Pressing OK / Enter when HUD is idle wakes up the full HUD + if ((e.key === "Enter" || e.keyCode === 13) && !isHudVisible && !videoRef.current) { + e.preventDefault(); + setIsHudVisible(true); + resetHudTimer(); + setTimeout(() => { + const backBtn = document.querySelector(".has-cinema-player-btn-back"); + if (backBtn) backBtn.focus(); + }, 40); + return; + } + + if (isPlayPause || (e.code === "Space" && !isInput)) { + e.preventDefault(); + if (videoRef.current) { + if (videoRef.current.paused) { + videoRef.current.play().catch(() => {}); + showTvToast("Воспроизведение", "▶", 1200); + } else { + videoRef.current.pause(); + showTvToast("Пауза", "⏸", 1200); + } + playTvBeep(560, 0.04); + } + return; + } + + if ( + isFastForward || + (e.key === "ArrowRight" && + !isInput && + !document.activeElement?.closest(".has-cinema-player-modal")) + ) { + if (videoRef.current) { + e.preventDefault(); + videoRef.current.currentTime = Math.min( + videoRef.current.duration || 999999, + videoRef.current.currentTime + 10 + ); + showTvToast("+10 сек", "⏩", 1000); + playTvBeep(520, 0.03); + return; + } + } + + if ( + isRewind || + (e.key === "ArrowLeft" && + !isInput && + !document.activeElement?.closest(".has-cinema-player-modal")) + ) { + if (videoRef.current) { + e.preventDefault(); + videoRef.current.currentTime = Math.max( + 0, + videoRef.current.currentTime - 10 + ); + showTvToast("-10 сек", "⏪", 1000); + playTvBeep(480, 0.03); + return; + } + } + } + + // 2. Global Back button outside player + if (isBack) { + if (showTrailerModal) { + e.preventDefault(); + setShowTrailerModal(false); + showTvToast("Трейлер закрыт", "✕", 1000); + playTvBeep(450, 0.04); + return; + } + if (detailsMedia) { + e.preventDefault(); + setDetailsMedia(null); + showTvToast("Возврат к списку", "↩", 1000); + playTvBeep(450, 0.04); + return; + } + if (searchQuery.trim().length > 0) { + e.preventDefault(); + setSearchQuery(""); + showTvToast("Поиск очищен", "🔍", 1200); + playTvBeep(450, 0.04); + return; + } + if (activeTab !== "home") { + e.preventDefault(); + setActiveTab("home"); + showTvToast("Главный экран", "🏠", 1200); + playTvBeep(450, 0.04); + return; + } + } + + // 3. D-Pad Directional Arrows Navigation + const isArrow = + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key) || + [19, 20, 21, 22, 37, 38, 39, 40].includes(e.keyCode); + + if (isArrow) { + if (isInput && e.key !== "ArrowDown") { + return; // Let user type and navigate within text input + } + + let direction: "up" | "down" | "left" | "right" = "down"; + if (e.key === "ArrowUp" || e.keyCode === 19 || e.keyCode === 38) + direction = "up"; + else if ( + e.key === "ArrowDown" || + e.keyCode === 20 || + e.keyCode === 40 + ) + direction = "down"; + else if ( + e.key === "ArrowLeft" || + e.keyCode === 21 || + e.keyCode === 37 + ) + direction = "left"; + else if ( + e.key === "ArrowRight" || + e.keyCode === 22 || + e.keyCode === 39 + ) + direction = "right"; + + const detailsEl = document.querySelector(".has-cinema-details-modal"); + const playerEl = document.querySelector(".has-cinema-player-modal"); + const trailerEl = document.querySelector(".has-cinema-trailer-modal"); + const settingsSheet = document.querySelector(".has-cinema-player-settings-sheet"); + const episodesSheet = document.querySelector(".has-cinema-player-episodes-sheet"); + + let focusables = Array.from( + document.querySelectorAll( + '[data-tv-focusable="true"], .has-cinema-nav-item, .has-cinema-btn-play, .has-cinema-btn-bookmark, .has-cinema-search-input, .has-cinema-media-card, .has-cinema-grid-card, .has-cinema-episode-card, .has-cinema-player-hud-btn, .has-cinema-player-btn-back, .has-cinema-drawer-option-btn, .has-cinema-drawer-chip, .has-cinema-drawer-ep-btn, .has-cinema-drawer-close-btn, .has-cinema-drawer-btn-primary, .has-cinema-drawer-btn-secondary, .has-cinema-player-pill-nav, .has-cinema-season-tab, .has-cinema-tv-mode-btn, .has-cinema-details-btn-play, .has-cinema-details-close-btn, .has-cinema-details-btn-bookmark, .has-cinema-details-btn-trailer, .has-cinema-actor-card, .has-cinema-btn-info-wide' + ) + ).filter((el) => { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return false; + const style = window.getComputedStyle(el); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" + ); + }); + + // Trap focus inside modal or drawer if open + if (settingsSheet) { + focusables = focusables.filter((el) => settingsSheet.contains(el)); + } else if (episodesSheet) { + focusables = focusables.filter((el) => episodesSheet.contains(el)); + } else if (trailerEl) { + focusables = focusables.filter((el) => trailerEl.contains(el)); + } else if (playerEl) { + focusables = focusables.filter((el) => playerEl.contains(el)); + } else if (detailsEl) { + focusables = focusables.filter((el) => detailsEl.contains(el)); + } + + if (focusables.length === 0) return; + + const current = document.activeElement as HTMLElement | null; + if (!current || !focusables.includes(current)) { + const initial = + focusables.find((el) => el.classList.contains("has-cinema-btn-play")) || + focusables.find((el) => el.classList.contains("has-cinema-nav-item")) || + focusables[0]; + if (initial) { + e.preventDefault(); + initial.focus(); + initial.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + playTvBeep(580, 0.03); + } + return; + } + + const curRect = current.getBoundingClientRect(); + const curCx = curRect.left + curRect.width / 2; + const curCy = curRect.top + curRect.height / 2; + + let bestEl: HTMLElement | null = null; + let minScore = Infinity; + + for (const candidate of focusables) { + if (candidate === current) continue; + const cRect = candidate.getBoundingClientRect(); + const cCx = cRect.left + cRect.width / 2; + const cCy = cRect.top + cRect.height / 2; + + const dx = cCx - curCx; + const dy = cCy - curCy; + + if (direction === "up" && dy >= -8) continue; + if (direction === "down" && dy <= 8) continue; + if (direction === "left" && dx >= -8) continue; + if (direction === "right" && dx <= 8) continue; + + let score = Infinity; + if (direction === "up" || direction === "down") { + score = Math.abs(dy) + Math.abs(dx) * 1.7; + } else { + score = Math.abs(dx) + Math.abs(dy) * 2.4; + } + + if (score < minScore) { + minScore = score; + bestEl = candidate; + } + } + + if (bestEl) { + e.preventDefault(); + bestEl.focus(); + bestEl.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + playTvBeep(640, 0.025); + } + } + }, + [ + playingEpisode, + detailsMedia, + showTrailerModal, + showPlayerSettings, + showPlayerEpisodes, + searchQuery, + activeTab, + handleClosePlayer, + showTvToast, + playTvBeep, + toggleTvMode, + resetHudTimer, + ] + ); + + useEffect(() => { + window.addEventListener("keydown", handleRemoteKeyDown); + return () => { + window.removeEventListener("keydown", handleRemoteKeyDown); + }; + }, [handleRemoteKeyDown]); + + // Handle Android TV hardware back & browser popstate to guarantee return to movie details card + useEffect(() => { + if (playingEpisode !== null) { + window.history.pushState({ cinemaPlayer: true }, ""); + + const handlePopState = (e: PopStateEvent) => { + e.preventDefault(); + if (showPlayerSettings) { + setShowPlayerSettings(false); + window.history.pushState({ cinemaPlayer: true }, ""); + return; + } + if (showPlayerEpisodes) { + setShowPlayerEpisodes(false); + window.history.pushState({ cinemaPlayer: true }, ""); + return; + } + handleClosePlayer(); + }; + + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + } + }, [playingEpisode, showPlayerSettings, showPlayerEpisodes, handleClosePlayer]); + + // Handle Android TV hardware back & browser popstate for details modal + useEffect(() => { + if (detailsMedia !== null && playingEpisode === null) { + window.history.pushState({ cinemaDetails: true }, ""); + + const handlePopState = (e: PopStateEvent) => { + e.preventDefault(); + if (showTrailerModal) { + setShowTrailerModal(false); + window.history.pushState({ cinemaDetails: true }, ""); + return; + } + setDetailsMedia(null); + }; + + window.addEventListener("popstate", handlePopState); + return () => { + window.removeEventListener("popstate", handlePopState); + }; + } + }, [detailsMedia, playingEpisode, showTrailerModal]); + + // Toggle Desktop Fullscreen mode + const handleToggleFullscreen = async () => { + if (isTauri) { + try { + const win = getCurrentWindow(); + const current = await win.isFullscreen(); + await win.setFullscreen(!current); + setIsFullscreen(!current); + } catch { + setIsFullscreen((prev) => !prev); + } + } else { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(() => {}); + setIsFullscreen(true); + } else { + document.exitFullscreen().catch(() => {}); + setIsFullscreen(false); + } + } + }; + + // Switch mirror manually + const handleSelectMirror = async (mirrorId: BalancerMirror) => { + setSelectedMirror(mirrorId); + if (mirrorId === "anilibria") { + const activeMedia = playingMedia || selectedMedia; + const epNum = playingEpisode?.number || 1; + const stream = await fetchAniLibriaEpisodeStream(activeMedia, playingSeasonNumber, epNum); + if (stream) { + setAnilibriaStreamUrl(stream); + setRussianDubNotice("🌸 Подключен поток AniLibria Full HD (Studio Band)."); + } + } else { + setAnilibriaStreamUrl(null); + } + }; + + // Title matching helper to eliminate unrelated fuzzy search results + const isMatchingTitle = (target: string, candidate: string): boolean => { + if (!target || !candidate) return false; + const cleanTarget = target.toLowerCase().replace(/[^a-zа-я0-9\s]/gi, " ").trim(); + const cleanCandidate = candidate.toLowerCase().replace(/[^a-zа-я0-9\s]/gi, " ").trim(); + if (cleanTarget === cleanCandidate) return true; + const targetWords = cleanTarget.split(/\s+/).filter((w) => w.length > 2); + if (targetWords.length === 0) return false; + const minWordsToCheck = Math.min(2, targetWords.length); + return targetWords.slice(0, minWordsToCheck).every((w) => cleanCandidate.includes(w)); + }; + + // Search Kodik API for Russian studio voiceovers (LostFilm, HDRezka, Studio Band, AniLibria, AniDUB, Дубляж) + const searchKodikDubbings = async (title: string, autoSwitch: boolean = false) => { + setIsSearchingKodik(true); + setRussianDubNotice(null); + const activeMedia = playingMedia || selectedMedia; + const targetTitle = activeMedia.title; + const targetOrig = activeMedia.original_title || title; + + try { + let rawResults: KodikItem[] = []; + + // 1. Try search by original title first + if (targetOrig) { + const q = encodeURIComponent(targetOrig); + const res = await fetch(`https://kodik-api.com/search?token=41dd95f84c21719b09d6c71182237a25&title=${q}`); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data.results)) { + rawResults = data.results; + } + } + } + + // 2. Try search by Russian title if needed + if (rawResults.length === 0 && targetTitle) { + const qRu = encodeURIComponent(targetTitle); + const resRu = await fetch(`https://kodik-api.com/search?token=41dd95f84c21719b09d6c71182237a25&title=${qRu}`); + if (resRu.ok) { + const dataRu = await resRu.json(); + if (Array.isArray(dataRu.results)) { + rawResults = dataRu.results; + } + } + } + + // 3. Strict filtering: ONLY include results that actually match this title! + const matchingResults = rawResults.filter((r) => { + const matchRu = isMatchingTitle(targetTitle, r.title); + const matchOrig = isMatchingTitle(targetOrig, r.title_orig || r.title); + return matchRu || matchOrig; + }); + + // 4. Deduplicate by translation studio name + const uniqueStudios = new Map(); + for (const item of matchingResults) { + const studioName = item.translation?.title?.trim() || "Озвучка"; + if (!uniqueStudios.has(studioName)) { + uniqueStudios.set(studioName, item); + } + } + const finalDubbings = Array.from(uniqueStudios.values()); + + if (finalDubbings.length > 0) { + setKodikResults(finalDubbings); + setSelectedKodikLink(finalDubbings[0].link); + if (autoSwitch) { + setSelectedMirror("kodik_ru"); + setRussianDubNotice(`🇷🇺 Найдено ${finalDubbings.length} студий русской озвучки для «${targetTitle}».`); + } + } else { + setKodikResults([]); + setSelectedKodikLink(null); + if (autoSwitch) { + setSelectedMirror("vidlink_pro"); + setRussianDubNotice("🇷🇺 Для данного фильма русская аудиодорожка встроена в плеер VidLink Pro (нажмите на шестеренку ⚙️ или значок аудио в правом нижнем углу плеера)."); + } + } + } catch { + setKodikResults([]); + setSelectedKodikLink(null); + if (autoSwitch) { + setSelectedMirror("vidlink_pro"); + setRussianDubNotice("🌍 Переключено на основной плеер VidLink Pro."); + } + } finally { + setIsSearchingKodik(false); + } + }; + + + + const currentSeasonData: CinemaSeason = + selectedMedia.seasons.find((s) => s.season_number === selectedSeason) || selectedMedia.seasons[0]; + + // Balancer embed URL with multiple verified mirror selection + const getBalancerEmbedUrl = (): string => { + const media = playingMedia || selectedMedia; + const tmdb = media.tmdb_id; + const isTv = media.media_type === "tv"; + const epNum = playingEpisode?.number || 1; + const sNum = playingSeasonNumber || 1; + const kpId = currentKpId || (tmdb ? KNOWN_KP_MAP[tmdb] : null); + const imdbId = currentImdbId; + + switch (selectedMirror) { + case "smotrim_ru": + case "embess_ru": + if (kpId) { + return `https://api.embess.ws/embed/kp/${kpId}${isTv ? `?season=${sNum}&episode=${epNum}` : ""}`; + } + if (imdbId) { + return `https://api.embess.ws/embed/imdb/${imdbId}${isTv ? `?season=${sNum}&episode=${epNum}` : ""}`; + } + // If neither KP nor IMDb is known for Embess, route to VidLink Pro which natively resolves all TMDB IDs + return isTv + ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1` + : `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`; + + case "alloha_ru": + if (currentAllohaUrl) { + return currentAllohaUrl; + } + if (kpId) { + return `https://api.delivembd.ws/embed/kp/${kpId}${isTv ? `?season=${sNum}&episode=${epNum}` : ""}`; + } + return isTv + ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1` + : `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`; + + case "kodik_ru": + if (selectedKodikLink) { + return selectedKodikLink.startsWith("//") ? `https:${selectedKodikLink}` : selectedKodikLink; + } + if (kpId) { + return `https://api.embess.ws/embed/kp/${kpId}${isTv ? `?season=${sNum}&episode=${epNum}` : ""}`; + } + return isTv + ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1` + : `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`; + + case "vidlink_pro": + return isTv + ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1` + : `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`; + + case "vidsrc_to": + return isTv + ? `https://vidsrc.to/embed/tv/${tmdb || 693134}/${sNum}/${epNum}` + : `https://vidsrc.to/embed/movie/${tmdb || 693134}`; + + case "autoembed": + return isTv + ? `https://player.autoembed.co/embed/tv/${tmdb || 693134}/${sNum}/${epNum}` + : `https://player.autoembed.co/embed/movie/${tmdb || 693134}`; + + default: + if (kpId) { + return `https://api.embess.ws/embed/kp/${kpId}${isTv ? `?season=${sNum}&episode=${epNum}` : ""}`; + } + return isTv + ? `https://vidlink.pro/tv/${tmdb || 693134}/${sNum}/${epNum}?primaryColor=6366f1` + : `https://vidlink.pro/movie/${tmdb || 693134}?primaryColor=6366f1`; + } + }; + + const balancerEmbedUrl = getBalancerEmbedUrl(); + + // Combine live TMDB catalog with initial fallbacks + const allMoviesList = popularMovies.length > 0 ? popularMovies : INITIAL_LIBRARY.filter((i) => i.media_type === "movie"); + const allSeriesList = popularSeries.length > 0 ? popularSeries : INITIAL_LIBRARY.filter((i) => i.media_type === "tv"); + const allAnimeList = popularAnime.length > 0 ? popularAnime : INITIAL_LIBRARY.filter((i) => i.genre.includes("Аниме") || i.genre.includes("Cartoon")); + const allTrending = trendingAll.length > 0 ? trendingAll : INITIAL_LIBRARY; + + const bookmarkedItems = [ + ...trendingAll, + ...popularMovies, + ...popularSeries, + ...popularAnime, + ...INITIAL_LIBRARY, + ].filter((item, index, self) => cinemaState.bookmarks.includes(item.id) && self.findIndex((s) => s.id === item.id) === index); + + return ( +
+ {/* ======================================================== + 1. TOP NAVIGATION BAR (All categories & search) + ======================================================== */} +
+ + +
+ {/* Android TV 10-Foot Mode Switcher Button */} + + + {/* Universal Search Box */} +
+
+ + + + + setSearchQuery(e.target.value)} + className="has-cinema-search-input" + data-tv-focusable="true" + /> + {isSearching && } + {searchQuery && ( + + )} +
+
+ + {/* User profile avatar */} +
+ {currentUser.avatar ? ( + User + ) : ( + + {(currentUser.username || currentUser.email || "U").slice(0, 2).toUpperCase()} + + )} +
+
+
+ + {/* ======================================================== + SEARCH RESULTS FULL GRID VIEW (When Searching) + ======================================================== */} + {searchQuery.trim().length >= 2 ? ( +
+
+

+ Результаты поиска по запросу «{searchQuery}» +

+ + Найдено: {searchResults.length} + +
+ + {searchResults.length === 0 ? ( +
+ {isSearching ? "Поиск в международной базе фильмов..." : "Ничего не найдено по данному запросу."} + + Попробуйте ввести оригинальное название на английском или уточнить запрос. + +
+ ) : ( +
+ {searchResults.map((item) => ( +
handleMediaCardClick(item)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handleMediaCardClick(item); + } + }} + > +
+ {item.title} +
★ {item.rating}
+
+
▶
+
+
+
+ {item.title} + + {item.release_year} • {getMediaCategoryLabel(item)} + +
+
+ ))} +
+ )} +
+ ) : ( + <> + {/* ======================================================== + 2. HERO FEATURED BANNER + ======================================================== */} +
+
+ {selectedMedia.title} +
+
+
+ +
+ {/* Badge */} +
+ {getMediaCategoryLabel(selectedMedia)} +
+ + {/* Title */} +

{selectedMedia.title}

+ + {/* Metadata Row */} +
+ {selectedMedia.release_year} + {selectedMedia.media_type !== "movie" && selectedMedia.seasons_count && ( + + {selectedMedia.seasons_count}{" "} + {selectedMedia.seasons_count === 1 + ? "сезон" + : selectedMedia.seasons_count < 5 + ? "сезона" + : "сезонов"} + + )} + {selectedMedia.duration_minutes && ( + {selectedMedia.duration_minutes} мин + )} + {selectedMedia.genre} +
+ ★★★★★ + {selectedMedia.rating} +
+
+ + {/* Synopsis */} +

{selectedMedia.synopsis}

+ + {/* Action Buttons: Play + Bookmark + Info */} +
+ + + + + + + +
+
+
+ + {/* ======================================================== + 3. SEASONS TABS & EPISODES (For TV Series & Anime) + ======================================================== */} + {selectedMedia.media_type !== "movie" && selectedMedia.seasons.length > 0 && ( +
+
+ {selectedMedia.seasons.map((season) => ( + + ))} +
+ + {/* Horizontal Carousel of Episodes */} +
+ {currentSeasonData.episodes.map((ep) => { + const progress = getEpisodeProgress(selectedSeason, ep.number); + return ( +
handlePlayEpisode(selectedSeason, ep)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handlePlayEpisode(selectedSeason, ep); + } + }} + title={`Смотреть: Сезон ${selectedSeason}, Серия ${ep.number} — ${ep.title}`} + > +
+ {ep.title} +
S
+
+ + + + +
+
+ +
+
+ {ep.title.startsWith("Серия") ? ep.title : `${ep.number}. ${ep.title}`} +
+ {ep.duration_minutes && ( +
{ep.duration_minutes} мин
+ )} +
+ +
+
+
+
+ ); + })} +
+
+ )} + + {/* ======================================================== + 4. FULL CATALOG ROWS & COLLECTIONS (All Movies & Series) + ======================================================== */} +
+ {/* Row 1: Continue Watching */} + {cinemaState.history.length > 0 && activeTab === "home" && ( +
+

Продолжить просмотр

+
+ {cinemaState.history.map((h, i) => { + const percent = + h.duration_seconds > 0 + ? Math.min(100, Math.round((h.progress_seconds / h.duration_seconds) * 100)) + : 0; + return ( +
{ + const found = [ + ...allTrending, + ...allMoviesList, + ...allSeriesList, + ...allAnimeList, + ].find((item) => item.id === h.title_id); + if (found) { + handleMediaCardClick(found); + } + }} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + const found = [ + ...allTrending, + ...allMoviesList, + ...allSeriesList, + ...allAnimeList, + ].find((item) => item.id === h.title_id); + if (found) { + handleMediaCardClick(found); + } + } + }} + > +
+ {h.title_name} +
+
▶
+
+
+
+
+
+
+ {h.title_name} + + {h.season > 0 ? `Сезон ${h.season}, Серия ${h.episode}` : "Фильм"} + +
+
+ ); + })} +
+
+ )} + + {/* Row 2: In Trend (All Movies & Series) */} + {activeTab === "home" && allTrending.length > 0 && ( +
+

В тренде на этой неделе

+
+ {allTrending.map((item) => ( +
handleMediaCardClick(item)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handleMediaCardClick(item); + } + }} + > +
+ {item.title} +
★ {item.rating}
+
+
▶
+
+
+
+ {item.title} + + {item.release_year} • {getMediaCategoryLabel(item)} + +
+
+ ))} +
+
+ )} + + {/* Row 3: All Movies */} + {(activeTab === "home" || activeTab === "movies") && ( +
+

+ {activeTab === "movies" ? "Все популярные фильмы" : "Фильмы"} +

+
+ {allMoviesList.map((movie) => ( +
handleMediaCardClick(movie)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handleMediaCardClick(movie); + } + }} + > +
+ {movie.title} +
★ {movie.rating}
+
+
▶
+
+
+
+ {movie.title} + + {movie.release_year} • {movie.genre} + +
+
+ ))} +
+
+ )} + + {/* Row 4: All TV Series */} + {(activeTab === "home" || activeTab === "series") && ( +
+

+ {activeTab === "series" ? "Все популярные сериалы" : "Сериалы"} +

+
+ {allSeriesList.map((series) => ( +
handleMediaCardClick(series)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handleMediaCardClick(series); + } + }} + > +
+ {series.title} +
★ {series.rating}
+
+
▶
+
+
+
+ {series.title} + + {series.release_year} • {series.seasons_count || 1} сезонов + +
+
+ ))} +
+
+ )} + + {/* Row 5: All Anime */} + {(activeTab === "home" || activeTab === "anime") && ( +
+

+ {activeTab === "anime" ? "Все популярные аниме" : "Аниме & Мультфильмы"} +

+
+ {allAnimeList.map((anime) => ( +
handleMediaCardClick(anime)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handleMediaCardClick(anime); + } + }} + > +
+ {anime.title} +
★ {anime.rating}
+
+
▶
+
+
+
+ {anime.title} + + {anime.release_year} • {anime.genre} + +
+
+ ))} +
+
+ )} + + {/* Row 6: My Bookmarks List */} + {activeTab === "bookmarks" && ( +
+

Мой список закладок

+ {bookmarkedItems.length === 0 ? ( +
+ В вашем списке пока нет сохраненных фильмов или сериалов. + + Нажмите на кнопку + на баннере любого тайтла, чтобы добавить его сюда. + +
+ ) : ( +
+ {bookmarkedItems.map((item) => ( +
handleMediaCardClick(item)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handleMediaCardClick(item); + } + }} + > +
+ {item.title} +
★ {item.rating}
+
+
▶
+
+
+
+ {item.title} + + {item.release_year} • {getMediaCategoryLabel(item)} + +
+
+ ))} +
+ )} +
+ )} +
+ + )} + + {/* ======================================================== + 4.5. DETAILED MOVIE & SERIES INFORMATION MODAL + ======================================================== */} + {detailsMedia && !playingEpisode && ( +
setDetailsMedia(null)} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + > + {/* Background Backdrop with Gradient Fades */} +
+ {detailsMedia.title} +
+ +
+ +
+ {/* Top Hero Grid: Poster on Left, Main Info on Right */} +
+
+ {detailsMedia.title} +
+ + ★ {detailsMedia.rating} + + {detailsMedia.kinopoisk_rating && ( + + {detailsMedia.kinopoisk_rating} + + )} + + {getMediaBadgeLabel(detailsMedia)} + +
+
+ +
+
+ {getMediaCategoryLabel(detailsMedia)} +
+ +

{detailsMedia.title}

+ {detailsMedia.original_title && detailsMedia.original_title !== detailsMedia.title && ( +

+ {detailsMedia.original_title} +

+ )} + + {detailsMedia.tagline && ( +

+ «{detailsMedia.tagline}» +

+ )} + + {/* Quick Stats Badges */} +
+ + 📅 {detailsMedia.release_year} + + + {detailsMedia.duration_minutes && detailsMedia.duration_minutes > 0 && ( + + ⏱ {formatRuntime(detailsMedia.duration_minutes)} + + )} + + {detailsMedia.seasons_count && detailsMedia.media_type !== "movie" && ( + + 📺 Сезонов: {detailsMedia.seasons_count} + + )} + + {detailsMedia.genre && ( + + 🎭 {detailsMedia.genre} + + )} + + {detailsMedia.countries && ( + + 🌍 {detailsMedia.countries} + + )} +
+ + {/* Financial & Production Specs Grid */} +
+
+ Рейтинг: + + ★ {detailsMedia.rating} / 10 + +
+ + {detailsMedia.director && ( +
+ + {detailsMedia.media_type === "movie" ? "Режиссер:" : "Создатели / Режиссер:"} + + {detailsMedia.director} +
+ )} + + {detailsMedia.media_type === "movie" && detailsMedia.budget && detailsMedia.budget > 0 ? ( +
+ Бюджет фильма: + + 💰 {formatCurrency(detailsMedia.budget)} + +
+ ) : null} + + {detailsMedia.media_type === "movie" && detailsMedia.revenue && detailsMedia.revenue > 0 ? ( +
+ Сборы в мире: + + 💵 {formatCurrency(detailsMedia.revenue)} + +
+ ) : null} + + {detailsMedia.media_type === "movie" && !detailsMedia.budget && !detailsMedia.revenue && ( +
+ Формат: + 🎬 Полнометражный фильм +
+ )} +
+ + {detailsMedia.is_unreleased && ( +
+ ⏳ Премьера ожидается: Фильм запланирован к выходу ( + {detailsMedia.release_date_full || detailsMedia.release_year}). Цифровой плеер + станет доступен сразу после официального цифрового релиза. +
+ )} + + {/* Action Buttons: Play + Bookmark + Trailer */} +
+ + + + + {detailsMedia.trailer_youtube_key && ( + + )} + + {isLoadingDetails && ( + Загрузка данных... + )} +
+ + {/* Description / Synopsis */} +
+

+ {detailsMedia.media_type === "movie" ? "О фильме" : "О сериале"} +

+

+ {detailsMedia.synopsis} +

+
+
+
+ + {/* Actors / Cast Carousel */} + {detailsMedia.cast && detailsMedia.cast.length > 0 && ( +
+

В главных ролях (Актеры)

+
+ {detailsMedia.cast.map((actor) => ( +
+
+ {actor.profile_path ? ( + {actor.name} + ) : ( +
+ {actor.name.slice(0, 1).toUpperCase()} +
+ )} +
+
{actor.name}
+ {actor.character && ( +
{actor.character}
+ )} +
+ ))} +
+
+ )} + + {/* ======================================================== + IF TV SERIES OR ANIME: SHOW SEASONS & EPISODES + IF MOVIE: ABSOLUTELY NO SEASONS OR EPISODES! + ======================================================== */} + {detailsMedia.media_type !== "movie" && detailsMedia.seasons.length > 0 && ( +
+

Сезоны и серии

+
+ {detailsMedia.seasons.map((season) => ( + + ))} +
+ +
+ {((detailsMedia.seasons.find((s) => s.season_number === selectedSeason) || + detailsMedia.seasons[0])?.episodes || []).map((ep) => ( +
handlePlayFromDetails(selectedSeason, ep)} + data-tv-focusable="true" + tabIndex={0} + role="button" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " " || e.keyCode === 23 || e.keyCode === 66) { + e.preventDefault(); + handlePlayFromDetails(selectedSeason, ep); + } + }} + > +
+ {ep.title} +
+
▶
+
+
+
+
+ {ep.number}. {ep.title} +
+ {ep.duration_minutes && ( +
{ep.duration_minutes} мин
+ )} +
+
+ ))} +
+
+ )} +
+
+
+ )} + + {/* ======================================================== + TRAILER MODAL + ======================================================== */} + {showTrailerModal && detailsMedia?.trailer_youtube_key && ( +
setShowTrailerModal(false)} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + > +
+ + Трейлер: {detailsMedia.title} + + +
+
+